Git development
 help / color / mirror / Atom feed
* Re: [PATCH v2 4/8] repository: stop initializing the object database in `repo_set_gitdir()`
From: Karthik Nayak @ 2026-06-03 12:49 UTC (permalink / raw)
  To: Patrick Steinhardt, git; +Cc: Kristoffer Haugsbakk, Junio C Hamano
In-Reply-To: <20260526-b4-pks-setup-centralize-odb-creation-v2-4-2fa5b385c13e@pks.im>

[-- Attachment #1: Type: text/plain, Size: 2655 bytes --]

Patrick Steinhardt <ps@pks.im> writes:

[snip]

> diff --git a/repository.c b/repository.c
> index 58a13f7c4f..2c2395105f 100644
> --- a/repository.c
> +++ b/repository.c
> @@ -181,12 +181,6 @@ void repo_set_gitdir(struct repository *repo,
>  	free(old_gitdir);
>
>  	repo_set_commondir(repo, o->commondir);
> -
> -	if (!repo->objects)
> -		repo->objects = odb_new(repo, o->object_dir, o->alternate_db);
> -	else if (!o->skip_initializing_odb)
> -		BUG("cannot reinitialize an already-initialized object directory");
> -

This always confuses me, so we were creating the odb even if
`o->skip_initializing_odb` was set to true, if `repo->objects` didn't
exist. Weird.

>  	repo->disable_ref_updates = o->disable_ref_updates;
>
>  	expand_base_dir(&repo->graft_file, o->graft_file,
> @@ -302,6 +296,8 @@ int repo_init(struct repository *repo,
>  		goto error;
>  	}
>
> +	repo->objects = odb_new(repo, NULL, NULL);
> +
>  	if (worktree)
>  		repo_set_worktree(repo, worktree);
>
> diff --git a/repository.h b/repository.h
> index c3ec0f4b79..36e2db2633 100644
> --- a/repository.h
> +++ b/repository.h
> @@ -221,12 +221,9 @@ const char *repo_get_work_tree(struct repository *repo);
>   */
>  struct set_gitdir_args {
>  	const char *commondir;
> -	const char *object_dir;
>  	const char *graft_file;
>  	const char *index_file;
> -	const char *alternate_db;
>  	bool disable_ref_updates;
> -	bool skip_initializing_odb;
>  };
>
>  void repo_set_gitdir(struct repository *repo, const char *root,
> diff --git a/setup.c b/setup.c
> index c5015923f1..3bd3f6c592 100644
> --- a/setup.c
> +++ b/setup.c
> @@ -1045,17 +1045,18 @@ static void setup_git_env_internal(struct repository *repo,
>  	struct strvec to_free = STRVEC_INIT;
>
>  	args.commondir = getenv_safe(&to_free, GIT_COMMON_DIR_ENVIRONMENT);
> -	args.object_dir = getenv_safe(&to_free, DB_ENVIRONMENT);
>  	args.graft_file = getenv_safe(&to_free, GRAFT_ENVIRONMENT);
>  	args.index_file = getenv_safe(&to_free, INDEX_ENVIRONMENT);
> -	args.alternate_db = getenv_safe(&to_free, ALTERNATE_DB_ENVIRONMENT);
>  	if (getenv(GIT_QUARANTINE_ENVIRONMENT))
>  		args.disable_ref_updates = true;
> -	args.skip_initializing_odb = skip_initializing_odb;
>
>  	repo_set_gitdir(repo, git_dir, &args);
>  	strvec_clear(&to_free);
>
> +	if (!skip_initializing_odb)
> +		repo->objects = odb_new(repo, getenv_safe(&to_free, DB_ENVIRONMENT),
> +					getenv_safe(&to_free, ALTERNATE_DB_ENVIRONMENT));
> +

Now this makes a lot more sense.

>  	if (getenv(NO_REPLACE_OBJECTS_ENVIRONMENT))
>  		disable_replace_refs();
>  	replace_ref_base = getenv(GIT_REPLACE_REF_BASE_ENVIRONMENT);
>
> --
> 2.54.0.926.g75ba10bac6.dirty

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 690 bytes --]

^ permalink raw reply

* Re: [PATCH v2 3/8] setup: deduplicate logic to apply repository format
From: Karthik Nayak @ 2026-06-03 12:43 UTC (permalink / raw)
  To: Patrick Steinhardt, git; +Cc: Kristoffer Haugsbakk, Junio C Hamano
In-Reply-To: <20260526-b4-pks-setup-centralize-odb-creation-v2-3-2fa5b385c13e@pks.im>

[-- Attachment #1: Type: text/plain, Size: 4439 bytes --]

Patrick Steinhardt <ps@pks.im> writes:

> After having discovered the repository format we then apply it to the
> repository so that it knows to use the proper repository extensions. The
> logic to apply the format is duplicated across three callsites, which
> makes it rather painfull to add new extensions.
>
> Introduce a new function `apply_repository_format()` that takes a repo
> and applies a given format to it and adapt all callsites to use it.
> While at it, rename `check_repository_format()` to clarify that it
> doesn't only _check_ the format, but that it also applies it.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  repository.c | 31 +++++++-------------
>  setup.c      | 93 ++++++++++++++++++++++++++++++++----------------------------
>  setup.h      |  9 ++++++
>  3 files changed, 70 insertions(+), 63 deletions(-)
>
> diff --git a/repository.c b/repository.c
> index db57b8308b..58a13f7c4f 100644
> --- a/repository.c
> +++ b/repository.c
> @@ -262,8 +262,8 @@ void repo_set_worktree(struct repository *repo, const char *path)
>  	trace2_def_repo(repo);
>  }
>
> -static int read_and_verify_repository_format(struct repository_format *format,
> -					     const char *commondir)
> +static int read_repository_format_from_commondir(struct repository_format *format,
> +						 const char *commondir)

Nit: The commit explicitly calls out one rename, but this one wasn't.

>  {
>  	int ret = 0;
>  	struct strbuf sb = STRBUF_INIT;
> @@ -272,11 +272,6 @@ static int read_and_verify_repository_format(struct repository_format *format,
>  	read_repository_format(format, sb.buf);
>  	strbuf_reset(&sb);
>
> -	if (verify_repository_format(format, &sb) < 0) {
> -		warning("%s", sb.buf);
> -		ret = -1;
> -	}
> -

So we remove this, so that the callee would independently verify the
format I assume.

Edit: seems like we call verify_repository_format() within
apply_repository_format() and the latter is called by the callee.

>  	strbuf_release(&sb);
>  	return ret;
>  }
> @@ -290,6 +285,8 @@ int repo_init(struct repository *repo,
>  	      const char *worktree)
>  {
>  	struct repository_format format = REPOSITORY_FORMAT_INIT;
> +	struct strbuf err = STRBUF_INIT;
> +
>  	memset(repo, 0, sizeof(*repo));
>
>  	initialize_repository(repo);
> @@ -297,21 +294,13 @@ int repo_init(struct repository *repo,
>  	if (repo_init_gitdir(repo, gitdir))
>  		goto error;
>
> -	if (read_and_verify_repository_format(&format, repo->commondir))
> +	if (read_repository_format_from_commondir(&format, repo->commondir))
>  		goto error;
>
> -	repo_set_hash_algo(repo, format.hash_algo);
> -	repo_set_compat_hash_algo(repo, format.compat_hash_algo);
> -	repo_set_ref_storage_format(repo, format.ref_storage_format,
> -				    format.ref_storage_payload);
> -	repo->repository_format_worktree_config = format.worktree_config;
> -	repo->repository_format_relative_worktrees = format.relative_worktrees;
> -	repo->repository_format_precious_objects = format.precious_objects;
> -	repo->repository_format_submodule_path_cfg = format.submodule_path_cfg;
> -
> -	/* take ownership of format.partial_clone */

I see that we now do an xstrdup for format.partial_clone, meaning we
have our own memory segment to care about. Do we have to worry about
format.partial_clone not being free'd?

> -	repo->repository_format_partial_clone = format.partial_clone;
> -	format.partial_clone = NULL;
> +	if (apply_repository_format(repo, &format, &err) < 0) {
> +		warning("%s", err.buf);
> +		goto error;
> +	}
>
>  	if (worktree)
>  		repo_set_worktree(repo, worktree);

[snip]

> diff --git a/setup.h b/setup.h
> index 9409326fe4..5ed92f53fa 100644
> --- a/setup.h
> +++ b/setup.h
> @@ -221,6 +221,15 @@ void clear_repository_format(struct repository_format *format);
>  int verify_repository_format(const struct repository_format *format,
>  			     struct strbuf *err);
>
> +/*
> + * Apply the given repository format to the repo. This initializes extensions
> + * and basic data structures required for normal operation. Returns 0 on
> + * success, a negative error code otherwise.
> + */

Nit: perhaps we should also mention that we verify the format?

> +int apply_repository_format(struct repository *repo,
> +			    const struct repository_format *format,
> +			    struct strbuf *err);
> +
>  const char *get_template_dir(const char *option_template);
>
>  #define INIT_DB_QUIET      (1 << 0)
>
> --
> 2.54.0.926.g75ba10bac6.dirty
p

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 690 bytes --]

^ permalink raw reply

* Re: Git for Windows Failing to Clone
From: Johannes Schindelin @ 2026-06-03 12:42 UTC (permalink / raw)
  To: Dylan Carlyle; +Cc: git
In-Reply-To: <CAJKusd6WJUUVhbyN_-XHkGWVYeNe_=K2U3tZoezPWFG3+OG_zw@mail.gmail.com>

Hi Dylan,

On Wed, 3 Jun 2026, Dylan Carlyle wrote:

> Thank you for filling out a Git bug report!
> Please answer the following questions to help us understand your issue.
> 
> What did you do before the bug happened? (Steps to reproduce your issue):
> 
> Ran git clone user@ip_addres:repo
> 
> What did you expect to happen? (Expected behavior):
> 
> The repo to be cloned
> 
> What happened instead? (Actual behavior):
> 
> remote: Enumerating objects: 57873, done.
> remote: Counting objects: 100% (57873/57873), done.
> remote: Compressing objects: 100% (32002/32002), done.
> fatal: pack has bad object at offset 460179591: inflate returned 1
> fatal: fetch-pack: invalid index-pack output
> 
> What's different between what you expected and what actually happened?
> 
> The clone never finishes on Windows.
> 
> Anything else you want to add:
> 
> Git version on the remote server is 2.47.3
> This works fine from Linux but fails on Windows.

I guess that this is virtually identical to
https://github.com/git-for-windows/git/issues/6265

Since it works from Linux, but not from Windows, I strongly suspect the
problem to be related to that vexing 2GB/4GB problem induced by Git's
continued use of `unsigned long` instead of `size_t`, which I am slowly
(_very_ slowly) trying to address.

You can find out whether that effort might help you, by running `git repo
structure` on the original repository (I'd expect a blob whose unpacked
size is larger than 4GB).

Ciao,
Johannes

> 
> [System Info]
> git version:
> git version 2.54.0.windows.1
> cpu: x86_64
> built from commit: 2b8a3ab140826ac423c2845ef81d4c6ac4f7bf3c
> sizeof-long: 4
> sizeof-size_t: 8
> shell-path: D:/git-sdk-64-build-installers/usr/bin/sh
> rust: disabled
> feature: fsmonitor--daemon
> 
> -- 
> Kind Regards,
> 
> Dylan Carlyle
> REFTEK Systems, Inc.
> Systems Administrator
> 
> 

^ permalink raw reply

* Re: [PATCH v3] index-pack: retain child bases in delta cache
From: Derrick Stolee @ 2026-06-03 12:24 UTC (permalink / raw)
  To: Arijit Banerjee via GitGitGadget, git
  Cc: Ævar Arnfjörð Bjarmason, Junio C Hamano, Jeff King,
	Arijit Banerjee, Arijit Banerjee
In-Reply-To: <pull.2131.v3.git.1780445118653.gitgitgadget@gmail.com>

On 6/2/26 8:05 PM, Arijit Banerjee via GitGitGadget wrote:

>      Changes since v2:
>      
>       * Addressed Jeff King's review question by releasing cached base data
>         after all direct children have been dispatched, while keeping the
>         existing subtree bookkeeping intact.
>       * Re-ran t/t5302-pack-index.sh, p5302-pack-index.sh, and end-to-end
>         full clone spot checks with the precise-release version.
...
> +static int base_data_has_remaining_direct_children(struct base_data *c)
> +{
> +	return c->ref_first <= c->ref_last ||
> +	       c->ofs_first <= c->ofs_last;
> +}
> +

I'm glad you were able to find some bookkeeping that already exists to
help with this decision.



>   static void prune_base_data(struct base_data *retain)
>   {
>   	struct list_head *pos;
> @@ -1201,8 +1207,12 @@ static void *threaded_second_pass(void *data)
>   		}
>   
>   		work_lock();
> -		if (parent)
> +		if (parent) {
>   			parent->retain_data--;
> +			if (!parent->retain_data &&
> +			    !base_data_has_remaining_direct_children(parent))
> +				free_base_data(parent);
> +		}

This appears like the correct place to do this.

>   		if (child && child->data) {
>   			/*
> @@ -1212,7 +1222,6 @@ static void *threaded_second_pass(void *data)
>   			list_add(&child->list, &work_head);
>   			base_cache_used += child->size;
>   			prune_base_data(NULL);
> -			free_base_data(child);

And still we don't want this universal free.

Thanks for re-running your performance numbers after this change. I didn't
see any significant difference in the relative changes.

I don't think we have a way of measuring "memory pressure" during the
performance test suite. Did you see any evidence that this change has the
intended effect of reducing process memory proactively instead of relying
on the cache evictions?

Thanks,
-Stolee

^ permalink raw reply

* Re: [PATCH 1/2] b4: introduce configuration for the Git project
From: Weijie Yuan @ 2026-06-03 12:23 UTC (permalink / raw)
  To: SZEDER Gábor; +Cc: Patrick Steinhardt, Tuomas Ahola, git, Junio C Hamano
In-Reply-To: <aiAK9eLvew+mgWt+@szeder.dev>

On Wed, Jun 03, 2026 at 01:07:33PM +0200, SZEDER Gábor wrote:
> No, in Git shallow threading means that all patches are sent as a
> respose to the current cover letter, period.  It has nothing to do
> with whether the current cover letter is sent as a reply to the cover
> letter of the first or the previous version.

Thanks, agree

> Deep threading means that every mail is a reply to the previous one.
> Again, it has nothing to do with the relation of the current cover
> letter and the previous cover letters.
>
> Therefore, we do not recommend deep threading.

So the same reason with Patrick?

Thanks

^ permalink raw reply

* Re: [PATCH v2 1/8] t0001: plug test gaps for git-init(1) with GIT_OBJECT_DIRECTORY
From: Karthik Nayak @ 2026-06-03 12:22 UTC (permalink / raw)
  To: Patrick Steinhardt, git; +Cc: Kristoffer Haugsbakk, Junio C Hamano
In-Reply-To: <20260526-b4-pks-setup-centralize-odb-creation-v2-1-2fa5b385c13e@pks.im>

[-- Attachment #1: Type: text/plain, Size: 1861 bytes --]

Patrick Steinhardt <ps@pks.im> writes:

> In subsequent commits we'll rework how we set up the repository. This is
> a somewhat intricate and thus fragile sequence; there's many things that
> can go subtly wrong, and there are lots of interesting interactions that
> one can discover.
>
> One such discovered edge case was the interaction between git-init(1)
> and the "GIT_OBJECT_DIRECTORY" environment variable. When set, the
> behaviour is that the object directory should be created at the path
> that the variable points to. This behaviour is documented as such in
> its man page:
>
>   If the object storage directory is specified via the
>   GIT_OBJECT_DIRECTORY environment variable then the sha1 directories
>   are created underneath; otherwise, the default $GIT_DIR/objects
>   directory is used.
>
> Curiously enough though we don't seem to have any tests that exercise
> this directly, and thus a subsequent commit inadvertently would have
> broken this expectation.
>
> Plug this test gap.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  t/t0001-init.sh | 10 ++++++++++
>  1 file changed, 10 insertions(+)
>
> diff --git a/t/t0001-init.sh b/t/t0001-init.sh
> index e4d32bb4d2..e89feca544 100755
> --- a/t/t0001-init.sh
> +++ b/t/t0001-init.sh
> @@ -980,4 +980,14 @@ test_expect_success 're-init reads matching includeIf.onbranch' '
>  	test_cmp expect err
>  '
>
> +test_expect_success 'init honors GIT_OBJECT_DIRECTORY' '
> +	test_when_finished "rm -rf init-objdir custom-odb" &&
> +	mkdir custom-odb &&
> +	env GIT_OBJECT_DIRECTORY="$(pwd)/custom-odb" \
> +		git init init-objdir &&
> +	test_path_is_missing init-objdir/.git/objects/pack &&
> +	test_path_is_dir custom-odb/pack &&
> +	test_path_is_dir custom-odb/info
> +'
> +

I was surprised to find that such a small number of tests ever use
GIT_OBJECT_DIRECTORY. This looks good.

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 690 bytes --]

^ permalink raw reply

* [PATCH 2/2] builtin/add: use die_for_required_opt() helper
From: Siddharth Shrimali @ 2026-06-03 11:10 UTC (permalink / raw)
  To: git; +Cc: gitster, christian.couder, toon, jn.avila, r.siddharth.shrimali
In-Reply-To: <20260603111044.39116-1-r.siddharth.shrimali@gmail.com>

Clean up manual option dependency checks by replacing explicit conditional
blocks with the newly introduced die_for_required_opt() helper function.

Specifically, simplify the prerequisite check logic for both
'--ignore-missing' (which requires '--dry-run') and '--pathspec-file-nul'
(which requires '--pathspec-from-file').

Signed-off-by: Siddharth Shrimali <r.siddharth.shrimali@gmail.com>
---
 builtin/add.c | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/builtin/add.c b/builtin/add.c
index c859f66519..a5c91c6dcf 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -441,8 +441,7 @@ int cmd_add(int argc,
 	if (addremove && take_worktree_changes)
 		die(_("options '%s' and '%s' cannot be used together"), "-A", "-u");
 
-	if (!show_only && ignore_missing)
-		die(_("the option '%s' requires '%s'"), "--ignore-missing", "--dry-run");
+	die_for_required_opt(ignore_missing, "--ignore-missing", show_only, "--dry-run");
 
 	if (chmod_arg && ((chmod_arg[0] != '-' && chmod_arg[0] != '+') ||
 			  chmod_arg[1] != 'x' || chmod_arg[2]))
@@ -462,6 +461,8 @@ int cmd_add(int argc,
 		       PATHSPEC_SYMLINK_LEADING_PATH,
 		       prefix, argv);
 
+	die_for_required_opt(pathspec_file_nul, "--pathspec-file-nul",
+				!!pathspec_from_file, "--pathspec-from-file");
 	if (pathspec_from_file) {
 		if (pathspec.nr)
 			die(_("'%s' and pathspec arguments cannot be used together"), "--pathspec-from-file");
@@ -470,8 +471,6 @@ int cmd_add(int argc,
 				    PATHSPEC_PREFER_FULL |
 				    PATHSPEC_SYMLINK_LEADING_PATH,
 				    prefix, pathspec_from_file, pathspec_file_nul);
-	} else if (pathspec_file_nul) {
-		die(_("the option '%s' requires '%s'"), "--pathspec-file-nul", "--pathspec-from-file");
 	}
 
 	if (require_pathspec && pathspec.nr == 0) {
-- 
2.54.0


^ permalink raw reply related

* [PATCH 1/2] parse-options: introduce die_for_required_opt()
From: Siddharth Shrimali @ 2026-06-03 11:10 UTC (permalink / raw)
  To: git; +Cc: gitster, christian.couder, toon, jn.avila, r.siddharth.shrimali
In-Reply-To: <20260603111044.39116-1-r.siddharth.shrimali@gmail.com>

Introduce a new helper function die_for_required_opt() to check if a
given option is present without its required prerequisite option.

This provides a centralized API for handling simple option dependencies
(i.e., X requires Y), matching the style of the existing mutual-exclusion
helpers like die_for_incompatible_opt{2,3,4}().

Suggested-by: Christian Couder <christian.couder@gmail.com>
Signed-off-by: Siddharth Shrimali <r.siddharth.shrimali@gmail.com>
---
 parse-options.c | 7 +++++++
 parse-options.h | 3 +++
 2 files changed, 10 insertions(+)

diff --git a/parse-options.c b/parse-options.c
index a676da86f5..e100f9a0c1 100644
--- a/parse-options.c
+++ b/parse-options.c
@@ -1558,3 +1558,10 @@ void die_for_incompatible_opt4(int opt1, const char *opt1_name,
 		break;
 	}
 }
+
+void die_for_required_opt(int opt1, const char *opt1_name,
+			  int opt2, const char *opt2_name)
+{
+	if (opt1 && !opt2)
+		die(_("the option '%s' requires '%s'"), opt1_name, opt2_name);
+}
diff --git a/parse-options.h b/parse-options.h
index 0d1f738f8d..99dc53325d 100644
--- a/parse-options.h
+++ b/parse-options.h
@@ -460,6 +460,9 @@ static inline void die_for_incompatible_opt2(int opt1, const char *opt1_name,
 				  0, "");
 }
 
+void die_for_required_opt(int opt1, const char *opt1_name,
+			  int opt2, const char *opt2_name);
+
 /*
  * Use these assertions for callbacks that expect to be called with NONEG and
  * NOARG respectively, and do not otherwise handle the "unset" and "arg"
-- 
2.54.0


^ permalink raw reply related

* [PATCH 0/2] parse-options: introduce die_for_required_opt() helper
From: Siddharth Shrimali @ 2026-06-03 11:10 UTC (permalink / raw)
  To: git; +Cc: gitster, christian.couder, toon, jn.avila, r.siddharth.shrimali

Many built-in commands in Git manually check for option prerequisites 
(i.e., option X relies on option Y being present) using explicit 
conditional blocks and duplicated error message strings.

This short series comes out of a discussion with Christian about 
localization and code duplication. To address these issues, it 
introduces a centralized API helper that handles simple option 
prerequisites safely.

- Patch 1 introduces the `die_for_required_opt()` helper function 
  inside parse-options.
  
- Patch 2 cleans up `builtin/add.c` as a proof-of-concept by migrating 
  its manual prerequisite checks for '--ignore-missing' and 
  '--pathspec-file-nul' over to the new helper.

If this initial approach looks good, we can later extend the helper 
to handle more complex multi-option dependencies.

Siddharth Shrimali (2):
  parse-options: introduce die_for_required_opt()
  builtin/add: use die_for_required_opt() helper

 builtin/add.c   | 7 +++----
 parse-options.c | 7 +++++++
 parse-options.h | 3 +++
 3 files changed, 13 insertions(+), 4 deletions(-)

-- 
2.54.0


^ permalink raw reply

* Re: [PATCH 1/2] b4: introduce configuration for the Git project
From: SZEDER Gábor @ 2026-06-03 11:07 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: Weijie Yuan, Tuomas Ahola, git, Junio C Hamano
In-Reply-To: <ah_PyDwO1Sffr5yq@pks.im>

On Wed, Jun 03, 2026 at 08:55:04AM +0200, Patrick Steinhardt wrote:
> On Wed, Jun 03, 2026 at 10:12:22AM +0800, Weijie Yuan wrote:
> > On Tue, Jun 02, 2026 at 08:09:55PM +0300, Tuomas Ahola wrote:
> > > Huh?  Doesn't MyFirstContribution speak *against* shallow threading?
> > >
> > > 	        [...]  make sure to replace it with the correct Message-ID for your
> > > 	**previous cover letter** - that is, if you're sending v2, use the Message-ID
> > > 	from v1; if you're sending v3, use the Message-ID from v2.
> > 
> > I don't get it. Doesn't shallow threading means every following patches
> > are replying to the cover letter? Replying to the previous one is
> > --chain-reply-to, if I'm not mistaken.
> 
> Shallow threading basically means that all patches are sent as a
> response to the current cover letter, and the current cover letter is
> always attached to the cover letter of the _first_ version.

No, in Git shallow threading means that all patches are sent as a
respose to the current cover letter, period.  It has nothing to do
with whether the current cover letter is sent as a reply to the cover
letter of the first or the previous version.

> So this quote is definitely at odds with the configuration I have
> proposed. It's actually quite surprising to me that we recommend deep
> threading -- I personally find it extremely hard to navigate as the
> nesting eventually gets way too deep.

Deep threading means that every mail is a reply to the previous one.
Again, it has nothing to do with the relation of the current cover
letter and the previous cover letters.

Therefore, we do not recommend deep threading.

> You know -- I'll include a patch that changes the wording there to also
> use shallow nesting, mostly to kick off a discussion and arrive at a
> decision there.



^ permalink raw reply

* Re: [PATCH v2 1/3] Documentation/MyFirstContribution: recommend shallow threading
From: Weijie Yuan @ 2026-06-03 10:29 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git, Junio C Hamano, Tuomas Ahola, Ramsay Jones
In-Reply-To: <20260603-pks-b4-v2-1-a8aea0aa2c23@pks.im>

I'm afraid there will be some chaos.

As mentioned earlier, GitGitGadget now supports deep nesting of
iterations, if b4 changes while GitGitGadget doesn't, it would be
inconsistent in the archive. So, negotiation is necessary here.

As I know, b4 can generate a cover letter containing "Changes with vn",
e.g. in [PATCH v5 00/10], there would be Changes with v4, v3, v2, v1. In
this case, it is semantically correct that the cover letter of v5 is
replying-to the cover letter of v1.

But in traditional way, it seems that the norm is put a range-diff in
the cover letter. In this case, chain-reply-to makes more sence to me:
e.g. The cover letter contains the range-diff against v2, so cover
letter v3 is pointing to cover letter v2. (I don't know whether
git format-patch accepts several --range-diff or not, but if so, I
guess it might be painful to typing several refs, or copy and paste
from previous cover letter) Therefore, if git format-patch could
generate cover letter containing all the changes with v4/v3/v2/v1 as b4
does, it would be consistent, and semantically correct to pointing to
the first cover letter.

Do we need to consider backward compatibility here? ;-)

Thanks!

^ permalink raw reply

* [PATCH v2] rebase: skip branch symref aliases
From: Son Luong Ngoc via GitGitGadget @ 2026-06-03 10:27 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Phillip Wood, Son Luong Ngoc,
	Son Luong Ngoc
In-Reply-To: <pull.2126.git.1779946921.gitgitgadget@gmail.com>

From: Son Luong Ngoc <sluongng@gmail.com>

git rebase --update-refs can fail after the normal rebase path has
updated the current branch when another local branch is a symref to it.
This can happen during a default-branch rename where refs/heads/main
points at refs/heads/master while users migrate.

The sequencer queues update-ref commands from local branch decorations.
Commit 106b6885c7 (rebase: ignore non-branch update-refs) filters out
decorations that are not local branches, such as HEAD and tags. A branch
symref is different: it is still a local branch decoration, but if it
resolves to another branch then that target branch is itself present in
the decoration list and will be updated as a concrete branch.

Skip branch decorations whose symrefs resolve to refs/heads/*, because
those targets are already represented by concrete branch decorations.
This prevents aliases from scheduling a second update for the same
branch. Keep symrefs to non-branch targets on the existing path.

Preserve the existing checked-out branch handling before applying these
skips. Such refs still need a todo-list comment instead of an update-ref
command, even when the checked-out ref is the branch being rebased or a
branch symref alias. Use a copy of the resolved HEAD ref so later ref
resolution does not overwrite it.

Signed-off-by: Son Luong Ngoc <sluongng@gmail.com>
---
    rebase: handle --update-refs branch symrefs
    
    Changes since v1:
    
     * Squashed the regression test and fix into a single patch.
     * Reworked the implementation per Phillip's review: skip local branch
       symrefs that are not checked out when their targets resolve to
       refs/heads/*, while preserving the existing behavior for symrefs to
       non-branch targets.
     * Preserved checked-out branch comments before applying the new symref
       skip, so worktrees still get the existing todo-list warning instead
       of an update-ref command.
     * Folded the symref coverage into the existing "--update-refs updates
       refs correctly" test, covering both an alias of HEAD and an alias of
       another branch.
     * Kept the relationship to 106b6885c7 in the commit message: non-local
       decorations are already filtered, and this patch handles the
       remaining local branch decorations that are symref aliases.

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2126%2Fsluongng%2Fsl%2Frebase-update-refs-symrefs-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2126/sluongng/sl/rebase-update-refs-symrefs-v2
Pull-Request: https://github.com/gitgitgadget/git/pull/2126

Range-diff vs v1:

 1:  a550923440 < -:  ---------- t3404: add failing branch symref test
 2:  0ab0a71744 ! 1:  68f698225c rebase: skip branch symref aliases
     @@ Metadata
       ## Commit message ##
          rebase: skip branch symref aliases
      
     -    rebase --update-refs records local branch decorations before replaying
     -    commits. If a decoration is a symbolic branch such as refs/heads/main
     -    pointing at refs/heads/master, updating it later dereferences back to
     -    master and can fail because the normal rebase path already moved that
     -    branch.
     +    git rebase --update-refs can fail after the normal rebase path has
     +    updated the current branch when another local branch is a symref to it.
     +    This can happen during a default-branch rename where refs/heads/main
     +    points at refs/heads/master while users migrate.
      
     -    Resolve local branch symref decorations to their referents before
     -    queuing update-ref commands, and skip duplicates. This keeps branch
     -    aliases from scheduling a second update for the same underlying branch
     -    while still using the existing old-OID check for the single queued
     -    update.
     +    The sequencer queues update-ref commands from local branch decorations.
     +    Commit 106b6885c7 (rebase: ignore non-branch update-refs) filters out
     +    decorations that are not local branches, such as HEAD and tags. A branch
     +    symref is different: it is still a local branch decoration, but if it
     +    resolves to another branch then that target branch is itself present in
     +    the decoration list and will be updated as a concrete branch.
     +
     +    Skip branch decorations whose symrefs resolve to refs/heads/*, because
     +    those targets are already represented by concrete branch decorations.
     +    This prevents aliases from scheduling a second update for the same
     +    branch. Keep symrefs to non-branch targets on the existing path.
     +
     +    Preserve the existing checked-out branch handling before applying these
     +    skips. Such refs still need a todo-list comment instead of an update-ref
     +    command, even when the checked-out ref is the branch being rebased or a
     +    branch symref alias. Use a copy of the resolved HEAD ref so later ref
     +    resolution does not overwrite it.
      
          Signed-off-by: Son Luong Ngoc <sluongng@gmail.com>
      
     @@ sequencer.c: static int add_decorations_to_list(const struct commit *commit,
       	const struct name_decoration *decoration = get_name_decoration(&commit->object);
      -	const char *head_ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
      -						       "HEAD",
     -+	struct ref_store *refs = get_main_ref_store(the_repository);
     -+	const char *head_ref = refs_resolve_ref_unsafe(refs, "HEAD",
     - 						       RESOLVE_REF_READING,
     +-						       RESOLVE_REF_READING,
      -						       NULL,
      -						       NULL);
     -+						       NULL, NULL);
     -+	char *resolved_head_ref = refs_resolve_refdup(refs, "HEAD",
     -+						       RESOLVE_REF_READING,
     -+						       NULL, NULL);
     -+	struct strbuf update_ref = STRBUF_INIT;
     ++	struct ref_store *refs = get_main_ref_store(the_repository);
     ++	char *head_ref = refs_resolve_refdup(refs, "HEAD",
     ++					     RESOLVE_REF_READING,
     ++					     NULL, NULL);
       
       	while (decoration) {
       		struct todo_item *item;
       		const char *path;
     -+		const char *ref = decoration->name;
      +		const char *resolved_ref;
     -+		int is_symref = 0;
      +		int flags = 0;
       		size_t base_offset = ctx->buf->len;
       
       		/*
     -@@ sequencer.c: static int add_decorations_to_list(const struct commit *commit,
     - 		 * updated by the default rebase behavior.
     - 		 * Exclude it from the list of refs to update,
     - 		 * as well as any non-branch decorations.
     -+		 *
     -+		 * Resolve branch symrefs after checking for the current HEAD so
     -+		 * that aliases do not schedule duplicate updates for their
     -+		 * referents.
     -+		 *
     +-		 * If the branch is the current HEAD, then it will be
     +-		 * updated by the default rebase behavior.
     +-		 * Exclude it from the list of refs to update,
     +-		 * as well as any non-branch decorations.
       		 * Non-branch decorations may be present if the pretty format
       		 * includes "%d", which would have loaded all refs
       		 * into the global decoration table.
     @@ sequencer.c: static int add_decorations_to_list(const struct commit *commit,
      +			continue;
      +		}
      +
     -+		if (head_ref && !strcmp(head_ref, ref)) {
     ++		path = branch_checked_out(decoration->name);
     ++
     ++		/*
     ++		 * If the branch is the current HEAD, then it will be
     ++		 * updated by the default rebase behavior. Exclude it from
     ++		 * the list of refs to update, unless it is checked out and
     ++		 * needs a comment in the todo list.
     ++		 */
     ++		if (!path && head_ref && !strcmp(head_ref, decoration->name)) {
      +			decoration = decoration->next;
      +			continue;
      +		}
      +
     -+		strbuf_reset(&update_ref);
     -+		resolved_ref = refs_resolve_ref_unsafe(refs, ref,
     -+						       RESOLVE_REF_READING |
     -+						       RESOLVE_REF_NO_RECURSE,
     ++		resolved_ref = refs_resolve_ref_unsafe(refs, decoration->name,
     ++						       RESOLVE_REF_READING,
      +						       NULL, &flags);
     -+		if ((flags & REF_ISSYMREF) && resolved_ref) {
     -+			if (!starts_with(resolved_ref, "refs/heads/")) {
     -+				decoration = decoration->next;
     -+				continue;
     -+			}
     -+
     -+			strbuf_addstr(&update_ref, resolved_ref);
     -+			ref = update_ref.buf;
     -+			is_symref = 1;
     -+		}
     -+
     -+		if ((is_symref && resolved_head_ref &&
     -+		     !strcmp(resolved_head_ref, ref)) ||
     -+		    string_list_has_string(&ctx->refs_to_oids, ref)) {
     ++		if (!path && resolved_ref && (flags & REF_ISSYMREF) &&
     ++		    starts_with(resolved_ref, "refs/heads/")) {
       			decoration = decoration->next;
       			continue;
       		}
     @@ sequencer.c: static int add_decorations_to_list(const struct commit *commit,
       
       		/* If the branch is checked out, then leave a comment instead. */
      -		if ((path = branch_checked_out(decoration->name))) {
     -+		if ((path = branch_checked_out(ref))) {
     ++		if (path) {
       			item->command = TODO_COMMENT;
       			strbuf_commented_addf(ctx->buf, comment_line_str,
       					      "Ref %s checked out at '%s'\n",
     --					      decoration->name, path);
     -+					      ref, path);
     - 		} else {
     - 			struct string_list_item *sti;
     - 			item->command = TODO_UPDATE_REF;
     --			strbuf_addf(ctx->buf, "%s\n", decoration->name);
     -+			strbuf_addf(ctx->buf, "%s\n", ref);
     - 
     - 			sti = string_list_insert(&ctx->refs_to_oids,
     --						 decoration->name);
     --			sti->util = init_update_ref_record(decoration->name);
     -+						 ref);
     -+			sti->util = init_update_ref_record(ref);
     - 		}
     - 
     - 		item->offset_in_buf = base_offset;
      @@ sequencer.c: static int add_decorations_to_list(const struct commit *commit,
       		decoration = decoration->next;
       	}
       
     -+	strbuf_release(&update_ref);
     -+	free(resolved_head_ref);
     ++	free(head_ref);
       	return 0;
       }
       
      
       ## t/t3404-rebase-interactive.sh ##
      @@ t/t3404-rebase-interactive.sh: test_expect_success '--update-refs ignores non-branch decorations' '
     - 	test_cmp expect actual
       '
       
     --test_expect_failure '--update-refs skips branch symrefs to current branch' '
     -+test_expect_success '--update-refs skips branch symrefs to current branch' '
     - 	test_when_finished "
     - 		test_might_fail git rebase --abort &&
     - 		git checkout primary &&
     + test_expect_success '--update-refs updates refs correctly' '
     ++	test_when_finished "
     ++		test_might_fail git symbolic-ref -d refs/heads/no-conflict-branch-alias &&
     ++		test_might_fail git symbolic-ref -d refs/heads/second-alias
     ++	" &&
     + 	git checkout -B update-refs no-conflict-branch &&
     + 	git branch -f base HEAD~4 &&
     + 	git branch -f first HEAD~3 &&
     + 	git branch -f second HEAD~3 &&
     + 	git branch -f third HEAD~1 &&
     ++	git symbolic-ref refs/heads/no-conflict-branch-alias \
     ++		refs/heads/no-conflict-branch &&
     ++	git symbolic-ref refs/heads/second-alias refs/heads/second &&
     + 	test_commit extra2 fileX &&
     + 	git commit --amend --fixup=L &&
     + 
     +@@ t/t3404-rebase-interactive.sh: test_expect_success '--update-refs updates refs correctly' '
     + 
     + 	test_cmp_rev HEAD~3 refs/heads/first &&
     + 	test_cmp_rev HEAD~3 refs/heads/second &&
     ++	test_cmp_rev HEAD~3 refs/heads/second-alias &&
     + 	test_cmp_rev HEAD~1 refs/heads/third &&
     + 	test_cmp_rev HEAD refs/heads/no-conflict-branch &&
     ++	test_cmp_rev HEAD refs/heads/no-conflict-branch-alias &&
     ++	test_write_lines refs/heads/no-conflict-branch >expect &&
     ++	git symbolic-ref refs/heads/no-conflict-branch-alias >actual &&
     ++	test_cmp expect actual &&
     ++	test_write_lines refs/heads/second >expect &&
     ++	git symbolic-ref refs/heads/second-alias >actual &&
     ++	test_cmp expect actual &&
     + 
     + 	q_to_tab >expect <<-\EOF &&
     + 	Successfully rebased and updated refs/heads/update-refs.


 sequencer.c                   | 43 +++++++++++++++++++++++++----------
 t/t3404-rebase-interactive.sh | 15 ++++++++++++
 2 files changed, 46 insertions(+), 12 deletions(-)

diff --git a/sequencer.c b/sequencer.c
index 1ee4b2875b..6ab8b47108 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -6445,28 +6445,46 @@ static int add_decorations_to_list(const struct commit *commit,
 				   struct todo_add_branch_context *ctx)
 {
 	const struct name_decoration *decoration = get_name_decoration(&commit->object);
-	const char *head_ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
-						       "HEAD",
-						       RESOLVE_REF_READING,
-						       NULL,
-						       NULL);
+	struct ref_store *refs = get_main_ref_store(the_repository);
+	char *head_ref = refs_resolve_refdup(refs, "HEAD",
+					     RESOLVE_REF_READING,
+					     NULL, NULL);
 
 	while (decoration) {
 		struct todo_item *item;
 		const char *path;
+		const char *resolved_ref;
+		int flags = 0;
 		size_t base_offset = ctx->buf->len;
 
 		/*
-		 * If the branch is the current HEAD, then it will be
-		 * updated by the default rebase behavior.
-		 * Exclude it from the list of refs to update,
-		 * as well as any non-branch decorations.
 		 * Non-branch decorations may be present if the pretty format
 		 * includes "%d", which would have loaded all refs
 		 * into the global decoration table.
 		 */
-		if ((head_ref && !strcmp(head_ref, decoration->name)) ||
-		    (decoration->type != DECORATION_REF_LOCAL)) {
+		if (decoration->type != DECORATION_REF_LOCAL) {
+			decoration = decoration->next;
+			continue;
+		}
+
+		path = branch_checked_out(decoration->name);
+
+		/*
+		 * If the branch is the current HEAD, then it will be
+		 * updated by the default rebase behavior. Exclude it from
+		 * the list of refs to update, unless it is checked out and
+		 * needs a comment in the todo list.
+		 */
+		if (!path && head_ref && !strcmp(head_ref, decoration->name)) {
+			decoration = decoration->next;
+			continue;
+		}
+
+		resolved_ref = refs_resolve_ref_unsafe(refs, decoration->name,
+						       RESOLVE_REF_READING,
+						       NULL, &flags);
+		if (!path && resolved_ref && (flags & REF_ISSYMREF) &&
+		    starts_with(resolved_ref, "refs/heads/")) {
 			decoration = decoration->next;
 			continue;
 		}
@@ -6478,7 +6496,7 @@ static int add_decorations_to_list(const struct commit *commit,
 		memset(item, 0, sizeof(*item));
 
 		/* If the branch is checked out, then leave a comment instead. */
-		if ((path = branch_checked_out(decoration->name))) {
+		if (path) {
 			item->command = TODO_COMMENT;
 			strbuf_commented_addf(ctx->buf, comment_line_str,
 					      "Ref %s checked out at '%s'\n",
@@ -6501,6 +6519,7 @@ static int add_decorations_to_list(const struct commit *commit,
 		decoration = decoration->next;
 	}
 
+	free(head_ref);
 	return 0;
 }
 
diff --git a/t/t3404-rebase-interactive.sh b/t/t3404-rebase-interactive.sh
index 58b3bb0c27..bc0b6a31f7 100755
--- a/t/t3404-rebase-interactive.sh
+++ b/t/t3404-rebase-interactive.sh
@@ -1979,11 +1979,18 @@ test_expect_success '--update-refs ignores non-branch decorations' '
 '
 
 test_expect_success '--update-refs updates refs correctly' '
+	test_when_finished "
+		test_might_fail git symbolic-ref -d refs/heads/no-conflict-branch-alias &&
+		test_might_fail git symbolic-ref -d refs/heads/second-alias
+	" &&
 	git checkout -B update-refs no-conflict-branch &&
 	git branch -f base HEAD~4 &&
 	git branch -f first HEAD~3 &&
 	git branch -f second HEAD~3 &&
 	git branch -f third HEAD~1 &&
+	git symbolic-ref refs/heads/no-conflict-branch-alias \
+		refs/heads/no-conflict-branch &&
+	git symbolic-ref refs/heads/second-alias refs/heads/second &&
 	test_commit extra2 fileX &&
 	git commit --amend --fixup=L &&
 
@@ -1991,8 +1998,16 @@ test_expect_success '--update-refs updates refs correctly' '
 
 	test_cmp_rev HEAD~3 refs/heads/first &&
 	test_cmp_rev HEAD~3 refs/heads/second &&
+	test_cmp_rev HEAD~3 refs/heads/second-alias &&
 	test_cmp_rev HEAD~1 refs/heads/third &&
 	test_cmp_rev HEAD refs/heads/no-conflict-branch &&
+	test_cmp_rev HEAD refs/heads/no-conflict-branch-alias &&
+	test_write_lines refs/heads/no-conflict-branch >expect &&
+	git symbolic-ref refs/heads/no-conflict-branch-alias >actual &&
+	test_cmp expect actual &&
+	test_write_lines refs/heads/second >expect &&
+	git symbolic-ref refs/heads/second-alias >actual &&
+	test_cmp expect actual &&
 
 	q_to_tab >expect <<-\EOF &&
 	Successfully rebased and updated refs/heads/update-refs.

base-commit: c69baaf57ba26cf117c2b6793802877f19738b0d
-- 
gitgitgadget

^ permalink raw reply related

* Re: [PATCH 2/2] builtin/history: implement "drop" subcommand
From: Patrick Steinhardt @ 2026-06-03 10:06 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git
In-Reply-To: <xmqqbjdt25e3.fsf@gitster.g>

On Tue, Jun 02, 2026 at 08:43:48AM +0900, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
[snip]
> > +static int update_worktree(struct repository *repo,
> > +			   const struct commit *old_head,
> > +			   const struct commit *new_head,
> > +			   bool dry_run)
> > +{
> > +...
> > +
> > +out:
> > +	clear_unpack_trees_porcelain(&opts);
> > +	rollback_lock_file(&lock);
> > +	release_index(&index);
> > +	free(desc_buf[0]);
> > +	free(desc_buf[1]);
> > +	return ret;
> > +}
> 
> The function looks very familiar---anybody who wants to perform
> "checkout <other-commit>" needs to do exactly the above.  It is a
> bit surprising and disappointing that this topic needs to *invent*
> its own helper function and carry it as a file-scope static.

It certainly is. We basically have this whole dance in ~8 different
locations by now, and given the verbosity that is required for the whole
setup it's a good hint that the interface is not exactly great.

One of the functions that we might be able to reuse is `reset_head()`...
goes down the rabbit hole... ugh, this is turning out to be somewhat
painful. I'll send a v2 that does the whole exercise, but I'm not a 100%
convinced it's the right thing to do. There's various assumptions that
we have to break:

  - It assumes that the index is always clean.

  - We don't have a dry-run mode.

  - We need to stop it from updating any refs.

  - We need to introduce another field to let the caller decide which
    commit we're moving from.

So I'm 7 commits deep now adapting the function to our needs. But maybe
the end result is ultimately worth it...? We'll see.

Patrick

^ permalink raw reply

* Re: [PATCH 2/2] builtin/history: implement "drop" subcommand
From: Patrick Steinhardt @ 2026-06-03 10:06 UTC (permalink / raw)
  To: Pablo Sabater; +Cc: git
In-Reply-To: <CAN5EUNQbSN7+SDWcrh3jTD7SXrnD=e-fQ9Qj9778R7cy2q4b1g@mail.gmail.com>

On Tue, Jun 02, 2026 at 09:31:17AM +0200, Pablo Sabater wrote:
> El mar, 2 jun 2026 a las 8:16, Patrick Steinhardt (<ps@pks.im>) escribió:
[snip]
> > +       original = lookup_commit_reference_by_name(argv[0]);
> > +       if (!original) {
> > +               ret = error(_("commit cannot be found: %s"), argv[0]);
> > +               goto out;
> > +       }
> > +
> > +       if (!original->parents) {
> > +               ret = error(_("cannot drop root commit %s: "
> > +                             "it has no parent to replay onto"),
> > +                           argv[0]);
> > +               goto out;
> > +       } else if (original->parents->next) {
> > +               ret = error(_("cannot drop merge commit"));
> 
> Why the if block adds which commit context, but not on the else if block?

True indeed, will adapt.

> > diff --git a/t/t3454-history-drop.sh b/t/t3454-history-drop.sh
> > new file mode 100755
> > index 0000000000..b320ff09b3
> > --- /dev/null
> > +++ b/t/t3454-history-drop.sh
> > @@ -0,0 +1,513 @@
> > +#!/bin/sh
> > +
> > +test_description='tests for git-history drop subcommand'
> > +
> > +. ./test-lib.sh
> > +. "$TEST_DIRECTORY/lib-log-graph.sh"
> > +
> > +expect_graph () {
> > +       cat >expect &&
> > +       lib_test_cmp_graph --graph --format=%s "$@"
> > +}
> 
> This function appears exactly the same at t6016 and t4215 but named as
> check_graph. I was gonna do a cleanup for a commit series I'm working
> on to bring that function to `lib-log-graph.sh` because all these test
> files share that they import graph functions from `lib-log-graph.c`,
> maybe you could do it?

I'd rather keep this series focussed, but I wouldn't mind a follow up
that deduplicates these call sites.

> Also:
> 
> lib_test_cmp_graph () {
>         git log --graph "$@" >output &&
>         sed 's/ *$//' >output.sanitized <output &&
>         test_cmp expect output.sanitized
> }
> 
> Already uses `--graph` you can drop it from expect_graph()

True indeed, dropped the "--graph" argument.

Thanks!

Patrick

^ permalink raw reply

* Re: [PATCH v2 1/3] Documentation/MyFirstContribution: recommend shallow threading
From: Tuomas Ahola @ 2026-06-03 10:01 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git, Junio C Hamano, Weijie Yuan, Ramsay Jones
In-Reply-To: <20260603-pks-b4-v2-1-a8aea0aa2c23@pks.im>

Patrick Steinhardt <ps@pks.im> wrote:

> The "MyFirstContribution" document recommends the use of deep threading:
> every cover letter of subsequent iterations shall be linked to the cover
> letter of the preceding version. The result of this is that eventually,
> threads with many versions are getting nested so deep that it becomes
> hard to follow.
> 
> Adapt the recommendation to instead propose shallow threading: instead
> of linking the cover letter to the previous cover letter, the user is
> supposed to always link it to the first cover letter. This still makes
> it easy to follow the iterations, but has the benefit of nesting to a
> much shallower level.
> 
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/MyFirstContribution.adoc | 4 ++--
>  1 file changed, 2 insertions(+), 2 deletions(-)
> 
> diff --git a/Documentation/MyFirstContribution.adoc b/Documentation/MyFirstContribution.adoc
> index b9fdefce02..069020196c 100644
> --- a/Documentation/MyFirstContribution.adoc
> +++ b/Documentation/MyFirstContribution.adoc
> @@ -1227,8 +1227,8 @@ Message-ID: <foo.12345.author@example.com>
>  
>  Your Message-ID is `<foo.12345.author@example.com>`. This example will be used
>  below as well; make sure to replace it with the correct Message-ID for your
> -**previous cover letter** - that is, if you're sending v2, use the Message-ID
> -from v1; if you're sending v3, use the Message-ID from v2.
> +**first cover letter** - that is, for any subsequent version that you send,
> +always use the Message-ID from v1.
>  
>  While you're looking at the email, you should also note who is CC'd, as it's
>  common practice in the mailing list to keep all CCs on a thread. You can add
> 
> -- 
> 2.54.0.1064.gd145956f57.dirty

If we adapt this change to the guidance, let's fix also other places of the
document that talk about replying to the previous cover letter.

-----8<-----

diff --git a/Documentation/MyFirstContribution.adoc b/Documentation/MyFirstContribution.adoc
index 069020196c..bf64a211bd 100644
--- a/Documentation/MyFirstContribution.adoc
+++ b/Documentation/MyFirstContribution.adoc
@@ -790,7 +790,7 @@ We can note a few things:
   v3", etc. in place of "PATCH". For example, "[PATCH v2 1/3]" would be the first of
   three patches in the second iteration. Each iteration is sent with a new cover
   letter (like "[PATCH v2 0/3]" above), itself a reply to the cover letter of the
-  previous iteration (more on that below).
+  first iteration (more on that below).
 
 NOTE: A single-patch topic is sent with "[PATCH]", "[PATCH v2]", etc. without
 _i_/_n_ numbering (in the above thread overview, no single-patch topic appears,
@@ -1214,7 +1214,7 @@ between your last version and now, if it's something significant. You do not
 need the exact same body in your second cover letter; focus on explaining to
 reviewers the changes you've made that may not be as visible.
 
-You will also need to go and find the Message-ID of your previous cover letter.
+You will also need to go and find the Message-ID of your original cover letter.
 You can either note it when you send the first series, from the output of `git
 send-email`, or you can look it up on the
 https://lore.kernel.org/git[mailing list]. Find your cover letter in the

^ permalink raw reply related

* Re: [PATCH 1/2] b4: introduce configuration for the Git project
From: Weijie Yuan @ 2026-06-03  9:51 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: Tuomas Ahola, git, Junio C Hamano
In-Reply-To: <ah_c3kgmfRh3bXns@wyuan.org>

On Wed, Jun 03, 2026 at 03:51:21PM +0800, Weijie Yuan wrote:
> On Wed, Jun 03, 2026 at 08:55:04AM +0200, Patrick Steinhardt wrote:
> > So this quote is definitely at odds with the configuration I have
> > proposed. It's actually quite surprising to me that we recommend deep
> > threading -- I personally find it extremely hard to navigate as the
> > nesting eventually gets way too deep.
>
> Sorry I'm a little confused. The example thread at git-scm.com:
>
> https://git-scm.com/docs/MyFirstContribution#ready-to-share
>
> Isn't this actually supporting shallow nesting?
>
> > It's actually quite surprising to me that we recommend deep
> > threading -- I personally find it extremely hard to navigate as the
> > nesting eventually gets way too deep.
>
> In my understanding, deep threading == --chain-reply-to, so can you
> point out where do Git recommend deep threading? I always thought Git
> supports shallow threading.
>
> Thanks! And please forgive me if I am wrong :-)

Ah, I know you mean the deep nesting of cover letters, sorry, now I
know.

Thanks!

^ permalink raw reply

* Re: [PATCH v2 0/3] contrib/subtree: reduce recursion during split
From: Ian Jackson @ 2026-06-03  9:12 UTC (permalink / raw)
  To: Colin Stagner
  Cc: Junio C Hamano, git, Christian Heusel, george, Christian Hesse,
	Phillip Wood
In-Reply-To: <0915b5cc-5cbb-4cce-a832-147f85d4ff1f@howdoi.land>

Colin Stagner writes ("Re: [PATCH v2 0/3] contrib/subtree: reduce recursion during split"):
> On 6/1/26 17:13, Junio C Hamano wrote:
> > While I do agree that avoiding bash-isms in the main part of Git and
> > sticking to vanilla POSIX has merit, this particular one seems more
> > like an artificial limit imposed by dash than sticking to the POSIX
> > as the common denoninator, at least to me.
> 
> Correct, this topic is a workaround for an artificial limit. The limit 
> is Debian-specific and was introduced as a downstream patch in 2018 [1], 
> [2].

I don't think it is correct to say that this is Debian-specific.  The
limit is baked into dash, which is a non-distro-specific minimal POSIX
shell derived from NetBSD's ash:
  http://gondor.apana.org.au/~herbert/dash/
I don't know what other distros use it (or can use it) as their
/bin/sh.  I also haven't checked POSIX to see if the question of
maximum recursion level is discussed.

Ian.

-- 
Ian Jackson <ijackson@chiark.greenend.org.uk>   These opinions are my own.  

Pronouns: they/he.  If I emailed you from @fyvzl.net or @evade.org.uk,
that is a private address which bypasses my fierce spamfilter.

^ permalink raw reply

* [PATCH v12 6/6] branch: add --dry-run for --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-03  9:04 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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.

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 |  8 ++++++-
 builtin/branch.c              | 12 +++++++---
 t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++
 3 files changed, 60 insertions(+), 4 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 69878549fc..c579df4fe0 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +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>)...
+git branch [--dry-run] (--prune-merged <branch>)...
 
 DESCRIPTION
 -----------
@@ -230,6 +230,12 @@ 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 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`::
diff --git a/builtin/branch.c b/builtin/branch.c
index e03805a8a7..1811511b9e 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -860,7 +860,7 @@ static void collect_forked_set(const struct string_list *upstreams,
 }
 
 static int prune_merged_branches(const struct string_list *upstreams,
-				 int quiet)
+				 int quiet, int dry_run)
 {
 	struct ref_store *refs = get_main_ref_store(the_repository);
 	struct string_list candidates = STRING_LIST_INIT_DUP;
@@ -917,7 +917,7 @@ static int prune_merged_branches(const struct string_list *upstreams,
 				      quiet,
 				      1, /* warn_only */
 				      1, /* no_head_fallback */
-				      0  /* dry_run */);
+				      dry_run);
 
 	strvec_clear(&deletable);
 	string_list_clear(&candidates, 0);
@@ -967,6 +967,7 @@ int cmd_branch(int argc,
 	    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;
+	int dry_run = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -1024,6 +1025,8 @@ int cmd_branch(int argc,
 			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_BOOL(0, "dry-run", &dry_run,
+			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")),
@@ -1083,6 +1086,9 @@ int cmd_branch(int argc,
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
+	if (dry_run && !prune_merged_upstreams.nr)
+		die(_("--dry-run requires --prune-merged"));
+
 	if (recurse_submodules_explicit) {
 		if (!submodule_propagate_branches)
 			die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ -1124,7 +1130,7 @@ int cmd_branch(int argc,
 		if (argc)
 			die(_("--prune-merged does not take positional arguments; "
 			      "repeat --prune-merged for each <branch>"));
-		ret = prune_merged_branches(&prune_merged_upstreams, quiet);
+		ret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run);
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 9e33179590..29bfd0e109 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2027,4 +2027,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
 	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/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 --dry-run --prune-merged "origin/*" >actual &&
+	test_grep "Would delete branch one " actual &&
+	test_grep "Would delete branch two " actual &&
+
+	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 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-dry-mixed branch --dry-run --prune-merged "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 without --prune-merged is rejected' '
+	test_must_fail git -C forked branch --dry-run 2>err &&
+	test_grep "requires --prune-merged" err
+'
+
 test_done
-- 
gitgitgadget

^ permalink raw reply related

* [PATCH v12 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-06-03  9:04 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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.

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 |  7 +++++++
 Documentation/git-branch.adoc    |  5 +++--
 builtin/branch.c                 | 14 ++++++++++++++
 t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++
 4 files changed, 54 insertions(+), 2 deletions(-)

diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..6c1b5bb9cd 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -102,3 +102,10 @@ for details).
 	`git branch --edit-description`. Branch description is
 	automatically added to the `format-patch` cover letter or
 	`request-pull` summary.
+
+`branch.<name>.pruneMerged`::
+	If set to `false`, branch _<name>_ is exempt from
+	`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.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index f7942fcd7d..69878549fc 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -221,9 +221,10 @@ 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
diff --git a/builtin/branch.c b/builtin/branch.c
index 736480b002..e03805a8a7 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -878,7 +878,9 @@ static int prune_merged_branches(const struct string_list *upstreams,
 		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_addf(&full, "refs/heads/%s", short_name);
 		skip = !!branch_checked_out(full.buf);
@@ -893,6 +895,18 @@ static int prune_merged_branches(const struct string_list *upstreams,
 		if (!push || !strcmp(push, upstream))
 			continue;
 
+		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"),
+					short_name, short_name);
+			strbuf_release(&key);
+			continue;
+		}
+		strbuf_release(&key);
+
 		strvec_push(&deletable, short_name);
 	}
 
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index beb86987ad..9e33179590 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1997,4 +1997,34 @@ test_expect_success '--prune-merged rejects positional arguments' '
 	test_grep "does not take positional arguments" err
 '
 
+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 &&
+	test_config -C pm-optout branch.one.pruneMerged false &&
+
+	git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+	git -C pm-optout rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+	test_grep "Skipping .one." err
+'
+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+	test_when_finished "rm -rf pm-optout-d" &&
+	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 &&
+	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
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v12 4/6] branch: add --prune-merged <branch>
From: Harald Nordgren via GitGitGadget @ 2026-06-03  9:04 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

	git branch --prune-merged <branch>...

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
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH] git-gui: silence install recipes under "make -s"
From: Harald Nordgren via GitGitGadget @ 2026-06-03  9:04 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren

From: Harald Nordgren <haraldnordgren@gmail.com>

The split install/uninstall recipes embed "echo" calls that fire
even under "make -s", so install still prints "DEST /path" and
"INSTALL 644 about.tcl" banners. The existing "-s" block only
clears QUIET_GEN.

Wrap the whole "ifndef V" block in the canonical "-s" guard from
shared.mak, and drop the now-redundant narrow block.

Signed-off-by: Harald Nordgren <harald.nordgren@kostdoktorn.se>
---
    git-gui: silence install recipes under "make -s"

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2318%2FHaraldNordgren%2Fgit-gui-respect-silent-flag-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2318/HaraldNordgren/git-gui-respect-silent-flag-v1
Pull-Request: https://github.com/git/git/pull/2318

 git-gui/Makefile | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/git-gui/Makefile b/git-gui/Makefile
index ca01068810..d33204e875 100644
--- a/git-gui/Makefile
+++ b/git-gui/Makefile
@@ -64,6 +64,7 @@ REMOVE_F0  = $(RM_RF) # space is required here
 REMOVE_F1  =
 CLEAN_DST  = true
 
+ifneq ($(findstring s,$(firstword -$(MAKEFLAGS))),s)
 ifndef V
 	QUIET          = @
 	QUIET_GEN      = $(QUIET)echo '   ' GEN '$@' &&
@@ -89,6 +90,7 @@ ifndef V
 	REMOVE_F0 = dst=
 	REMOVE_F1 = && echo '   ' REMOVE `basename "$$dst"` && $(RM_RF) "$$dst"
 endif
+endif
 
 TCLTK_PATH ?= wish
 ifeq (./,$(dir $(TCLTK_PATH)))
@@ -97,10 +99,6 @@ else
 	TCL_PATH ?= $(dir $(TCLTK_PATH))$(notdir $(subst wish,tclsh,$(TCLTK_PATH)))
 endif
 
-ifeq ($(findstring $(firstword -$(MAKEFLAGS)),s),s)
-QUIET_GEN =
-endif
-
 -include config.mak
 
 DESTDIR_SQ = $(subst ','\'',$(DESTDIR))

base-commit: 1666c1265231b0bc5f613fbbf3f0a9896cdef76e
-- 
gitgitgadget

^ permalink raw reply related

* [PATCH v12 3/6] branch: prepare delete_branches for a bulk caller
From: Harald Nordgren via GitGitGadget @ 2026-06-03  9:04 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>

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.

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();
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-06-03  9:04 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>

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)
 {
 	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();
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v12 1/6] branch: add --forked filter for --list mode
From: Harald Nordgren via GitGitGadget @ 2026-06-03  9:04 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>

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.

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.

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>)...]
 	   [(-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.
+
 `-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);
+
 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);
+
 	if (filter->verbose)
 		maxwidth = calc_maxwidth(&array, strlen(remote_prefix));
 
@@ -673,6 +681,131 @@ 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 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));
+		out->is_wildcard = 0;
+		free(full_ref);
+		return 0;
+	}
+	free(full_ref);
+	return -1;
+}
+
+static void parse_forked_args(const struct string_list *args,
+			      struct upstream_pattern **patterns_out,
+			      size_t *nr_out)
+{
+	struct upstream_pattern *patterns;
+	size_t i;
+
+	ALLOC_ARRAY(patterns, args->nr);
+	for (i = 0; i < args->nr; i++) {
+		const char *arg = args->items[i].string;
+		if (parse_one_forked_arg(arg, &patterns[i]) < 0) {
+			upstream_pattern_list_clear(patterns, i);
+			die(_("'%s' is not a valid branch or pattern"), arg);
+		}
+	}
+	*patterns_out = patterns;
+	*nr_out = args->nr;
+}
+
+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;
+		}
+	}
+	return 0;
+}
+
+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),
+				patterns, nr_patterns);
+}
+
+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;
+
+	upstream_pattern_list_clear(patterns, nr_patterns);
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -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;
 	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_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v12 0/6] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-03  9:04 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren
In-Reply-To: <pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com>

 * Reworked --forked from a standalone action into a --list-mode filter.
 * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
   options.
 * Dropped the bare-remote-name resolution for --forked, the argument is now
   a ref or a glob.

Harald Nordgren (6):
  branch: add --forked filter for --list mode
  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    |  37 ++++
 builtin/branch.c                 | 317 +++++++++++++++++++++++++--
 ref-filter.c                     |  10 +-
 ref-filter.h                     |   2 +
 t/t3200-branch.sh                | 354 +++++++++++++++++++++++++++++++
 6 files changed, 701 insertions(+), 26 deletions(-)


base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v12
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v12
Pull-Request: https://github.com/git/git/pull/2285

Range-diff vs v11:

 1:  b9fddd124a ! 1:  8834c424fb branch: add --forked <branch>
     @@ Metadata
      Author: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Commit message ##
     -    branch: add --forked <branch>
     +    branch: add --forked filter for --list mode
      
     -    List local branches whose configured upstream
     -    (branch.<name>.merge resolved against branch.<name>.remote)
     -    matches any of the given <branch> arguments.
     +    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.
      
     -    Each <branch> is interpreted against the local repository, not
     -    against any specific remote:
     +    Because it is a filter on list mode, --forked composes with the
     +    existing list-mode filters, so
      
     -      * 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.
     +        git branch --merged origin/main --forked 'origin/*'
      
     -    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.
     +    lists branches forked from origin that have already been
     +    integrated 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.
     @@ Commit message
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Documentation/git-branch.adoc ##
     -@@ Documentation/git-branch.adoc: 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 --forked <branch>...
     - 
     - DESCRIPTION
     - -----------
     +@@ Documentation/git-branch.adoc: 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>)...]
     + 	   [(-r|--remotes) | (-a|--all)]
     + 	   [--list] [<pattern>...]
     + git branch [--track[=(direct|inherit)] | --no-track] [-f]
      @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
       	Print the name of the current branch. In detached `HEAD` state,
       	nothing is printed.
       
     -+`--forked`::
     -+	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.
     ++`--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.
      +
       `-v`::
       `-vv`::
     @@ builtin/branch.c
      +#include "wildmatch.h"
       
       static const char * const builtin_branch_usage[] = {
     - 	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
     -@@ builtin/branch.c: 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>] --forked <branch>..."),
     - 	NULL
     - };
     +-	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>..."),
     +@@ builtin/branch.c: 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);
     ++
     + 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;
     +@@ builtin/branch.c: 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);
     ++
     + 	if (filter->verbose)
     + 		maxwidth = calc_maxwidth(&array, strlen(remote_prefix));
       
      @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
       	free_worktrees(worktrees);
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
      +
      +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);
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
      +		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/") ||
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
      +	return -1;
      +}
      +
     -+static void parse_forked_args(int argc, const char **argv,
     ++static void parse_forked_args(const struct string_list *args,
      +			      struct upstream_pattern **patterns_out,
      +			      size_t *nr_out)
      +{
      +	struct upstream_pattern *patterns;
     -+	int i;
     ++	size_t i;
      +
     -+	ALLOC_ARRAY(patterns, argc);
     -+	for (i = 0; i < argc; i++) {
     -+		if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
     ++	ALLOC_ARRAY(patterns, args->nr);
     ++	for (i = 0; i < args->nr; i++) {
     ++		const char *arg = args->items[i].string;
     ++		if (parse_one_forked_arg(arg, &patterns[i]) < 0) {
      +			upstream_pattern_list_clear(patterns, i);
     -+			die(_("'%s' is not a valid branch or pattern"),
     -+			    argv[i]);
     ++			die(_("'%s' is not a valid branch or pattern"), arg);
      +		}
      +	}
      +	*patterns_out = patterns;
     -+	*nr_out = argc;
     ++	*nr_out = args->nr;
      +}
      +
      +static int upstream_matches(const char *short_upstream,
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
      +	return 0;
      +}
      +
     -+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)
     ++static int branch_upstream_matches(const char *full_refname,
     ++				   const struct upstream_pattern *patterns,
     ++				   size_t nr_patterns)
      +{
     -+	struct forked_cb *cb = cb_data;
     ++	const char *short_name;
      +	struct branch *branch;
      +	const char *upstream;
      +
     -+	if (ref->flags & REF_ISSYMREF)
     ++	if (!skip_prefix(full_refname, "refs/heads/", &short_name))
      +		return 0;
     -+	branch = branch_get(ref->name);
     ++	branch = branch_get(short_name);
      +	if (!branch)
      +		return 0;
      +	upstream = branch_get_upstream(branch, NULL);
      +	if (!upstream)
      +		return 0;
     -+	if (upstream_matches(short_upstream_name(upstream),
     -+			     cb->patterns, cb->nr_patterns))
     -+		string_list_append(cb->out, ref->name);
     -+	return 0;
     ++	return upstream_matches(short_upstream_name(upstream),
     ++				patterns, nr_patterns);
      +}
      +
     -+static int list_forked_branches(int argc, const char **argv)
     ++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;
     -+	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>"));
     ++	int i, kept = 0;
      +
     -+	parse_forked_args(argc, argv, &patterns, &nr_patterns);
     -+	cb.patterns = patterns;
     -+	cb.nr_patterns = nr_patterns;
     -+	cb.out = &out;
     ++	parse_forked_args(upstreams, &patterns, &nr_patterns);
      +
     -+	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);
     ++	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;
      +
      +	upstream_pattern_list_clear(patterns, nr_patterns);
     -+	string_list_clear(&out, 0);
     -+	return 0;
      +}
      +
       static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
     @@ builtin/branch.c: 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;
     -+	int forked = 0;
     ++	struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
       	const char *new_upstream = NULL;
       	int noncreate_actions = 0;
       	/* possible options */
     @@ builtin/branch.c: 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_BOOL(0, "forked", &forked,
     -+			N_("list local branches whose upstream matches the given <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")),
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 			     0);
     - 
     - 	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
     --	    !show_current && !unset_upstream && argc == 0)
     -+	    !show_current && !unset_upstream && !forked && argc == 0)
       		list = 1;
       
       	if (filter.with_commit || filter.no_commit ||
     -@@ builtin/branch.c: int cmd_branch(int argc,
     +-	    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 +
     - 			    !!show_current + !!list + !!edit_description +
     --			    !!unset_upstream;
     -+			    !!unset_upstream + !!forked;
     - 	if (noncreate_actions > 1)
     - 		usage_with_options(builtin_branch_usage, options);
     - 
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 			die(_("branch name required"));
     - 		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
     - 		goto out;
     -+	} else if (forked) {
     -+		ret = list_forked_branches(argc, argv);
     -+		goto out;
     - 	} else if (show_current) {
     - 		print_current_branch_name();
     - 		ret = 0;
     + 		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);
     +@@ builtin/branch.c: int cmd_branch(int argc,
     + 
     + out:
     + 	string_list_clear(&sorting_options, 0);
     ++	string_list_clear(&forked_upstreams, 0);
     + 	return ret;
     + }
     +
     + ## ref-filter.c ##
     +@@ ref-filter.c: 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) {
     +@@ ref-filter.c: 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
     +@@ ref-filter.c: 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;
     + 
     +@@ ref-filter.c: 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);
     +@@ ref-filter.c: 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)
     +
     + ## ref-filter.h ##
     +@@ ref-filter.h: 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 */
      
       ## 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 -C forked branch --track local-trunk local-base
      +'
      +
     -+test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
     -+	git -C forked branch --forked origin/one >actual &&
     ++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> matches by wildmatch' '
     -+	git -C forked branch --forked "origin/*" >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
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +'
      +
      +test_expect_success '--forked <local-branch> matches branches with local upstream' '
     -+	git -C forked branch --forked local-base >actual &&
     ++	git -C forked branch --forked local-base --format="%(refname:short)" >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 &&
     ++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
     @@ 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 local-base "other/*" >actual &&
     ++	git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-foreign
      +	local-trunk
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +'
      +
      +test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
     -+	git -C forked branch --forked "*/*" >actual &&
     ++	git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-foreign
      +	local-one
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +	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 at least one <branch>' '
     ++test_expect_success '--forked requires a value' '
      +	test_must_fail git -C forked branch --forked 2>err &&
     -+	test_grep "at least one <branch>" err
     ++	test_grep "requires a value" err
      +'
      +
       test_done
 2:  b666d09bf5 ! 2:  6c95e4e77c branch: let delete_branches warn instead of error on bulk refusal
     @@ Commit message
          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.
     +    emits. Default callers pass 0 and are unaffected.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ builtin/branch.c: int cmd_branch(int argc,
      +		ret = delete_branches(argc, argv, delete > 1, filter.kind,
      +				      quiet, 0);
       		goto out;
     - 	} else if (forked) {
     - 		ret = list_forked_branches(argc, argv);
     + 	} else if (show_current) {
     + 		print_current_branch_name();
 3:  6e6580270e ! 3:  004a96f7a4 branch: prepare delete_branches for a bulk caller
     @@ Metadata
       ## Commit message ##
          branch: prepare delete_branches for a bulk caller
      
     -    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.
     +    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.  That reminder is only
     -    meaningful when the caller actually cares about HEAD; for the
     -    bulk caller every candidate is known to have an upstream and HEAD
     -    is irrelevant to the decision.  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.
     +    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.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ builtin/branch.c: int cmd_branch(int argc,
      -				      quiet, 0);
      +				      quiet, 0, 0, 0);
       		goto out;
     - 	} else if (forked) {
     - 		ret = list_forked_branches(argc, argv);
     + 	} else if (show_current) {
     + 		print_current_branch_name();
 4:  e7e03c1338 ! 4:  cccfdb831c branch: add --prune-merged <branch>
     @@ Commit message
          upstream: the work has already landed on the upstream they track,
          so the local copy is no longer needed.
      
     -    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.
     +    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:
      
     @@ Commit message
            * 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
     +        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.
     @@ Commit message
          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.
     +    whole sweep. We only act on upstream-merged status.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Documentation/git-branch.adoc ##
     -@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
     +@@ Documentation/git-branch.adoc: 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 --forked <branch>...
     -+git branch --prune-merged <branch>...
     ++git branch (--prune-merged <branch>)...
       
       DESCRIPTION
       -----------
     -@@ 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.
     +@@ Documentation/git-branch.adoc: 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`::
     ++`--prune-merged <branch>`::
      +	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.
     ++	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
     ++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:
     @@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`.  A bare conf
       `--verbose`::
      
       ## builtin/branch.c ##
     -@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
     +@@ builtin/branch.c: 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
     + };
     + 
     +@@ builtin/branch.c: static int upstream_matches(const char *short_upstream,
       	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)
     +-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)
       {
     - 	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;
     +-	const char *short_name;
     +-	struct branch *branch;
     ++	struct branch *branch = branch_get(short_branch_name);
     + 	const char *upstream;
       
     --	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;
     +-	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);
     +@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
       
     - 	refs_for_each_branch_ref(get_main_ref_store(the_repository),
     - 				 collect_forked_branch, &cb);
     + 	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);
     +@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
     + 	upstream_pattern_list_clear(patterns, nr_patterns);
     + }
       
     --	string_list_sort(&out);
     -+	string_list_sort(out);
     ++struct forked_cb {
     ++	const struct upstream_pattern *patterns;
     ++	size_t nr_patterns;
     ++	struct string_list *out;
     ++};
      +
     -+	upstream_pattern_list_clear(patterns, nr_patterns);
     ++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 int list_forked_branches(int argc, const char **argv)
     ++static void collect_forked_set(const struct string_list *upstreams,
     ++			       struct string_list *out)
      +{
     -+	struct string_list out = STRING_LIST_INIT_DUP;
     -+	struct string_list_item *item;
     ++	struct upstream_pattern *patterns = NULL;
     ++	size_t nr_patterns = 0;
     ++	struct forked_cb cb;
      +
     -+	if (!argc)
     -+		die(_("--forked requires at least one <branch>"));
     ++	parse_forked_args(upstreams, &patterns, &nr_patterns);
     ++	cb.patterns = patterns;
     ++	cb.nr_patterns = nr_patterns;
     ++	cb.out = out;
      +
     -+	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;
     - }
     - 
     -+static int prune_merged_branches(int argc, const char **argv, int quiet)
     ++	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;
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      +	struct string_list_item *item;
      +	int ret = 0;
      +
     -+	if (!argc)
     ++	if (!upstreams->nr)
      +		die(_("--prune-merged requires at least one <branch>"));
      +
     -+	collect_forked_set(argc, argv, &candidates);
     ++	collect_forked_set(upstreams, &candidates);
      +
      +	for_each_string_list_item(item, &candidates) {
      +		const char *short_name = item->string;
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      @@ builtin/branch.c: int cmd_branch(int argc,
       	int delete = 0, rename = 0, copy = 0, list = 0,
       	    unset_upstream = 0, show_current = 0, edit_description = 0;
     - 	int forked = 0;
     -+	int prune_merged = 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 */
      @@ builtin/branch.c: int cmd_branch(int argc,
       			 N_("edit the description for the branch")),
     - 		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 is merged")),
     + 		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")),
     @@ builtin/branch.c: int cmd_branch(int argc,
       			     0);
       
       	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
     --	    !show_current && !unset_upstream && !forked && argc == 0)
     -+	    !show_current && !unset_upstream && !forked && !prune_merged &&
     +-	    !show_current && !unset_upstream && argc == 0)
     ++	    !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
      +	    argc == 0)
       		list = 1;
       
     @@ builtin/branch.c: int cmd_branch(int argc,
       
       	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
       			    !!show_current + !!list + !!edit_description +
     --			    !!unset_upstream + !!forked;
     -+			    !!unset_upstream + !!forked + !!prune_merged;
     +-			    !!unset_upstream;
     ++			    !!unset_upstream + !!prune_merged_upstreams.nr;
       	if (noncreate_actions > 1)
       		usage_with_options(builtin_branch_usage, options);
       
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 	} else if (forked) {
     - 		ret = list_forked_branches(argc, argv);
     + 		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     + 				      quiet, 0, 0, 0);
       		goto out;
     -+	} else if (prune_merged) {
     -+		ret = prune_merged_branches(argc, argv, quiet);
     ++	} 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;
     +@@ builtin/branch.c: 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;
     + }
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>' '
     - 	test_grep "at least one <branch>" err
     +@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
     + 	test_grep "requires a value" err
       '
       
      +test_expect_success '--prune-merged: setup' '
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
      +	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 &&
     ++	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
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
      +	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_expect_success '--prune-merged requires a value' '
      +	test_must_fail git -C forked branch --prune-merged 2>err &&
     -+	test_grep "at least one <branch>" 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
 5:  75b6d2366a ! 5:  5f793f8d0d branch: add branch.<name>.pruneMerged opt-out
     @@ Documentation/git-branch.adoc: the upstream refs refreshed.
       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)
     +@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
       		struct branch *branch = branch_get(short_name);
       		const char *upstream, *push;
       		struct strbuf full = STRBUF_INIT;
     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
       
       		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)
     +@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
       		if (!push || !strcmp(push, upstream))
       			continue;
       
     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
       
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '
     - 	test_grep "at least one <branch>" err
     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged rejects positional arguments' '
     + 	test_grep "does not take positional arguments" err
       '
       
      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
 6:  a1a42a6b19 ! 6:  1a0d5eab15 branch: add --dry-run for --prune-merged
     @@ Commit message
          branch: add --dry-run for --prune-merged
      
          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.
     +    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.
     +    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.
     @@ Commit message
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Documentation/git-branch.adoc ##
     -@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
     +@@ Documentation/git-branch.adoc: 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 --forked <branch>...
     --git branch --prune-merged <branch>...
     -+git branch --prune-merged [--dry-run] <branch>...
     +-git branch (--prune-merged <branch>)...
     ++git branch [--dry-run] (--prune-merged <branch>)...
       
       DESCRIPTION
       -----------
     @@ Documentation/git-branch.adoc: Branches refused by the "fully merged" safety che
       `--verbose`::
      
       ## builtin/branch.c ##
     -@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
     - 	return 0;
     +@@ builtin/branch.c: static void collect_forked_set(const struct string_list *upstreams,
       }
       
     --static int prune_merged_branches(int argc, const char **argv, int quiet)
     -+static int prune_merged_branches(int argc, const char **argv, int quiet,
     -+				 int dry_run)
     + static int prune_merged_branches(const struct string_list *upstreams,
     +-				 int quiet)
     ++				 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)
     +@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
       				      quiet,
       				      1, /* warn_only */
       				      1, /* no_head_fallback */
     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
       	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;
     - 	int prune_merged = 0;
     + 	struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
     + 	struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
      +	int dry_run = 0;
       	const char *new_upstream = NULL;
       	int noncreate_actions = 0;
       	/* possible options */
      @@ 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 is merged")),
     + 			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_BOOL(0, "dry-run", &dry_run,
      +			N_("with --prune-merged, only print which branches would be deleted")),
       		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
     @@ builtin/branch.c: int cmd_branch(int argc,
       	if (noncreate_actions > 1)
       		usage_with_options(builtin_branch_usage, options);
       
     -+	if (dry_run && !prune_merged)
     ++	if (dry_run && !prune_merged_upstreams.nr)
      +		die(_("--dry-run requires --prune-merged"));
      +
       	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,
     - 		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, quiet, dry_run);
     + 		if (argc)
     + 			die(_("--prune-merged does not take positional arguments; "
     + 			      "repeat --prune-merged for each <branch>"));
     +-		ret = prune_merged_branches(&prune_merged_upstreams, quiet);
     ++		ret = prune_merged_branches(&prune_merged_upstreams, 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
      +	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 &&
     ++	git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
      +	test_grep "Would delete branch one " actual &&
      +	test_grep "Would delete branch two " actual &&
      +
     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
      +	git -C pm-dry-mixed branch merged one-commit &&
      +	git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
      +
     -+	git -C pm-dry-mixed branch --prune-merged --dry-run "origin/*" >out &&
     ++	git -C pm-dry-mixed branch --dry-run --prune-merged "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 &&

-- 
gitgitgadget

^ permalink raw reply


This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox