Git development
 help / color / mirror / Atom feed
* [PATCH 3/4] doc: config: include existing git-hook(1) section
From: kristofferhaugsbakk @ 2026-05-21 16:25 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, jn.avila, adrian.ratiu
In-Reply-To: <CV_doc_hook.6f0@msgid.xyz>

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

It is already included in git-hook(1) but missing from git-config(1).

Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---
 Documentation/config.adoc | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/Documentation/config.adoc b/Documentation/config.adoc
index dcea3c0c15e..a80e7db46d9 100644
--- a/Documentation/config.adoc
+++ b/Documentation/config.adoc
@@ -451,6 +451,8 @@ include::config/guitool.adoc[]
 
 include::config/help.adoc[]
 
+include::config/hook.adoc[]
+
 include::config/http.adoc[]
 
 include::config/i18n.adoc[]
-- 
2.54.0.13.g9c7419e39f8


^ permalink raw reply related

* [PATCH 4/4] doc: hook: don’t self-link via config include
From: kristofferhaugsbakk @ 2026-05-21 16:25 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, jn.avila, adrian.ratiu
In-Reply-To: <CV_doc_hook.6f0@msgid.xyz>

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

Do not link to git-hook(1) from the config options when we already are
in that doc.

This implementation is similar to the updates to git-init(1) and
git-commit(1), implemented in [1] and [2], respectively.

† 1: e7b3a768 (doc: git-init: rework config item init.templateDir,
     2024-03-10)
† 2: 819fdd6e (doc: convert git commit config to new format, 2025-01-15)

Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---
 Documentation/config/hook.adoc | 19 +++++++++++++------
 Documentation/git-hook.adoc    |  1 +
 2 files changed, 14 insertions(+), 6 deletions(-)

diff --git a/Documentation/config/hook.adoc b/Documentation/config/hook.adoc
index a9dc0063c12..083dc60a132 100644
--- a/Documentation/config/hook.adoc
+++ b/Documentation/config/hook.adoc
@@ -1,10 +1,17 @@
+ifdef::git-hook[]
+:see-git-hook:
+endif::git-hook[]
+ifndef::git-hook[]
+:see-git-hook: See linkgit:git-hook[1].
+endif::git-hook[]
+
 hook.<friendly-name>.command::
 	The command to execute for `hook.<friendly-name>`. `<friendly-name>`
 	is a unique name that identifies this hook. The hook events that
 	trigger the command are configured with `hook.<friendly-name>.event`.
 	The value can be an executable path or a shell oneliner. If more than
 	one value is specified for the same `<friendly-name>`, only the last
-	value parsed is used. See linkgit:git-hook[1].
+	value parsed is used. {see-git-hook}
 
 hook.<friendly-name>.event::
 	The hook events that trigger `hook.<friendly-name>`. The value is the
@@ -14,7 +21,7 @@ hook.<friendly-name>.event::
 	This is a multi-valued key. To run `hook.<friendly-name>` on multiple
 	events, specify the key more than once. An empty value resets
 	the list of events, clearing any previously defined events for
-	`hook.<friendly-name>`. See linkgit:git-hook[1].
+	`hook.<friendly-name>`. {see-git-hook}
 +
 The `<friendly-name>` must not be the same as a known hook event name
 (e.g. do not use `hook.pre-commit.event`). Using a known event name as
@@ -27,7 +34,7 @@ hook.<friendly-name>.enabled::
 	Set to `false` to disable the hook without removing its
 	configuration. This is particularly useful when a hook is defined
 	in a system or global config file and needs to be disabled for a
-	specific repository. See linkgit:git-hook[1].
+	specific repository. {see-git-hook}
 
 hook.<friendly-name>.parallel::
 	Whether the hook `hook.<friendly-name>` may run in parallel with other hooks
@@ -37,13 +44,13 @@ hook.<friendly-name>.parallel::
 	all hooks for that event run sequentially regardless of `hook.jobs`.
 	Only configured (named) hooks need to declare this. Traditional hooks
 	found in the hooks directory do not need to, and run in parallel when
-	the effective job count is greater than 1. See linkgit:git-hook[1].
+	the effective job count is greater than 1. {see-git-hook}
 
 hook.<event>.enabled::
 	Switch to enable or disable all hooks for the `<event>` hook event.
 	When set to `false`, no hooks fire for that event, regardless of any
 	per-hook `hook.<friendly-name>.enabled` settings. Defaults to `true`.
-	See linkgit:git-hook[1].
+	{see-git-hook}
 +
 Note on naming: `<event>` must be the event name (e.g. `pre-commit`),
 not a hook friendly-name. Since using a known event name as a
@@ -60,7 +67,7 @@ hook.<event>.jobs::
 	setting has no effect unless all configured hooks for the event have
 	`hook.<friendly-name>.parallel` set to `true`. Set to `-1` to use the
 	number of available CPU cores. Must be a positive integer or `-1`;
-	zero is rejected with a warning. See linkgit:git-hook[1].
+	zero is rejected with a warning. {see-git-hook}
 +
 Note on naming: although this key resembles `hook.<friendly-name>.*`
 (a per-hook setting), `<event>` must be the event name, not a hook
diff --git a/Documentation/git-hook.adoc b/Documentation/git-hook.adoc
index 750df58e58e..4868852aa0b 100644
--- a/Documentation/git-hook.adoc
+++ b/Documentation/git-hook.adoc
@@ -204,6 +204,7 @@ unintended and unsupported ways.
 
 CONFIGURATION
 -------------
+:git-hook: 1
 include::config/hook.adoc[]
 
 SEE ALSO
-- 
2.54.0.13.g9c7419e39f8


^ permalink raw reply related

* Re: [PATCH v3 1/8] environment: move "trust_ctime" into `struct repo_config_values`
From: Tian Yuchen @ 2026-05-21 16:37 UTC (permalink / raw)
  To: Olamide Caleb Bello, git
  Cc: phillip.wood123, gitster, christian.couder, usmanakinyemi202,
	kaartic.sivaraam, me
In-Reply-To: <20260423165432.143598-2-belkid98@gmail.com>

Hi Bello!

On 4/24/26 00:54, Olamide Caleb Bello wrote:

The code itself looks great to me, but I have some reservations about 
the description here (in terms of why trust_ctime is eagerly parsed):

 > `core.trustctime` is parsed eagerly
 > because it is used in low‑level stat‑matching functions
 > (`match_stat_data()`), where a lazy parse could cause unexpected
 > fatal errors and complicate libification efforts.

It's true that if we use repo_config_get_bool() to parse trust_ctime, 
following the call stack downwards, there is a die() call. The terminate 
condition is that the configuration does not exist or contains invalid 
characters.

But I think there is another factor: match_stat_data() is called on a 
hot path. The following code is implemented in read-cache.c, 
refresh_index() function:

	for (i = 0; i < istate->cache_nr; i++) {
		...
		new_entry = refresh_cache_ent(istate, ce, options,
					      &cache_errno, &changed,
					      &t2_did_lstat, &t2_did_scan);
		t2_sum_lstat += t2_did_lstat;
		t2_sum_scan += t2_did_scan;
		if (new_entry == ce)
		...

The call chain: refresh_index() -> refresh_cache_ent() -> 
ie_match_stat() -> ce_match_stat_basic() -> *match_stat_data()*

Therefore, if the variable is lazily parsed, this means there will be a 
performance regression whenever the index status needs to be checked, 
e.g. 'git status'.

So, I guess it would be better to extend a bit:

'...where a lazy parse could cause unexpected fatal, and result in a 
performance regression...'

Thanks, yuchen

^ permalink raw reply

* Re: [PATCH 1/8] t0001: plug test gaps for git-init(1) with GIT_OBJECT_DIRECTORY
From: Junio C Hamano @ 2026-05-21 16:49 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git
In-Reply-To: <20260521-b4-pks-setup-centralize-odb-creation-v1-1-f130d2a7e8ae@pks.im>

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" enviroment variable. When set, the

"environment"???

> 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 broke this
> expectation.
>
> Plug this test gap.

Nice.

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

^ permalink raw reply

* Re: [PATCH v2 10/11] git-gui: adapt blame/browser parsing for bare operation
From: Mark Levedahl @ 2026-05-21 17:35 UTC (permalink / raw)
  To: Shroom Moo; +Cc: git, Johannes Sixt, Aina Boot
In-Reply-To: <tencent_407FE60B6954528497709B6CAD49018D120A@qq.com>



On 5/21/26 1:02 AM, Shroom Moo wrote:
> On 5/21/26 4:24 AM, Mark Levedahl wrote: 
>> +proc find_path_type {head path} {
>> +	if {$path eq {./}} {
>> +		# the root-tree exists in every rev, ls-tree gives data on the contents,
>> +		# not the type of tree itself. So, if the rev exists, return {tree}
>> +		if {[catch {set objtype [git ls-tree $head]}]} {
>> +			set objtype {}
>> +		} else {
>> +			set objtype {tree}
>> +		}
>> +	} else {
>> +		# test that the path exists in head, ls-tree gives info on the path only
>> +		if {[catch {set objtype [git ls-tree {--format=%(objecttype)} $head $path]}]} {
>> +			set objtype {}
>> +		}
>> +	}
>> +	return $objtype
>> +}
> In v1, argument parsing relied on file exists within the worktree to 
> determine if a path existed, without using ls-tree. In v2, the use of 
> git ls-tree seems to actually be intended to list directory contents, 
> rather than querying the type of the path itself. 
>
> If $path is a directory (a tree object), git ls-tree outputs the 
> object type for every entry within that directory, one per line. 
>
> The variable objtype is assigned a multi-line string. When compared 
> against "tree", the match fails, causing the function to return an 
> empty string, which subsequently leads to an error. We can change to 
> "git cat-file -t" or similiar approaches. 
>
> Shroom
>
git ls-tree $rev $path --format='%(objecttype)' gives the type of the object at $path in
$rev, or an error. The types returned are "tree" for a directory, "blob" for a file. So
this gives definitive information if the object desired exists in the given rev, and is of
the right type. (We don't care about commits and tags, those cannot be blamed or browsed).

git-lstree $rev $dirname/  lists all of the objects in the directory, while git-lstree
$rev $dirname (no trailing /) gives info on the directory itself. There is no name for the
root directory itself, all of its contents are listed. That is why the root './' is
special case.

Asking the worktree that is on version 33 about whether frotz is a directory in version 2
is just asking for trouble, at best the worktree is authoritative for the checked out
version, but even then there can be uncommitted changed. In the root of git-gui, I get

/git-gui.sh browser gitgui-0.9.0 Makefile
'Makefile' is not a directory in rev 'gitgui-0.9.0'

so the types of objects are being checked.

Mark


^ permalink raw reply

* Re: [PATCH v2 07/11] git-gui: try harder to find worktree from gitdir
From: Mark Levedahl @ 2026-05-21 17:45 UTC (permalink / raw)
  To: Shroom Moo; +Cc: git, Johannes Sixt, Aina Boot
In-Reply-To: <tencent_E13EB585242AD7C263B8B3B732A428465D09@qq.com>



On 5/21/26 12:55 AM, Shroom Moo wrote:
> On 5/21/26 4:24 AM, Mark Levedahl wrote:
>> +	} elseif [file exists {gitdir}] {
>> +		if {[catch {
>> +			set fd_gitdir [open {gitdir} {r}]
>> +			set gitlink_parent [file dirname [read $fd_gitdir]]
>> +			catch {close $fd_gitdir}
>> +			set worktree [git -C $gitlink_parent rev-parse --show-toplevel]
>> +			set parent_gitdir [git -C $worktree rev-parse --absolute-git-dir]
>> +			if {$::_gitdir ne $parent_gitdir} {
>> +				set worktree {}
>> +			}
>> +		}]} {
>> +			catch {close $fd_gitdir}
>> +			set worktree {}
>> +		}
>> +	}
> There is also an unaddressed issue: 
> In [file exists {gitdir}] and [open {gitdir} r], {gitdir} is a 
> literal string referring to a file named gitdir in the current 
> working directory. However, in the context of a linked worktree 
> (created via git worktree add), the actual file path is 
> $_gitdir/gitdir (e.g., .git/worktrees/<name>/gitdir). While the 
> current working directory could be anywhere (even inside the .git 
> directory), $_gitdir is an absolute path pointing to that worktree's 
> gitdir (e.g., /path/to/main/.git/worktrees/branch). The gitdir file 
> resides within the $_gitdir directory and contains a relative path 
> like ../../.git/worktrees/branch. The current code logic will never 
> locate this file. 
You have to be in the particular worktree's gitdir for this to work. I there exists
    worktrees/foo
    worktrees/frotz
    worktrees/bar
Which would we expore? The code above must be in foo, frotz, bar

The main worktree is found not from worktrees/*, but from the root of the gitdir.
>
> Additionally, [file exists {gitdir}] checks for the gitdir file in 
> the current working directory. Since the function has not yet 
> switched to $_gitdir when this check runs, it is almost impossible 
> to find the file. Consequently, this logic never triggers, preventing 
> linked worktrees from being recognized. 
>
> Maybe the identification of linked worktree should not directly look 
> for the gitdir file, but should check whether there is a.git file and 
> its content points to... /.git/worktrees/... ? Anyways, using the 
> literal {gitdir} to search in the current directory lead to risks. 
>
> Shroom
>
We cannot get to this code if not inside the gitdir, and if the user set GIT_DIR and/or
GIT_WORK_TREE to do something clever, that either worked or the code already threw an
error. git, without GIT_WORK_TREE set, uses the current directory as the worktree, or the
parent directory containing .git. So, we must be inside the gitdir if this code path gets hit.

Mark

Mark

^ permalink raw reply

* Re: [PATCH 16/18] odb/source-loose: wire up `write_object_stream()` callback
From: Junio C Hamano @ 2026-05-21 17:49 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git
In-Reply-To: <20260521-b4-pks-odb-source-loose-v1-16-6553b399be2d@pks.im>

Patrick Steinhardt <ps@pks.im> writes:

> -int odb_source_loose_write_stream(struct odb_source_loose *loose,
> +/*
> + * Write the given stream into the loose object source. The only difference to
> + * the generic implementation of this function is that we don't perform an

"difference to" -> "difference from"???

^ permalink raw reply

* Re: [PATCH 1/8] t0001: plug test gaps for git-init(1) with GIT_OBJECT_DIRECTORY
From: Kristoffer Haugsbakk @ 2026-05-21 17:51 UTC (permalink / raw)
  To: Patrick Steinhardt, git
In-Reply-To: <20260521-b4-pks-setup-centralize-odb-creation-v1-1-f130d2a7e8ae@pks.im>

On Thu, May 21, 2026, at 09:42, Patrick Steinhardt wrote:
> 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

Should this be s/, there/; there/ ? Depends on if this is a list of
three items or if “This is” is a subclause that is supposed to point at
“there's many”.

> 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" enviroment 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 broke this
> expectation.

Isn’t it more that “the upcoming changes *would have* broken” them if
not for this change? This seems to refer to a an alternative commit
history where this change does not exist?

>
> Plug this test gap.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>[snip]

^ permalink raw reply

* Re: [PATCH 8/8] setup: construct object database in `apply_repository_format()`
From: Junio C Hamano @ 2026-05-21 17:59 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git
In-Reply-To: <20260521-b4-pks-setup-centralize-odb-creation-v1-8-f130d2a7e8ae@pks.im>

Patrick Steinhardt <ps@pks.im> writes:

> With the preceding changes we now always construct the repository's
> object database before applying the repository format. Remove this
> duplication by constructing it in `apply_repository_format()` instead.
>
> Note that we create the object database _after_ having set up the
> repository's hash algorithm, but _before_ setting the compat hash
> algorithm. This is intentional:
>
>   - Constructing the object database may require knowledge of its
>     intended object format.
>
>   - Setting up the compatibility hash requires the object database to be
>     initialized already, because we immediately read the loose object
>     map.
>
> The first point is sensible, the second maybe a little less so. Ideally,
> it should be the responsibility of the object database itself to
> initialize any data structures required for the compatibility hash. But
> this would require further changes, so this is kept as-is for now.

Yeah, I guess it is a good place to stop, instead of solving the
chicken-and-egg problem in one go.

> Further note that this requires us to move handling of the environment
> variables GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES into
> the repository format, as well. This allows the caller more flexibility
> around whether or not those environment variables are being honored, as
> we do do want to respect them in "setup.c", but not in "repository.c".

It seems that we really really really want to do so ;-).  "do do
want to" -> "do want to" or even "want to", perhaps.

> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  repository.c |  4 +---
>  setup.c      | 45 +++++++++++++++++++++------------------------
>  setup.h      | 10 ++++++++++
>  3 files changed, 32 insertions(+), 27 deletions(-)
>
> diff --git a/repository.c b/repository.c
> index 61dfbb8be6..187dd471c4 100644
> --- a/repository.c
> +++ b/repository.c
> @@ -291,13 +291,11 @@ int repo_init(struct repository *repo,
>  	if (read_repository_format_from_commondir(&format, repo->commondir))
>  		goto error;
>  
> -	if (apply_repository_format(repo, &format, &err) < 0) {
> +	if (apply_repository_format(repo, &format, 0, &err) < 0) {
>  		warning("%s", err.buf);
>  		goto error;
>  	}
>  
> -	repo->objects = odb_new(repo, NULL, NULL);
> -
>  	if (worktree)
>  		repo_set_worktree(repo, worktree);
>  
> diff --git a/setup.c b/setup.c
> index 4a8d6230b1..513fc88749 100644
> --- a/setup.c
> +++ b/setup.c
> @@ -1752,12 +1752,22 @@ enum discovery_result discover_git_directory_reason(struct strbuf *commondir,
>  
>  int apply_repository_format(struct repository *repo,
>  			    const struct repository_format *format,
> +			    enum apply_repository_format_flags flags,
>  			    struct strbuf *err)
>  {
> +	char *object_directory = NULL, *alternate_object_directories = NULL;
> +
>  	if (verify_repository_format(format, err) < 0)
>  		return -1;
>  
> +	if (flags & APPLY_REPOSITORY_FORMAT_HONOR_ENV) {
> +		object_directory = xstrdup_or_null(getenv(DB_ENVIRONMENT));
> +		alternate_object_directories = xstrdup_or_null(getenv(ALTERNATE_DB_ENVIRONMENT));
> +	}
> +
>  	repo_set_hash_algo(repo, format->hash_algo);
> +	repo->objects = odb_new(repo, object_directory,
> +				alternate_object_directories);
>  	repo_set_compat_hash_algo(repo, format->compat_hash_algo);
>  	repo_set_ref_storage_format(repo,
>  				    format->ref_storage_format,
> @@ -1773,6 +1783,8 @@ int apply_repository_format(struct repository *repo,
>  	repo->repository_format_precious_objects =
>  		format->precious_objects;
>  
> +	free(alternate_object_directories);
> +	free(object_directory);
>  	return 0;
>  }
>  
> @@ -1785,7 +1797,8 @@ int apply_repository_format(struct repository *repo,
>   * If successful and fmt is not NULL, fill fmt with data.
>   */
>  static void check_and_apply_repository_format(struct repository *repo,
> -					      struct repository_format *fmt)
> +					      struct repository_format *fmt,
> +					      enum apply_repository_format_flags flags)
>  {
>  	struct repository_format repo_fmt = REPOSITORY_FORMAT_INIT;
>  	struct strbuf err = STRBUF_INIT;
> @@ -1794,7 +1807,7 @@ static void check_and_apply_repository_format(struct repository *repo,
>  		fmt = &repo_fmt;
>  
>  	check_repository_format_gently(repo_get_git_dir(repo), fmt, NULL);
> -	if (apply_repository_format(repo, fmt, &err) < 0)
> +	if (apply_repository_format(repo, fmt, flags, &err) < 0)
>  		die("%s", err.buf);
>  	startup_info->have_repository = 1;
>  
> @@ -1874,15 +1887,9 @@ const char *enter_repo(struct repository *repo, const char *path, unsigned flags
>  	}
>  
>  	if (is_git_directory(".")) {
> -		struct strvec to_free = STRVEC_INIT;
> -
>  		set_git_dir(repo, ".", 0);
> -		repo->objects = odb_new(repo,
> -					getenv_safe(&to_free, DB_ENVIRONMENT),
> -					getenv_safe(&to_free, ALTERNATE_DB_ENVIRONMENT));
> -		check_and_apply_repository_format(repo, NULL);
> -
> -		strvec_clear(&to_free);
> +		check_and_apply_repository_format(repo, NULL,
> +						  APPLY_REPOSITORY_FORMAT_HONOR_ENV);
>  		return path;
>  	}
>  
> @@ -2034,8 +2041,6 @@ const char *setup_git_directory_gently(struct repository *repo, int *nongit_ok)
>  	    startup_info->have_repository ||
>  	    /* GIT_DIR_EXPLICIT */
>  	    getenv(GIT_DIR_ENVIRONMENT)) {
> -		struct strvec to_free = STRVEC_INIT;
> -
>  		if (!repo->gitdir) {
>  			const char *gitdir = getenv(GIT_DIR_ENVIRONMENT);
>  			if (!gitdir)
> @@ -2046,17 +2051,13 @@ const char *setup_git_directory_gently(struct repository *repo, int *nongit_ok)
>  		if (startup_info->have_repository) {
>  			struct strbuf err = STRBUF_INIT;
>  
> -			repo->objects = odb_new(repo,
> -						getenv_safe(&to_free, DB_ENVIRONMENT),
> -						getenv_safe(&to_free, ALTERNATE_DB_ENVIRONMENT));
> -			if (apply_repository_format(repo, &repo_fmt, &err) < 0)
> +			if (apply_repository_format(repo, &repo_fmt,
> +						    APPLY_REPOSITORY_FORMAT_HONOR_ENV, &err) < 0)
>  				die("%s", err.buf);
>  
>  			clear_repository_format(&repo_fmt);
>  			strbuf_release(&err);
>  		}
> -
> -		strvec_clear(&to_free);
>  	}
>  	/*
>  	 * Since precompose_string_if_needed() needs to look at
> @@ -2805,7 +2806,6 @@ int init_db(struct repository *repo,
>  	int exist_ok = flags & INIT_DB_EXIST_OK;
>  	char *original_git_dir = real_pathdup(git_dir, 1);
>  	struct repository_format repo_fmt = REPOSITORY_FORMAT_INIT;
> -	struct strvec to_free = STRVEC_INIT;
>  
>  	if (real_git_dir) {
>  		struct stat st;
> @@ -2826,16 +2826,14 @@ int init_db(struct repository *repo,
>  	}
>  	startup_info->have_repository = 1;
>  
> -	repo->objects = odb_new(repo, getenv_safe(&to_free, DB_ENVIRONMENT),
> -				getenv_safe(&to_free, ALTERNATE_DB_ENVIRONMENT));
> -
>  	/*
>  	 * Check to see if the repository version is right.
>  	 * Note that a newly created repository does not have
>  	 * config file, so this will not fail.  What we are catching
>  	 * is an attempt to reinitialize new repository with an old tool.
>  	 */
> -	check_and_apply_repository_format(repo, &repo_fmt);
> +	check_and_apply_repository_format(repo, &repo_fmt,
> +					  APPLY_REPOSITORY_FORMAT_HONOR_ENV);
>  
>  	repository_format_configure(repo, &repo_fmt, hash, ref_storage_format);
>  
> @@ -2892,7 +2890,6 @@ int init_db(struct repository *repo,
>  	}
>  
>  	clear_repository_format(&repo_fmt);
> -	strvec_clear(&to_free);
>  	free(original_git_dir);
>  	return 0;
>  }
> diff --git a/setup.h b/setup.h
> index 5ed92f53fa..821b55aca0 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);
>  
> +enum apply_repository_format_flags {
> +	/*
> +	 * Honor environment variables when applying the repository format to
> +	 * the repository. For now, this only covers environment variables that
> +	 * relate to the object database.
> +	 */
> +	APPLY_REPOSITORY_FORMAT_HONOR_ENV = (1 << 0),
> +};
> +
>  /*
>   * Apply the given repository format to the repo. This initializes extensions
>   * and basic data structures required for normal operation. Returns 0 on
> @@ -228,6 +237,7 @@ int verify_repository_format(const struct repository_format *format,
>   */
>  int apply_repository_format(struct repository *repo,
>  			    const struct repository_format *format,
> +			    enum apply_repository_format_flags flags,
>  			    struct strbuf *err);
>  
>  const char *get_template_dir(const char *option_template);

^ permalink raw reply

* [PATCH 0/4] doc: replay: fix config link
From: kristofferhaugsbakk @ 2026-05-21 18:01 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Siddharth Asthana

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

Topic name: kh/doc-replay-config

Topic summary: link to the config for git-replay(1) (one variable) in
git-replay(1) and git-config(1). Also improve the doc for that config
variable and `--ref-action`.

[1/4] doc: link to config for git-replay(1)
[2/4] doc: replay: simplify replay.refAction description
[3/4] doc: replay: use a nested definition list
[4/4] doc: replay: move “default” to the right-hand-side

 Documentation/config.adoc        |  2 ++
 Documentation/config/replay.adoc | 17 +++++++----------
 Documentation/git-replay.adoc    | 13 +++++++++----
 3 files changed, 18 insertions(+), 14 deletions(-)


base-commit: a89346e34a937f001e5d397ee62224e3e9852040
-- 
2.54.0.13.g9c7419e39f8


^ permalink raw reply

* [PATCH 1/4] doc: link to config for git-replay(1)
From: kristofferhaugsbakk @ 2026-05-21 18:01 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Siddharth Asthana
In-Reply-To: <CV_doc_replay_config.709@msgid.xyz>

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

This config doc was added in 336ac90c (replay: add replay.refAction
config option, 2025-11-06) but never included anywhere. Include it in
git-replay(1) and git-config(1).

Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---
 Documentation/config.adoc     | 2 ++
 Documentation/git-replay.adoc | 4 ++++
 2 files changed, 6 insertions(+)

diff --git a/Documentation/config.adoc b/Documentation/config.adoc
index 62eebe7c545..51fabecb9b0 100644
--- a/Documentation/config.adoc
+++ b/Documentation/config.adoc
@@ -511,6 +511,8 @@ include::config/remotes.adoc[]
 
 include::config/repack.adoc[]
 
+include::config/replay.adoc[]
+
 include::config/rerere.adoc[]
 
 include::config/revert.adoc[]
diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead3..f9ca2db2833 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -209,6 +209,10 @@ This replays the range `aabbcc..ddeeff` onto commit `112233` and updates
 `refs/heads/mybranch` to point at the result. This can be useful when you want
 to use bare commit IDs instead of branch names.
 
+CONFIGURATION
+-------------
+include::config/replay.adoc[]
+
 GIT
 ---
 Part of the linkgit:git[1] suite
-- 
2.54.0.13.g9c7419e39f8


^ permalink raw reply related

* [PATCH 2/4] doc: replay: simplify replay.refAction description
From: kristofferhaugsbakk @ 2026-05-21 18:01 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Siddharth Asthana
In-Reply-To: <CV_doc_replay_config.709@msgid.xyz>

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

We don’t need to list what each argument does since the documentation
for `--ref-action` does that. So let’s simplify the `replay.refAction`
description by referring to git-replay(1).

Also make sure to not self-link for the git-replay(1) inclusion.

Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---
 Documentation/config/replay.adoc | 17 +++++++----------
 Documentation/git-replay.adoc    |  1 +
 2 files changed, 8 insertions(+), 10 deletions(-)

diff --git a/Documentation/config/replay.adoc b/Documentation/config/replay.adoc
index 7d549d2f0e5..42e521694d1 100644
--- a/Documentation/config/replay.adoc
+++ b/Documentation/config/replay.adoc
@@ -1,11 +1,8 @@
 replay.refAction::
-	Specifies the default mode for handling reference updates in
-	`git replay`. The value can be:
-+
---
-	* `update`: Update refs directly using an atomic transaction (default behavior).
-	* `print`: Output update-ref commands for pipeline use.
---
-+
-This setting can be overridden with the `--ref-action` command-line option.
-When not configured, `git replay` defaults to `update` mode.
+	Specifies the default mode for handling reference updates. Either `update` or `print`.
+ifdef::git-replay[]
+See `--ref-action`.
+endif::git-replay[]
+ifndef::git-replay[]
+See `--ref-action` for linkgit:git-replay[1] for details.
+endif::git-replay[]
diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index f9ca2db2833..4de85088d6c 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -211,6 +211,7 @@ to use bare commit IDs instead of branch names.
 
 CONFIGURATION
 -------------
+:git-replay: 1
 include::config/replay.adoc[]
 
 GIT
-- 
2.54.0.13.g9c7419e39f8


^ permalink raw reply related

* [PATCH 3/4] doc: replay: use a nested definition list
From: kristofferhaugsbakk @ 2026-05-21 18:02 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Siddharth Asthana
In-Reply-To: <CV_doc_replay_config.709@msgid.xyz>

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

This bullet list for `--ref-action` introduces a term with a colon.
This is exactly what a definition list is, structurally. Let’s be
sylistically consistent and use the definition list markup construct.

We can reuse the `::` delimiter since we use an open block.
But for consistency use the typical nested definition list
delimiter, namely `;;`.

Also drop the harmless but unneeded indentation.

Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---
 Documentation/git-replay.adoc | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index 4de85088d6c..b4fe43ec687 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -80,10 +80,10 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 	Control how references are updated. The mode can be:
 +
 --
-	* `update` (default): Update refs directly using an atomic transaction.
-	  All refs are updated or none are (all-or-nothing behavior).
-	* `print`: Output update-ref commands for pipeline use. This is the
-	  traditional behavior where output can be piped to `git update-ref --stdin`.
+`update` (default);; Update refs directly using an atomic transaction.
+	All refs are updated or none are (all-or-nothing behavior).
+`print`;; Output update-ref commands for pipeline use. This is the
+	traditional behavior where output can be piped to `git update-ref --stdin`.
 --
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
-- 
2.54.0.13.g9c7419e39f8


^ permalink raw reply related

* [PATCH 4/4] doc: replay: move “default” to the right-hand-side
From: kristofferhaugsbakk @ 2026-05-21 18:02 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Siddharth Asthana
In-Reply-To: <CV_doc_replay_config.709@msgid.xyz>

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

This is now a definition list (see previous commit) and parentheticals
like this do not go on the left-hand-side. Moving it to the other side
makes it stand out just as much and is also more consistent with the
rest of the documentation.

Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---

Notes (series):
    > do not go on the left-hand-side.
    
    At least I haven’t seen it.

 Documentation/git-replay.adoc | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index b4fe43ec687..39ecc2e1876 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -80,7 +80,7 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 	Control how references are updated. The mode can be:
 +
 --
-`update` (default);; Update refs directly using an atomic transaction.
+`update`;; (default) Update refs directly using an atomic transaction.
 	All refs are updated or none are (all-or-nothing behavior).
 `print`;; Output update-ref commands for pipeline use. This is the
 	traditional behavior where output can be piped to `git update-ref --stdin`.
-- 
2.54.0.13.g9c7419e39f8


^ permalink raw reply related

* Re: [PATCH v9 3/5] branch: add --prune-merged <remote>
From: Harald Nordgren @ 2026-05-21 19:16 UTC (permalink / raw)
  To: phillip.wood
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	Johannes Sixt
In-Reply-To: <f1d15d08-6fee-479f-8ed0-34efd256d8dc@gmail.com>

> While we want to clean up topic branches, we want to avoid cleaning up
> branches like "master" which follow an upstream branch and therefore
> look like they've been merged straight after they've been pulled. So I
> think as well as checking that the local branch is merged into its
> upstream branch, we want to check that the local branch is not pushed to
> the upstream branch i.e. that branch@{upstream} != branch@{push}.

This one I handle already by letting the default branch be guarded.


Harald

On Thu, May 21, 2026 at 11:46 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> Hi Harald
>
> A couple more thoughts ...
>
> On 18/05/2026 16:27, Phillip Wood wrote:
> > On 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:
> >> From: Harald Nordgren <haraldnordgren@gmail.com>
> >>
> >> Delete the local branches that --forked <remote> would list, but
> >> only those whose tip is reachable from their configured upstream
> >> remote-tracking branch (branch.<name>.merge): the work has already
> >> landed on the upstream it tracks, so the local copy is no longer
> >> needed.
>
> While we want to clean up topic branches, we want to avoid cleaning up
> branches like "master" which follow an upstream branch and therefore
> look like they've been merged straight after they've been pulled. So I
> think as well as checking that the local branch is merged into its
> upstream branch, we want to check that the local branch is not pushed to
> the upstream branch i.e. that branch@{upstream} != branch@{push}. That
> should also avoid deleting newly created topic branches that match their
> upstream (I think that's probably less likely to happen in practice as
> I'd expect the branch to be checked out and therefore protected against
> deletion).
>
> Also as this is a destructive operation (there is no way to restore a
> deleted branch and its reflog) it would be good to have a --dry-run option.
>
> Thanks
>
> Phillip
>

^ permalink raw reply

* Re: [PATCH 7/9] notes: support an external command to display notes
From: brian m. carlson @ 2026-05-21 21:18 UTC (permalink / raw)
  To: Siddh Raman Pant
  Cc: git@vger.kernel.org, gitster@pobox.com, newren@gmail.com,
	ps@pks.im, code@khaugsbakk.name
In-Reply-To: <4086055f59eec99f94847a1b37c684a084f08e0b.camel@oracle.com>

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

On 2026-05-21 at 04:12:41, Siddh Raman Pant wrote:
> On Thu, May 21 2026 at 06:42:00 +0530, brian m. carlson wrote:
> > > Assisted-by: Codex:gpt-5.5-xhigh-fast
> > 
> > Just a question here: was this written in whole or in part by Codex, or
> > was it just used as a reference to ask questions?  I ask because the
> > style of notes-external.c differs quite a bit from the style we use (for
> > one, the horizontal rule comments) and we have this in
> 
> AI tools typically don't generate comments in code like in this series,
> you can see by trying out for yourself. Each comment is hand-written by
> me. Sorry, I'll remove those lines in v2 after this discussion.

I've actually seen AI tools do things very similar to what you've
written.

> > SubmittingPatches:
> > 
> >     The Developer's Certificate of Origin requires contributors to certify
> >     that they know the origin of their contributions to the project and
> >     that they have the right to submit it under the project's license.
> >     It's not yet clear that this can be legally satisfied when submitting
> >     significant amount of content that has been generated by AI tools.
> > 
> >     [...]
> > 
> >     To avoid these issues, we will reject anything that looks AI
> >     generated, that sounds overly formal or bloated, that looks like AI
> >     slop, that looks good on the surface but makes no sense, or that
> >     senders don’t understand or cannot explain.
> 
> Please tell me why this change is a slop and doesn't make sense.

I didn't say this was slop and didn't make sense.  I quoted the portion
that says that we don't accept anything AI generated, including for
license reasons.  There's still very little clarity about whether AI
code is a derivative work of the training set or whether it can be
copyrightable at all, very especially on a worldwide basis.  We don't
want to end up with a legal or license problem that the DCO was intended
to solve.

> If I wanted to mislead here, I would not have used the "Assisted-by"
> trailer, which is now being used in kernel land:
> 
> https://www.kernel.org/doc/html/latest/process/submitting-patches.html#using-assisted-by

The kernel and Git do different things.  Linux generally allows AI and
we generally restrict its use quite heavily.  Linux tries to never break
dependent projects and we don't have that policy.

I appreciate the header being included and agree that it should be, but
it's important we ask questions about the provenance of the code when AI
is used because many people do not read SubmittingPatches (or
contributing documentation in general).

> > I'll note that it also has a lot of global variables, which are common
> > in the codebase but we're trying to move away from, 
> 
> Is there a new facility to store the config without a global variable?
> 
> If the issue is the number, I can make a housing struct if you want.

We'd typically use repo_config_get_string or such to fetch the
configuration these days.  If you don't want to fetch it multiple times,
we'd generally read all the config and put it in a struct that we'd
initialize with a function at a suitable time.

There's effort to avoid the global variables because they don't work
well in libraries and we want to allow libgit.a to be used more
generally.  In addition, Rust considers static mutable variables to be
unsafe, so as we add more Rust, we'll need to minimize the use of any
globals.

> I added comments to explain the code clearly as it's being followed,
> especially since this is a new feature and I wanted the intent to be
> clear.
> 
> If you could tell me which comments to remove, that would be great.

I don't think it's necessarily a problem to have the comments, but it is
uncommon in our codebase, which is what drew my attention.
-- 
brian m. carlson (they/them)
Toronto, Ontario, CA

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

^ permalink raw reply

* [PATCH v10 0/4] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-05-21 22:40 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren
In-Reply-To: <pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com>

 * --forked / --prune-merged now take a branch glob instead of a remote name
   — origin, origin/*, origin/release-* all work. This replaces the
   remote-only form and subsumes the old --all-remotes flag, which has been
   dropped.
 * New --dry-run for --prune-merged.

Harald Nordgren (4):
  branch: add --forked <branch>
  branch: add --prune-merged <branch>
  branch: add branch.<name>.pruneMerged opt-out
  branch: add --dry-run for --prune-merged

 Documentation/config/branch.adoc |   5 +
 Documentation/git-branch.adoc    |  33 ++++
 builtin/branch.c                 | 253 +++++++++++++++++++++++++--
 t/t3200-branch.sh                | 282 +++++++++++++++++++++++++++++++
 4 files changed, 560 insertions(+), 13 deletions(-)


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

Range-diff vs v9:

 1:  9324b26091 ! 1:  f2df159830 branch: add --forked <remote>
     @@ Metadata
      Author: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Commit message ##
     -    branch: add --forked <remote>
     +    branch: add --forked <branch>
      
     -    List local branches whose configured upstream falls within any of
     -    the given <remote> arguments. <remote> may be either a configured
     -    remote name (matching all of its remote-tracking branches) or a
     -    single remote-tracking branch. Multiple <remote> arguments are
     -    unioned.
     +            git branch --forked <branch>...
      
     -    This is the building block for --prune-merged, which deletes the
     -    listed branches.
     +    lists local branches whose configured upstream matches any
     +    of the given <branch> arguments.
     +
     +    Each <branch> is resolved to the same kind of ref that
     +    branch.<name>.remote and branch.<name>.merge together point at:
     +    a remote-tracking branch (e.g. origin/master), or, for branches
     +    tracking a local upstream, a local branch (e.g. master).
     +    Shell-style globs are also accepted (e.g. 'origin/*'). Multiple
     +    arguments are unioned.
     +
     +    This is the building block for --prune-merged.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ 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 <remote>...
     ++git branch --forked <branch>...
       
       DESCRIPTION
       -----------
     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
       	nothing is printed.
       
      +`--forked`::
     -+	List local branches that fork from any of the given _<remote>_
     -+	arguments, that is, those whose configured upstream
     -+	(`branch.<name>.merge`) is one of those remotes' remote-tracking
     -+	branches.
     -++
     -+Each _<remote>_ may be either the name of a configured remote
     -+(e.g. `origin`, meaning any branch tracking a
     -+`refs/remotes/origin/*` ref) or a specific remote-tracking branch
     -+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
     ++	List local branches whose configured upstream matches any
     ++	of the given _<branch>_ arguments. Each argument is either
     ++	a ref (e.g. `origin/master`, `master`) or a shell-style
     ++	glob (e.g. `'origin/*'`). Multiple arguments are unioned.
      +
       `-v`::
       `-vv`::
       `--verbose`::
      
       ## builtin/branch.c ##
     +@@
     + #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]"),
      @@ 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 <remote>..."),
     ++	N_("git branch [<options>] --forked <branch>..."),
       	NULL
       };
       
     +@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
     + 
     + static int check_branch_commit(const char *branchname, const char *refname,
     + 			       const struct object_id *oid, struct commit *head_rev,
     +-			       int kinds, int force)
     ++			       int kinds, int force, int warn_only,
     ++			       int *n_not_merged)
     + {
     + 	struct commit *rev = lookup_commit_reference(the_repository, oid);
     + 	if (!force && !rev) {
     +@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
     + 		return -1;
     + 	}
     + 	if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
     +-		error(_("the branch '%s' is not fully merged"), branchname);
     +-		advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
     +-				  _("If you are sure you want to delete it, "
     +-				  "run 'git branch -D %s'"), branchname);
     ++		if (warn_only) {
     ++			warning(_("the branch '%s' is not fully merged"),
     ++				branchname);
     ++		} else {
     ++			error(_("the branch '%s' is not fully merged"),
     ++			      branchname);
     ++			advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
     ++					  _("If you are sure you want to delete it, "
     ++					  "run 'git branch -D %s'"), branchname);
     ++		}
     ++		if (n_not_merged)
     ++			(*n_not_merged)++;
     + 		return -1;
     + 	}
     + 	return 0;
     +@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
     + }
     + 
     + static int delete_branches(int argc, const char **argv, int force, int kinds,
     +-			   int quiet)
     ++			   int quiet, int warn_only, int *n_not_merged)
     + {
     + 	struct commit *head_rev = NULL;
     + 	struct object_id oid;
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 
     + 		if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
     + 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
     +-					force)) {
     +-			ret = 1;
     ++					force, warn_only, n_not_merged)) {
     ++			if (!warn_only)
     ++				ret = 1;
     + 			goto next;
     + 		}
     + 
      @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
       	free_worktrees(worktrees);
       }
       
      +static void parse_forked_args(int argc, const char **argv,
     -+			      struct string_list *remote_names,
     -+			      struct string_list *tracking_refs)
     ++			      struct string_list *upstream_patterns)
      +{
      +	int i;
      +
      +	for (i = 0; i < argc; i++) {
      +		const char *arg = argv[i];
     -+		struct remote *remote;
      +		struct object_id oid;
      +		char *full_ref = NULL;
     ++		const char *short_ref;
      +
     -+		remote = remote_get(arg);
     -+		if (remote && remote_is_configured(remote, 0)) {
     -+			string_list_insert(remote_names, remote->name);
     ++		if (has_glob_specials(arg)) {
     ++			string_list_insert(upstream_patterns, arg);
      +			continue;
      +		}
      +
      +		if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
      +				  &full_ref, 0) == 1 &&
     -+		    starts_with(full_ref, "refs/remotes/")) {
     -+			string_list_insert(tracking_refs, full_ref);
     ++		    (skip_prefix(full_ref, "refs/heads/", &short_ref) ||
     ++		     skip_prefix(full_ref, "refs/remotes/", &short_ref))) {
     ++			string_list_insert(upstream_patterns, short_ref);
      +			free(full_ref);
      +			continue;
      +		}
      +		free(full_ref);
      +
     -+		die(_("'%s' is neither a configured remote nor a "
     -+		      "remote-tracking branch"), arg);
     ++		die(_("'%s' is not a valid branch or pattern"), arg);
      +	}
      +}
      +
     -+static int branch_is_forked(const char *short_name,
     -+			    const struct string_list *remote_names,
     -+			    const struct string_list *tracking_refs)
     -+{
     -+	struct branch *branch = branch_get(short_name);
     -+	const char *upstream;
     -+
     -+	if (!branch || !branch->remote_name)
     -+		return 0;
     -+
     -+	if (string_list_has_string(remote_names, branch->remote_name))
     -+		return 1;
     -+
     -+	upstream = branch_get_upstream(branch, NULL);
     -+	if (upstream && string_list_has_string(tracking_refs, upstream))
     -+		return 1;
     -+
     -+	return 0;
     -+}
     -+
      +struct forked_cb {
     -+	const struct string_list *remote_names;
     -+	const struct string_list *tracking_refs;
     ++	const struct string_list *upstream_patterns;
      +	struct string_list *out;
      +};
      +
      +static int collect_forked_branch(const struct reference *ref, void *cb_data)
      +{
      +	struct forked_cb *cb = cb_data;
     ++	struct branch *branch;
     ++	const char *upstream, *short_upstream;
     ++	const struct string_list_item *item;
      +
      +	if (ref->flags & REF_ISSYMREF)
      +		return 0;
     -+	if (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))
     -+		string_list_append(cb->out, ref->name);
     ++	branch = branch_get(ref->name);
     ++	if (!branch)
     ++		return 0;
     ++	upstream = branch_get_upstream(branch, NULL);
     ++	if (!upstream)
     ++		return 0;
     ++	short_upstream = upstream;
     ++	(void)(skip_prefix(short_upstream, "refs/heads/", &short_upstream) ||
     ++	       skip_prefix(short_upstream, "refs/remotes/", &short_upstream));
     ++
     ++	for_each_string_list_item(item, cb->upstream_patterns)
     ++		if (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {
     ++			string_list_append(cb->out, ref->name)->util =
     ++				xstrdup(upstream);
     ++			return 0;
     ++		}
      +	return 0;
      +}
      +
     -+static int list_forked_branches(int argc, const char **argv)
     ++static void collect_forked_set(int argc, const char **argv,
     ++			       struct string_list *out)
      +{
     -+	struct string_list remote_names = STRING_LIST_INIT_NODUP;
     -+	struct string_list tracking_refs = STRING_LIST_INIT_DUP;
     -+	struct string_list out = STRING_LIST_INIT_DUP;
     -+	struct string_list_item *item;
     ++	struct string_list upstream_patterns = STRING_LIST_INIT_DUP;
      +	struct forked_cb cb = {
     -+		.remote_names = &remote_names,
     -+		.tracking_refs = &tracking_refs,
     -+		.out = &out,
     ++		.upstream_patterns = &upstream_patterns,
     ++		.out = out,
      +	};
      +
     -+	if (!argc)
     -+		die(_("--forked requires at least one <remote>"));
     -+
     -+	parse_forked_args(argc, argv, &remote_names, &tracking_refs);
     ++	parse_forked_args(argc, argv, &upstream_patterns);
      +
      +	refs_for_each_branch_ref(get_main_ref_store(the_repository),
      +				 collect_forked_branch, &cb);
      +
     -+	string_list_sort(&out);
     ++	string_list_clear(&upstream_patterns, 0);
     ++}
     ++
     ++static int list_forked_branches(int argc, const char **argv)
     ++{
     ++	struct string_list out = STRING_LIST_INIT_DUP;
     ++	struct string_list_item *item;
     ++
     ++	if (!argc)
     ++		die(_("--forked requires at least one <branch>"));
     ++
     ++	collect_forked_set(argc, argv, &out);
      +	for_each_string_list_item(item, &out)
      +		puts(item->string);
      +
     -+	string_list_clear(&remote_names, 0);
     -+	string_list_clear(&tracking_refs, 0);
     -+	string_list_clear(&out, 0);
     ++	string_list_clear(&out, 1);
      +	return 0;
      +}
      +
     @@ builtin/branch.c: int cmd_branch(int argc,
       		OPT_BOOL(0, "edit-description", &edit_description,
       			 N_("edit the description for the branch")),
      +		OPT_BOOL(0, "forked", &forked,
     -+			N_("list local branches forked from the given <remote>s")),
     ++			N_("list local branches whose upstream matches the given <branch>...")),
       		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,
       		usage_with_options(builtin_branch_usage, options);
       
      @@ builtin/branch.c: int cmd_branch(int argc,
     + 	if (delete) {
     + 		if (!argc)
       			die(_("branch name required"));
     - 		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
     - 		goto out;
     +-		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
     ++		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     ++				      quiet, 0, NULL);
     ++		goto out;
      +	} else if (forked) {
      +		ret = list_forked_branches(argc, argv);
     -+		goto out;
     + 		goto out;
       	} else if (show_current) {
       		print_current_branch_name();
     - 		ret = 0;
      
       ## t/t3200-branch.sh ##
      @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +	git -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 detached &&
     ++	git -C forked branch --track topic-on-main main
     ++'
     ++
     ++test_expect_success '--forked <remote-tracking-branch> lists matching branches' '
     ++	git -C forked branch --forked origin/one >actual &&
     ++	echo local-one >expect &&
     ++	test_cmp expect actual
     ++'
     ++
     ++test_expect_success '--forked <local-branch> lists branches tracking that local branch' '
     ++	git -C forked branch --forked main >actual &&
     ++	echo topic-on-main >expect &&
     ++	test_cmp expect actual
      +'
      +
     -+test_expect_success '--forked <remote-name> lists branches tracking that remote' '
     -+	git -C forked branch --forked origin >actual &&
     ++test_expect_success '--forked <glob> matches every upstream under the pattern' '
     ++	git -C forked branch --forked "origin/*" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-one
      +	local-two
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +	test_cmp expect actual
      +'
      +
     -+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '
     -+	git -C forked branch --forked origin/one >actual &&
     -+	echo local-one >expect &&
     ++test_expect_success '--forked unions multiple <branch> arguments' '
     ++	git -C forked branch --forked origin/one other/foreign >actual &&
     ++	cat >expect <<-\EOF &&
     ++	local-foreign
     ++	local-one
     ++	EOF
      +	test_cmp expect actual
      +'
      +
     -+test_expect_success '--forked unions multiple <remote> arguments' '
     -+	git -C forked branch --forked origin/one other >actual &&
     ++test_expect_success '--forked combines literal and glob arguments' '
     ++	git -C forked branch --forked main "other/*" >actual &&
     ++	cat >expect <<-\EOF &&
     ++	local-foreign
     ++	topic-on-main
     ++	EOF
     ++	test_cmp expect actual
     ++'
     ++
     ++test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
     ++	git -C forked branch --forked "*/*" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-foreign
      +	local-one
     ++	local-two
     ++	main
      +	EOF
      +	test_cmp expect actual
      +'
      +
     -+test_expect_success '--forked rejects unknown remote/ref' '
     ++test_expect_success '--forked rejects unknown branch/pattern' '
      +	test_must_fail git -C forked branch --forked nope 2>err &&
     -+	test_grep "neither a configured remote nor a remote-tracking branch" err
     ++	test_grep "not a valid branch or pattern" err
      +'
      +
     -+test_expect_success '--forked requires at least one <remote>' '
     ++test_expect_success '--forked requires at least one <branch>' '
      +	test_must_fail git -C forked branch --forked 2>err &&
     -+	test_grep "at least one <remote>" err
     ++	test_grep "at least one <branch>" err
      +'
      +
       test_done
 2:  2a13e5d4bc < -:  ---------- branch: let delete_branches warn instead of error on bulk refusal
 3:  f87e96e99d ! 2:  718e28c7e0 branch: add --prune-merged <remote>
     @@ Metadata
      Author: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Commit message ##
     -    branch: add --prune-merged <remote>
     +    branch: add --prune-merged <branch>
      
     -    Delete the local branches that --forked <remote> would list, but
     -    only those whose tip is reachable from their configured upstream
     -    remote-tracking branch (branch.<name>.merge): the work has already
     -    landed on the upstream it tracks, so the local copy is no longer
     -    needed.
     +            git branch --prune-merged <branch>...
      
     -    A branch whose upstream no longer resolves locally is left alone --
     -    its disappearance is not, on its own, evidence that the work was
     -    integrated. With --force, skip the reachability check and delete
     -    every branch in the candidate set. The currently checked-out
     -    branch in any worktree is always preserved, as is the local branch
     -    that mirrors <remote>'s default branch.
     +    deletes the local branches that --forked <branch> would list,
     +    but only those whose tip is reachable from their configured
     +    upstream: the work has already landed on the upstream the
     +    branch tracks, so the local copy is no longer needed.
      
     -    Reachability is read from whatever the remote-tracking refs say
     -    locally, so the natural workflow is
     +    The following branches are always preserved:
     +
     +    * the currently checked-out branch in any worktree;
     +    * any local branch whose name matches the default branch of
     +      any configured remote (the target of
     +      refs/remotes/<remote>/HEAD) -- typically 'main' or
     +      'master';
     +    * any branch whose upstream no longer resolves locally.
     +
     +    Reachability is read from whatever branch.<name>.merge
     +    resolves to locally, which is usually a remote-tracking ref
     +    but may also be a local branch. When the upstream is a
     +    remote-tracking ref, the natural workflow is
      
                  git fetch <remote>
     -            git branch --prune-merged <remote>
     +            git branch --prune-merged <upstream-pattern>
      
     -    with no implicit cleanup driven by fetch itself.
     +    so the upstream reflects the current state before pruning.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc
      @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
       git branch (-d|-D) [-r] <branch-name>...
       git branch --edit-description [<branch-name>]
     - git branch --forked <remote>...
     -+git branch --prune-merged <remote>...
     + git branch --forked <branch>...
     ++git branch --prune-merged <branch>...
       
       DESCRIPTION
       -----------
     -@@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a configured remote
     - `refs/remotes/origin/*` ref) or a specific remote-tracking branch
     - (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
     +@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
     + 	a ref (e.g. `origin/master`, `master`) or a shell-style
     + 	glob (e.g. `'origin/*'`). Multiple arguments are unioned.
       
      +`--prune-merged`::
      +	Delete the local branches that `--forked` would list for
     -+	the same _<remote>_ arguments, but only those whose tip is
     -+	reachable from their configured upstream remote-tracking
     -+	branch (`branch.<name>.merge`). In other words: the work on
     -+	the branch has already landed on the upstream it tracks, so
     -+	the local copy is no longer needed.
     ++	the same _<branch>_ arguments, but only those whose tip is
     ++	reachable from their configured upstream.
     +++
     ++For arguments that refer to remote-tracking branches, run
     ++`git fetch` first so reachability is checked against the
     ++current upstream state; refs are read locally.
      ++
     -+Run `git fetch` first so the upstream remote-tracking branches
     -+reflect the current state of _<remote>_; reachability is checked
     -+against whatever the remote-tracking refs say locally.
     ++The following branches are always preserved:
      ++
     -+A branch whose upstream no longer resolves locally is left alone
     -+(its disappearance is not, on its own, evidence that the work was
     -+integrated). The currently checked-out branch in any worktree is
     -+always preserved, as is the local branch that mirrors _<remote>_'s
     -+default branch.
     ++--
     ++* the currently checked-out branch in any worktree;
     ++* any local branch whose name matches the default branch of
     ++  any configured remote (the target of
     ++  `refs/remotes/<remote>/HEAD`) -- typically `main` or
     ++  `master`;
     ++* any branch whose upstream no longer resolves locally.
     ++--
      +
       `-v`::
       `-vv`::
     @@ builtin/branch.c
       #include "column.h"
       #include "utf8.h"
       #include "ref-filter.h"
     +@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
     + 	N_("git branch [<options>] [-r | -a] [--points-at]"),
     + 	N_("git branch [<options>] [-r | -a] [--format]"),
     + 	N_("git branch [<options>] --forked <branch>..."),
     ++	N_("git branch [<options>] --prune-merged <branch>..."),
     + 	NULL
     + };
     + 
      @@ builtin/branch.c: static int branch_merged(int kind, const char *name,
       	 * any of the following code, but during the transition period,
       	 * a gentle reminder is in order.
     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,
       		if (expect < 0)
       			exit(128);
       		if (expect == merged)
     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
     - 	strbuf_release(&buf);
     - }
     - 
     --static int delete_branches(int argc, const char **argv, int force, int kinds,
     -+static int delete_branches(int argc, const char **argv,
     -+			   int no_head_fallback,
     -+			   int force, int kinds,
     - 			   int quiet, int warn_only, int *n_not_merged)
     - {
     - 	struct commit *head_rev = NULL;
     -@@ builtin/branch.c: 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)) {
     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     - 		}
     - 
     - 		if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
     --		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
     --					force, warn_only, n_not_merged)) {
     -+		    check_branch_commit(bname.buf, name, &oid, head_rev,
     -+					kinds, force, warn_only, n_not_merged)) {
     - 			if (!warn_only)
     - 				ret = 1;
     - 			goto next;
      @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
       	return 0;
       }
       
     --static int list_forked_branches(int argc, const char **argv)
     -+static void collect_default_branch_refs(const struct string_list *remote_names,
     -+					struct string_list *out)
     ++static int collect_default_branch_name(struct remote *remote, void *cb_data)
      +{
     ++	struct string_list *protected = cb_data;
      +	struct ref_store *refs = get_main_ref_store(the_repository);
     -+	struct string_list_item *item;
     -+
     -+	for_each_string_list_item(item, remote_names) {
     -+		struct strbuf head = STRBUF_INIT;
     -+		const char *target;
     -+
     -+		strbuf_addf(&head, "refs/remotes/%s/HEAD", item->string);
     -+		target = refs_resolve_ref_unsafe(refs, head.buf,
     -+						 RESOLVE_REF_NO_RECURSE,
     -+						 NULL, NULL);
     -+		if (target && starts_with(target, "refs/remotes/"))
     -+			string_list_insert(out, target);
     -+		strbuf_release(&head);
     ++	struct strbuf head = STRBUF_INIT;
     ++	const char *target;
     ++
     ++	strbuf_addf(&head, "refs/remotes/%s/HEAD", remote->name);
     ++	target = refs_resolve_ref_unsafe(refs, head.buf,
     ++					 RESOLVE_REF_NO_RECURSE, NULL, NULL);
     ++	if (target) {
     ++		const char *leaf = strrchr(target, '/');
     ++		if (leaf)
     ++			string_list_insert(protected, leaf + 1);
      +	}
     ++	strbuf_release(&head);
     ++	return 0;
      +}
      +
     -+static void collect_forked_set(int argc, const char **argv,
     -+			       struct string_list *protected_default_refs,
     -+			       struct string_list *out)
     + static void collect_forked_set(int argc, const char **argv,
     + 			       struct string_list *out)
       {
     - 	struct string_list remote_names = STRING_LIST_INIT_NODUP;
     - 	struct string_list tracking_refs = STRING_LIST_INIT_DUP;
     --	struct string_list out = STRING_LIST_INIT_DUP;
     --	struct string_list_item *item;
     - 	struct forked_cb cb = {
     - 		.remote_names = &remote_names,
     - 		.tracking_refs = &tracking_refs,
     --		.out = &out,
     -+		.out = out,
     - 	};
     - 
     --	if (!argc)
     --		die(_("--forked requires at least one <remote>"));
     --
     - 	parse_forked_args(argc, argv, &remote_names, &tracking_refs);
     - 
     - 	refs_for_each_branch_ref(get_main_ref_store(the_repository),
     - 				 collect_forked_branch, &cb);
     - 
     --	string_list_sort(&out);
     --	for_each_string_list_item(item, &out)
     --		puts(item->string);
     -+	string_list_sort(out);
     -+
     -+	if (protected_default_refs)
     -+		collect_default_branch_refs(&remote_names, protected_default_refs);
     - 
     - 	string_list_clear(&remote_names, 0);
     - 	string_list_clear(&tracking_refs, 0);
     -+}
     -+
     -+static int list_forked_branches(int argc, const char **argv)
     -+{
     -+	struct string_list out = STRING_LIST_INIT_DUP;
     -+	struct string_list_item *item;
     -+
     -+	if (!argc)
     -+		die(_("--forked requires at least one <remote>"));
     -+
     -+	collect_forked_set(argc, argv, NULL, &out);
     -+	for_each_string_list_item(item, &out)
     -+		puts(item->string);
     -+
     - 	string_list_clear(&out, 0);
     +@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
       	return 0;
       }
       
      +static int prune_merged_branches(int argc, const char **argv, int quiet)
      +{
     ++	struct ref_store *refs = get_main_ref_store(the_repository);
      +	struct string_list candidates = STRING_LIST_INIT_DUP;
     -+	struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
     ++	struct string_list protected_default_names = STRING_LIST_INIT_DUP;
      +	struct strvec deletable = STRVEC_INIT;
     ++	struct strbuf buf = STRBUF_INIT;
      +	struct string_list_item *item;
      +	int n_not_merged = 0;
      +	int ret = 0;
      +
      +	if (!argc)
     -+		die(_("--prune-merged requires at least one <remote>"));
     ++		die(_("--prune-merged requires at least one <branch>"));
      +
     -+	collect_forked_set(argc, argv, &protected_default_refs, &candidates);
     ++	collect_forked_set(argc, argv, &candidates);
     ++	for_each_remote(collect_default_branch_name, &protected_default_names);
      +
      +	for_each_string_list_item(item, &candidates) {
      +		const char *short_name = item->string;
     -+		struct strbuf full = STRBUF_INIT;
     -+		struct branch *branch;
     -+		const char *upstream;
     ++		const char *upstream = item->util;
      +
     -+		strbuf_addf(&full, "refs/heads/%s", short_name);
     -+		if (branch_checked_out(full.buf)) {
     -+			strbuf_release(&full);
     ++		strbuf_reset(&buf);
     ++		strbuf_addf(&buf, "refs/heads/%s", short_name);
     ++		if (branch_checked_out(buf.buf))
      +			continue;
     -+		}
     -+		strbuf_release(&full);
     -+
     -+		branch = branch_get(short_name);
     -+		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
     -+		if (!upstream ||
     -+		    !refs_ref_exists(get_main_ref_store(the_repository),
     -+				     upstream))
     ++
     ++		if (string_list_has_string(&protected_default_names,
     ++					   short_name))
     ++			continue;
     ++
     ++		if (!refs_ref_exists(refs, upstream))
      +			continue;
     -+		if (string_list_has_string(&protected_default_refs, upstream)) {
     -+			const char *leaf = strrchr(upstream, '/');
     -+			if (leaf && !strcmp(leaf + 1, short_name))
     -+				continue;
     -+		}
      +
      +		strvec_push(&deletable, short_name);
      +	}
     ++	strbuf_release(&buf);
      +
      +	if (deletable.nr)
      +		ret = delete_branches(deletable.nr, deletable.v,
     -+				      1, 0,
     -+				      FILTER_REFS_BRANCHES, quiet,
     ++				      0, FILTER_REFS_BRANCHES, quiet,
      +				      1, &n_not_merged);
      +
      +	if (n_not_merged && !quiet)
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      +			n_not_merged);
      +
      +	strvec_clear(&deletable);
     -+	string_list_clear(&candidates, 0);
     -+	string_list_clear(&protected_default_refs, 0);
     ++	string_list_clear(&candidates, 1);
     ++	string_list_clear(&protected_default_names, 0);
      +	return ret;
      +}
      +
     @@ builtin/branch.c: int cmd_branch(int argc,
      @@ builtin/branch.c: int cmd_branch(int argc,
       			 N_("edit the description for the branch")),
       		OPT_BOOL(0, "forked", &forked,
     - 			N_("list local branches forked from the given <remote>s")),
     + 			N_("list local branches whose upstream matches the given <branch>...")),
      +		OPT_BOOL(0, "prune-merged", &prune_merged,
     -+			N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
     ++			N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
       		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,
       		usage_with_options(builtin_branch_usage, options);
       
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 	if (delete) {
     - 		if (!argc)
     - 			die(_("branch name required"));
     --		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     -+		ret = delete_branches(argc, argv, 0, delete > 1, filter.kind,
     - 				      quiet, 0, NULL);
     - 		goto out;
       	} else if (forked) {
       		ret = list_forked_branches(argc, argv);
       		goto out;
     @@ builtin/branch.c: int cmd_branch(int argc,
       		ret = 0;
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>' '
     - 	test_grep "at least one <remote>" err
     +@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>' '
     + 	test_grep "at least one <branch>" err
       '
       
      +test_expect_success '--prune-merged: setup' '
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	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 &&
     ++	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 with a literal upstream argument' '
     ++	test_when_finished "rm -rf pm-literal" &&
     ++	git clone pm-upstream pm-literal &&
     ++	git -C pm-literal branch one one-commit &&
     ++	git -C pm-literal branch --set-upstream-to=origin/next one &&
     ++	git -C pm-literal branch keepme one-commit &&
     ++	git -C pm-literal branch --set-upstream-to=origin/main keepme &&
     ++
     ++	git -C pm-literal branch --prune-merged origin/next &&
     ++
     ++	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&
     ++	git -C pm-literal rev-parse --verify refs/heads/keepme
     ++'
     ++
     ++test_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 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 branch --prune-merged origin/next 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 with a local-branch argument' '
     ++	test_create_repo pm-local &&
     ++	test_when_finished "rm -rf pm-local" &&
     ++	test_commit -C pm-local base &&
     ++	git -C pm-local branch topic base &&
     ++	git -C pm-local config branch.topic.remote . &&
     ++	git -C pm-local config branch.topic.merge refs/heads/main &&
     ++	git -C pm-local checkout --detach &&
     ++
     ++	git -C pm-local branch --prune-merged main &&
     ++
     ++	test_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&
     ++	git -C pm-local rev-parse --verify refs/heads/main
     ++'
     ++
      +test_expect_success '--prune-merged spares branches with un-integrated commits' '
      +	test_when_finished "rm -rf pm-unmerged" &&
      +	git clone pm-upstream pm-unmerged &&
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	test_commit -C pm-unmerged local-only &&
      +	git -C pm-unmerged checkout - &&
      +
     -+	git -C pm-unmerged branch --prune-merged origin 2>err &&
     ++	git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
      +	test_grep "not fully merged" err &&
      +	test_grep "Skipped 1 branch" err &&
      +	test_grep "git branch -D" err &&
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	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 branch --prune-merged "origin/*" &&
      +
      +	git -C pm-upstream-gone rev-parse --verify refs/heads/one
      +'
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	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 branch --prune-merged "origin/*" &&
      +
      +	git -C pm-head rev-parse --verify refs/heads/one
      +'
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	test_when_finished "rm -rf pm-default" &&
      +	git clone pm-upstream pm-default &&
      +	git -C pm-default checkout --detach &&
     -+	git -C pm-default branch --prune-merged origin &&
     ++	git -C pm-default branch --prune-merged "origin/*" &&
      +	git -C pm-default rev-parse --verify refs/heads/main
      +'
      +
     -+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '
     ++test_expect_success '--prune-merged protects the default branch by name only' '
      +	test_when_finished "rm -rf pm-default-alias" &&
      +	git clone pm-upstream pm-default-alias &&
      +	git -C pm-default-alias branch --track trunk origin/main &&
      +	git -C pm-default-alias checkout --detach &&
     -+	git -C pm-default-alias branch --prune-merged origin &&
     ++	git -C pm-default-alias branch --prune-merged "origin/*" &&
      +	git -C pm-default-alias rev-parse --verify refs/heads/main &&
      +	test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
      +'
     ++
     ++test_expect_success '--prune-merged with literal arg also protects default-name' '
     ++	test_when_finished "rm -rf pm-literal-default" &&
     ++	git clone pm-upstream pm-literal-default &&
     ++	git -C pm-literal-default checkout --detach &&
     ++	git -C pm-literal-default branch --prune-merged origin/main &&
     ++	git -C pm-literal-default rev-parse --verify refs/heads/main
     ++'
     ++
     ++test_expect_success '--prune-merged requires at least one <branch>' '
     ++	test_must_fail git -C pm-upstream branch --prune-merged 2>err &&
     ++	test_grep "at least one <branch>" err
     ++'
      +
       test_done
 4:  19b6d94fa7 ! 3:  6e38d7af3a branch: add branch.<name>.pruneMerged opt-out
     @@ Metadata
       ## Commit message ##
          branch: add branch.<name>.pruneMerged opt-out
      
     -    Setting branch.<name>.pruneMerged=false exempts that branch from
     -    --prune-merged, even with --force. Useful for keeping a topic
     -    branch around between rounds.
     +    Setting branch.<name>.pruneMerged=false exempts that branch
     +    from --prune-merged. Useful for topic branches you intend to
     +    develop further after an initial round has been merged
     +    upstream.
      
          Explicit deletion via 'git branch -d' is unaffected.
      
     @@ Documentation/config/branch.adoc: for details).
      +
      +`branch.<name>.pruneMerged`::
      +	If set to `false`, branch _<name>_ is exempt from
     -+	`git branch --prune-merged`.
     -+	Useful for topic branches you intend to develop further after
     -+	an initial round has been merged upstream. Defaults to true.
     -+	Explicit deletion via `git branch -d` is unaffected.
     ++	`git branch --prune-merged`. Defaults to true. Explicit
     ++	deletion via `git branch -d` is unaffected.
      
       ## Documentation/git-branch.adoc ##
     -@@ Documentation/git-branch.adoc: against whatever the remote-tracking refs say locally.
     - A branch whose upstream no longer resolves locally is left alone
     - (its disappearance is not, on its own, evidence that the work was
     - integrated). The currently checked-out branch in any worktree is
     --always preserved, as is the local branch that mirrors _<remote>_'s
     -+always preserved, as is any branch with `branch.<name>.pruneMerged`
     -+set to `false`, and the local branch that mirrors _<remote>_'s
     - default branch.
     +@@ Documentation/git-branch.adoc: The following branches are always preserved:
     +   any configured remote (the target of
     +   `refs/remotes/<remote>/HEAD`) -- typically `main` or
     +   `master`;
     ++* any branch with `branch.<name>.pruneMerged` set to `false`;
     + * any branch whose upstream no longer resolves locally.
     + --
       
     - `-v`::
      
       ## builtin/branch.c ##
      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
       	for_each_string_list_item(item, &candidates) {
       		const char *short_name = item->string;
     - 		struct strbuf full = STRBUF_INIT;
     -+		struct strbuf key = STRBUF_INIT;
     - 		struct branch *branch;
     - 		const char *upstream;
     -+		int opt_out = 0;
     + 		const char *upstream = item->util;
     ++		int prune_allowed = 1;
       
     - 		strbuf_addf(&full, "refs/heads/%s", short_name);
     - 		if (branch_checked_out(full.buf)) {
     - 			strbuf_release(&full);
     -+			strbuf_release(&key);
     - 			continue;
     - 		}
     - 		strbuf_release(&full);
     + 		strbuf_reset(&buf);
     + 		strbuf_addf(&buf, "refs/heads/%s", short_name);
      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
     - 		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
     - 		if (!upstream ||
     - 		    !refs_ref_exists(get_main_ref_store(the_repository),
     --				     upstream))
     -+				     upstream)) {
     -+			strbuf_release(&key);
     + 		if (!refs_ref_exists(refs, upstream))
       			continue;
     -+		}
     - 		if (string_list_has_string(&protected_default_refs, upstream)) {
     - 			const char *leaf = strrchr(upstream, '/');
     --			if (leaf && !strcmp(leaf + 1, short_name))
     -+			if (leaf && !strcmp(leaf + 1, short_name)) {
     -+				strbuf_release(&key);
     - 				continue;
     -+			}
     -+		}
     -+
     -+		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
     -+		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
     -+		    !opt_out) {
     + 
     ++		strbuf_reset(&buf);
     ++		strbuf_addf(&buf, "branch.%s.prunemerged", short_name);
     ++		if (!repo_config_get_bool(the_repository, buf.buf,
     ++					  &prune_allowed) &&
     ++		    !prune_allowed) {
      +			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);
       	}
     + 	strbuf_release(&buf);
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default branch by name, no
     - 	test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '
     + 	test_grep "at least one <branch>" err
       '
       
      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
     @@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default
      +	git -C pm-optout branch --set-upstream-to=origin/next two &&
      +	git -C pm-optout config branch.one.pruneMerged false &&
      +
     -+	git -C pm-optout branch --prune-merged origin 2>err &&
     ++	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 &&
 5:  6ae95d3f98 ! 4:  c68d162e22 branch: add --all-remotes flag
     @@ Metadata
      Author: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Commit message ##
     -    branch: add --all-remotes flag
     +    branch: add --dry-run for --prune-merged
      
     -    Combined with --forked or --prune-merged, --all-remotes acts on
     -    every configured remote, in addition to any explicit <remote>
     -    arguments. Used alone, it errors out.
     +    With --dry-run, --prune-merged prints the branches it would
     +    delete and exits without touching any ref. Useful for
     +    sanity-checking a glob like 'origin/*' before letting it run.
      
          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>
     +@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
       git branch (-d|-D) [-r] <branch-name>...
       git branch --edit-description [<branch-name>]
     --git branch --forked <remote>...
     --git branch --prune-merged <remote>...
     -+git branch --forked (<remote>... | --all-remotes)
     -+git branch --prune-merged (<remote>... | --all-remotes)
     + git branch --forked <branch>...
     +-git branch --prune-merged <branch>...
     ++git branch --prune-merged [--dry-run] <branch>...
       
       DESCRIPTION
       -----------
     -@@ Documentation/git-branch.adoc: always preserved, as is any branch with `branch.<name>.pruneMerged`
     - set to `false`, and the local branch that mirrors _<remote>_'s
     - default branch.
     +@@ Documentation/git-branch.adoc: The following branches are always preserved:
     + * any branch whose upstream no longer resolves locally.
     + --
       
     -+`--all-remotes`::
     -+	With `--forked` or `--prune-merged`, act on every
     -+	configured remote in addition to any explicit _<remote>_
     -+	arguments.
     ++`--dry-run`::
     ++	With `--prune-merged`, print the branches that would be
     ++	deleted instead of deleting them.
      +
       `-v`::
       `-vv`::
       `--verbose`::
      
       ## builtin/branch.c ##
     -@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
     - 	free_worktrees(worktrees);
     +@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
     + 	N_("git branch [<options>] [-r | -a] [--points-at]"),
     + 	N_("git branch [<options>] [-r | -a] [--format]"),
     + 	N_("git branch [<options>] --forked <branch>..."),
     +-	N_("git branch [<options>] --prune-merged <branch>..."),
     ++	N_("git branch [<options>] --prune-merged [--dry-run] <branch>..."),
     + 	NULL
     + };
     + 
     +@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
       }
       
     -+static int collect_remote_name(struct remote *remote, void *cb_data)
     -+{
     -+	struct string_list *remote_names = cb_data;
     -+	string_list_insert(remote_names, remote->name);
     -+	return 0;
     -+}
     -+
     - static void parse_forked_args(int argc, const char **argv,
     - 			      struct string_list *remote_names,
     - 			      struct string_list *tracking_refs)
     -@@ builtin/branch.c: static void collect_default_branch_refs(const struct string_list *remote_names,
     - 	}
     - }
     - 
     --static void collect_forked_set(int argc, const char **argv,
     -+static void collect_forked_set(int argc, const char **argv, int all_remotes,
     - 			       struct string_list *protected_default_refs,
     - 			       struct string_list *out)
     - {
     -@@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,
     - 	};
     - 
     - 	parse_forked_args(argc, argv, &remote_names, &tracking_refs);
     -+	if (all_remotes)
     -+		for_each_remote(collect_remote_name, &remote_names);
     - 
     - 	refs_for_each_branch_ref(get_main_ref_store(the_repository),
     - 				 collect_forked_branch, &cb);
     -@@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,
     - 	string_list_clear(&tracking_refs, 0);
     - }
     - 
     --static int list_forked_branches(int argc, const char **argv)
     -+static int list_forked_branches(int argc, const char **argv, int all_remotes)
     + static int delete_branches(int argc, const char **argv, int force, int kinds,
     +-			   int quiet, int warn_only, int *n_not_merged)
     ++			   int quiet, int warn_only, int dry_run,
     ++			   int *n_not_merged)
       {
     - 	struct string_list out = STRING_LIST_INIT_DUP;
     - 	struct string_list_item *item;
     - 
     --	if (!argc)
     --		die(_("--forked requires at least one <remote>"));
     -+	if (!argc && !all_remotes)
     -+		die(_("--forked requires at least one <remote> or --all-remotes"));
     - 
     --	collect_forked_set(argc, argv, NULL, &out);
     -+	collect_forked_set(argc, argv, all_remotes, NULL, &out);
     - 	for_each_string_list_item(item, &out)
     - 		puts(item->string);
     - 
     + 	struct commit *head_rev = NULL;
     + 	struct object_id oid;
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 			goto next;
     + 		}
     + 
     ++		if (dry_run) {
     ++			printf(_("Would delete branch '%s'\n"),
     ++			       name + branch_name_pos);
     ++			goto next;
     ++		}
     ++
     + 		item = string_list_append(&refs_to_delete, name);
     + 		item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
     + 				    : (flags & REF_ISSYMREF) ? target
      @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
       	return 0;
       }
       
      -static int prune_merged_branches(int argc, const char **argv, int quiet)
      +static int prune_merged_branches(int argc, const char **argv,
     -+				 int all_remotes, int quiet)
     ++				 int dry_run, int quiet)
       {
     + 	struct ref_store *refs = get_main_ref_store(the_repository);
       	struct string_list candidates = STRING_LIST_INIT_DUP;
     - 	struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
     - 	int n_not_merged = 0;
     - 	int ret = 0;
     - 
     --	if (!argc)
     --		die(_("--prune-merged requires at least one <remote>"));
     -+	if (!argc && !all_remotes)
     -+		die(_("--prune-merged requires at least one <remote> or --all-remotes"));
     - 
     --	collect_forked_set(argc, argv, &protected_default_refs, &candidates);
     -+	collect_forked_set(argc, argv, all_remotes, &protected_default_refs,
     -+			   &candidates);
     - 
     - 	for_each_string_list_item(item, &candidates) {
     - 		const char *short_name = item->string;
     + 	if (deletable.nr)
     + 		ret = delete_branches(deletable.nr, deletable.v,
     + 				      0, FILTER_REFS_BRANCHES, quiet,
     +-				      1, &n_not_merged);
     ++				      1, dry_run, &n_not_merged);
     + 
     + 	if (n_not_merged && !quiet)
     + 		fprintf(stderr,
      @@ builtin/branch.c: int cmd_branch(int argc,
       	    unset_upstream = 0, show_current = 0, edit_description = 0;
       	int forked = 0;
       	int prune_merged = 0;
     -+	int all_remotes = 0;
     ++	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 forked from the given <remote>s")),
     + 			N_("list local branches whose upstream matches the given <branch>...")),
       		OPT_BOOL(0, "prune-merged", &prune_merged,
     - 			N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
     -+		OPT_BOOL_F(0, "all-remotes", &all_remotes,
     -+			N_("with --forked or --prune-merged, act on every configured remote"),
     -+			PARSE_OPT_NONEG),
     + 			N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
     ++		OPT_BOOL(0, "dry-run", &dry_run,
     ++			N_("with --prune-merged, only print what would be deleted")),
       		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
       		OPT_MERGED(&filter, N_("print only branches that are merged")),
       		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
     @@ builtin/branch.c: int cmd_branch(int argc,
       	argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
       			     0);
       
     -+	if (all_remotes && !forked && !prune_merged)
     -+		die(_("--all-remotes requires --forked or --prune-merged"));
     -+
     ++	if (dry_run && !prune_merged)
     ++		die(_("--dry-run requires --prune-merged"));
      +
       	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
       	    !show_current && !unset_upstream && !forked && !prune_merged &&
       	    argc == 0)
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 				      quiet, 0, NULL);
     + 		if (!argc)
     + 			die(_("branch name required"));
     + 		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     +-				      quiet, 0, NULL);
     ++				      quiet, 0, 0, NULL);
       		goto out;
       	} else if (forked) {
     --		ret = list_forked_branches(argc, argv);
     -+		ret = list_forked_branches(argc, argv, all_remotes);
     + 		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, all_remotes, quiet);
     ++		ret = prune_merged_branches(argc, argv, dry_run, quiet);
       		goto out;
       	} else if (show_current) {
       		print_current_branch_name();
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>' '
     - 	test_grep "at least one <remote>" err
     - '
     - 
     -+test_expect_success '--forked --all-remotes covers every configured remote' '
     -+	git -C forked branch --forked --all-remotes >actual &&
     -+	cat >expect <<-\EOF &&
     -+	local-foreign
     -+	local-one
     -+	local-two
     -+	main
     -+	EOF
     -+	test_cmp expect actual
     -+'
     -+
     -+test_expect_success '--forked --all-remotes still validates explicit <remote>' '
     -+	test_must_fail git -C forked branch --forked nope --all-remotes 2>err &&
     -+	test_grep "neither a configured remote nor a remote-tracking branch" err
     -+'
     -+
     -+test_expect_success '--all-remotes alone is rejected' '
     -+	test_must_fail git -C forked branch --all-remotes 2>err &&
     -+	test_grep "requires --forked or --prune-merged" err
     -+'
     -+
     - test_expect_success '--prune-merged: setup' '
     - 	test_create_repo pm-upstream &&
     - 	test_commit -C pm-upstream base &&
      @@ t/t3200-branch.sh: 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 --all-remotes covers every configured remote' '
     -+	test_when_finished "rm -rf pm-allremotes pm-other" &&
     -+	git clone pm-upstream pm-allremotes &&
     -+	test_create_repo pm-other &&
     -+	test_commit -C pm-other other-base &&
     -+	git -C pm-other checkout -b stable &&
     -+	test_commit -C pm-other foreign-commit &&
     -+	git -C pm-other branch foreign HEAD &&
     -+	git -C pm-other checkout main &&
     ++test_expect_success '--prune-merged --dry-run prints but does not delete' '
     ++	test_when_finished "rm -rf pm-dryrun" &&
     ++	git clone pm-upstream pm-dryrun &&
     ++	git -C pm-dryrun branch one one-commit &&
     ++	git -C pm-dryrun branch --set-upstream-to=origin/next one &&
      +
     -+	git -C pm-allremotes remote add other ../pm-other &&
     -+	git -C pm-allremotes fetch other &&
     -+	git -C pm-allremotes branch one one-commit &&
     -+	git -C pm-allremotes branch --set-upstream-to=origin/next one &&
     -+	git -C pm-allremotes branch foreign other/foreign &&
     -+	git -C pm-allremotes branch --set-upstream-to=other/stable foreign &&
     ++	git -C pm-dryrun branch --prune-merged --dry-run "origin/*" >out &&
     ++	test_grep "Would delete branch .one." out &&
     ++	git -C pm-dryrun rev-parse --verify refs/heads/one
     ++'
     ++
     ++test_expect_success '--prune-merged --dry-run skips un-integrated branches' '
     ++	test_when_finished "rm -rf pm-dryrun-unmerged" &&
     ++	git clone pm-upstream pm-dryrun-unmerged &&
     ++	git -C pm-dryrun-unmerged checkout -b wip origin/next &&
     ++	git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&
     ++	test_commit -C pm-dryrun-unmerged local-only &&
     ++	git -C pm-dryrun-unmerged checkout - &&
     ++	git -C pm-dryrun-unmerged branch merged one-commit &&
     ++	git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&
      +
     -+	git -C pm-allremotes branch --prune-merged --all-remotes &&
     ++	git -C pm-dryrun-unmerged branch --prune-merged --dry-run "origin/*" \
     ++		>out 2>err &&
     ++	test_grep "Would delete branch .merged." out &&
     ++	test_grep ! "Would delete branch .wip." out &&
     ++	test_grep "not fully merged" err &&
     ++	git -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&
     ++	git -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged
     ++'
      +
     -+	test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&
     -+	test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign
     ++test_expect_success '--dry-run requires --prune-merged' '
     ++	test_must_fail git -C pm-upstream branch --dry-run 2>err &&
     ++	test_grep "requires --prune-merged" err
      +'
      +
       test_done

-- 
gitgitgadget

^ permalink raw reply

* [PATCH v10 1/4] branch: add --forked <branch>
From: Harald Nordgren via GitGitGadget @ 2026-05-21 22:40 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

	git branch --forked <branch>...

lists local branches whose configured upstream matches any
of the given <branch> arguments.

Each <branch> is resolved to the same kind of ref that
branch.<name>.remote and branch.<name>.merge together point at:
a remote-tracking branch (e.g. origin/master), or, for branches
tracking a local upstream, a local branch (e.g. master).
Shell-style globs are also accepted (e.g. 'origin/*'). Multiple
arguments are unioned.

This is the building block for --prune-merged.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |   7 ++
 builtin/branch.c              | 137 +++++++++++++++++++++++++++++++---
 t/t3200-branch.sh             |  81 ++++++++++++++++++++
 3 files changed, 214 insertions(+), 11 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..3a421f6663 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -24,6 +24,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 --forked <branch>...
 
 DESCRIPTION
 -----------
@@ -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`::
+	List local branches whose configured upstream matches any
+	of the given _<branch>_ arguments. Each argument is either
+	a ref (e.g. `origin/master`, `master`) or a shell-style
+	glob (e.g. `'origin/*'`). Multiple arguments are unioned.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..1e24c95a69 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -28,6 +28,7 @@
 #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]"),
@@ -38,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>] --forked <branch>..."),
 	NULL
 };
 
@@ -191,7 +193,8 @@ static int branch_merged(int kind, const char *name,
 
 static int check_branch_commit(const char *branchname, const char *refname,
 			       const struct object_id *oid, struct commit *head_rev,
-			       int kinds, int force)
+			       int kinds, int force, int warn_only,
+			       int *n_not_merged)
 {
 	struct commit *rev = lookup_commit_reference(the_repository, oid);
 	if (!force && !rev) {
@@ -199,10 +202,18 @@ static int check_branch_commit(const char *branchname, const char *refname,
 		return -1;
 	}
 	if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
-		error(_("the branch '%s' is not fully merged"), branchname);
-		advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
-				  _("If you are sure you want to delete it, "
-				  "run 'git branch -D %s'"), branchname);
+		if (warn_only) {
+			warning(_("the branch '%s' is not fully merged"),
+				branchname);
+		} else {
+			error(_("the branch '%s' is not fully merged"),
+			      branchname);
+			advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
+					  _("If you are sure you want to delete it, "
+					  "run 'git branch -D %s'"), branchname);
+		}
+		if (n_not_merged)
+			(*n_not_merged)++;
 		return -1;
 	}
 	return 0;
@@ -218,7 +229,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, int *n_not_merged)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -308,8 +319,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, n_not_merged)) {
+			if (!warn_only)
+				ret = 1;
 			goto next;
 		}
 
@@ -673,6 +685,102 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
 	free_worktrees(worktrees);
 }
 
+static void parse_forked_args(int argc, const char **argv,
+			      struct string_list *upstream_patterns)
+{
+	int i;
+
+	for (i = 0; i < argc; i++) {
+		const char *arg = argv[i];
+		struct object_id oid;
+		char *full_ref = NULL;
+		const char *short_ref;
+
+		if (has_glob_specials(arg)) {
+			string_list_insert(upstream_patterns, arg);
+			continue;
+		}
+
+		if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+				  &full_ref, 0) == 1 &&
+		    (skip_prefix(full_ref, "refs/heads/", &short_ref) ||
+		     skip_prefix(full_ref, "refs/remotes/", &short_ref))) {
+			string_list_insert(upstream_patterns, short_ref);
+			free(full_ref);
+			continue;
+		}
+		free(full_ref);
+
+		die(_("'%s' is not a valid branch or pattern"), arg);
+	}
+}
+
+struct forked_cb {
+	const struct string_list *upstream_patterns;
+	struct string_list *out;
+};
+
+static int collect_forked_branch(const struct reference *ref, void *cb_data)
+{
+	struct forked_cb *cb = cb_data;
+	struct branch *branch;
+	const char *upstream, *short_upstream;
+	const struct string_list_item *item;
+
+	if (ref->flags & REF_ISSYMREF)
+		return 0;
+	branch = branch_get(ref->name);
+	if (!branch)
+		return 0;
+	upstream = branch_get_upstream(branch, NULL);
+	if (!upstream)
+		return 0;
+	short_upstream = upstream;
+	(void)(skip_prefix(short_upstream, "refs/heads/", &short_upstream) ||
+	       skip_prefix(short_upstream, "refs/remotes/", &short_upstream));
+
+	for_each_string_list_item(item, cb->upstream_patterns)
+		if (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {
+			string_list_append(cb->out, ref->name)->util =
+				xstrdup(upstream);
+			return 0;
+		}
+	return 0;
+}
+
+static void collect_forked_set(int argc, const char **argv,
+			       struct string_list *out)
+{
+	struct string_list upstream_patterns = STRING_LIST_INIT_DUP;
+	struct forked_cb cb = {
+		.upstream_patterns = &upstream_patterns,
+		.out = out,
+	};
+
+	parse_forked_args(argc, argv, &upstream_patterns);
+
+	refs_for_each_branch_ref(get_main_ref_store(the_repository),
+				 collect_forked_branch, &cb);
+
+	string_list_clear(&upstream_patterns, 0);
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+	struct string_list out = STRING_LIST_INIT_DUP;
+	struct string_list_item *item;
+
+	if (!argc)
+		die(_("--forked requires at least one <branch>"));
+
+	collect_forked_set(argc, argv, &out);
+	for_each_string_list_item(item, &out)
+		puts(item->string);
+
+	string_list_clear(&out, 1);
+	return 0;
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -714,6 +822,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;
+	int forked = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -767,6 +876,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_BOOL(0, "forked", &forked,
+			N_("list local branches whose upstream matches the given <branch>...")),
 		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")),
@@ -811,7 +922,7 @@ 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 ||
@@ -820,7 +931,7 @@ int cmd_branch(int argc,
 
 	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);
 
@@ -858,7 +969,11 @@ int cmd_branch(int argc,
 	if (delete) {
 		if (!argc)
 			die(_("branch name required"));
-		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
+		ret = delete_branches(argc, argv, delete > 1, filter.kind,
+				      quiet, 0, NULL);
+		goto out;
+	} else if (forked) {
+		ret = list_forked_branches(argc, argv);
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..45455cb8ce 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,85 @@ 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 --track local-one origin/one &&
+	git -C forked branch --track local-two origin/two &&
+	git -C forked branch --track local-foreign other/foreign &&
+	git -C forked branch detached &&
+	git -C forked branch --track topic-on-main main
+'
+
+test_expect_success '--forked <remote-tracking-branch> lists matching branches' '
+	git -C forked branch --forked origin/one >actual &&
+	echo local-one >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> lists branches tracking that local branch' '
+	git -C forked branch --forked main >actual &&
+	echo topic-on-main >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> matches every upstream under the pattern' '
+	git -C forked branch --forked "origin/*" >actual &&
+	cat >expect <<-\EOF &&
+	local-one
+	local-two
+	main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked unions multiple <branch> arguments' '
+	git -C forked branch --forked origin/one other/foreign >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+	git -C forked branch --forked main "other/*" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	topic-on-main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+	git -C forked branch --forked "*/*" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	local-two
+	main
+	EOF
+	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_must_fail git -C forked branch --forked 2>err &&
+	test_grep "at least one <branch>" err
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v10 2/4] branch: add --prune-merged <branch>
From: Harald Nordgren via GitGitGadget @ 2026-05-21 22:40 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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

deletes the local branches that --forked <branch> would list,
but only those whose tip is reachable from their configured
upstream: the work has already landed on the upstream the
branch tracks, so the local copy is no longer needed.

The following branches are always preserved:

* the currently checked-out branch in any worktree;
* any local branch whose name matches the default branch of
  any configured remote (the target of
  refs/remotes/<remote>/HEAD) -- typically 'main' or
  'master';
* any branch whose upstream no longer resolves locally.

Reachability is read from whatever branch.<name>.merge
resolves to locally, which is usually a remote-tracking ref
but may also be a local branch. When the upstream is a
remote-tracking ref, the natural workflow is

	git fetch <remote>
	git branch --prune-merged <upstream-pattern>

so the upstream reflects the current state before pruning.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  21 +++++
 builtin/branch.c              |  93 ++++++++++++++++++++++-
 t/t3200-branch.sh             | 139 ++++++++++++++++++++++++++++++++++
 3 files changed, 249 insertions(+), 4 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 3a421f6663..a7c0e29e94 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ 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>...
 
 DESCRIPTION
 -----------
@@ -206,6 +207,26 @@ This option is only applicable in non-verbose mode.
 	a ref (e.g. `origin/master`, `master`) or a shell-style
 	glob (e.g. `'origin/*'`). Multiple arguments are unioned.
 
+`--prune-merged`::
+	Delete the local branches that `--forked` would list for
+	the same _<branch>_ arguments, but only those whose tip is
+	reachable from their configured upstream.
++
+For arguments that refer to remote-tracking branches, run
+`git fetch` first so reachability is checked against the
+current upstream state; refs are read locally.
++
+The following branches are always preserved:
++
+--
+* the currently checked-out branch in any worktree;
+* any local branch whose name matches the default branch of
+  any configured remote (the target of
+  `refs/remotes/<remote>/HEAD`) -- typically `main` or
+  `master`;
+* any branch whose upstream no longer resolves locally.
+--
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1e24c95a69..29d38e9060 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -21,6 +21,7 @@
 #include "branch.h"
 #include "path.h"
 #include "string-list.h"
+#include "strvec.h"
 #include "column.h"
 #include "utf8.h"
 #include "ref-filter.h"
@@ -40,6 +41,7 @@ static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] [-r | -a] [--points-at]"),
 	N_("git branch [<options>] [-r | -a] [--format]"),
 	N_("git branch [<options>] --forked <branch>..."),
+	N_("git branch [<options>] --prune-merged <branch>..."),
 	NULL
 };
 
@@ -172,8 +174,8 @@ static int branch_merged(int kind, const char *name,
 	 * any of the following code, but during the transition period,
 	 * a gentle reminder is in order.
 	 */
-	if (head_rev != reference_rev) {
-		int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+	if (head_rev && head_rev != reference_rev) {
+		int expect = repo_in_merge_bases(the_repository, rev, head_rev);
 		if (expect < 0)
 			exit(128);
 		if (expect == merged)
@@ -748,6 +750,25 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
 	return 0;
 }
 
+static int collect_default_branch_name(struct remote *remote, void *cb_data)
+{
+	struct string_list *protected = cb_data;
+	struct ref_store *refs = get_main_ref_store(the_repository);
+	struct strbuf head = STRBUF_INIT;
+	const char *target;
+
+	strbuf_addf(&head, "refs/remotes/%s/HEAD", remote->name);
+	target = refs_resolve_ref_unsafe(refs, head.buf,
+					 RESOLVE_REF_NO_RECURSE, NULL, NULL);
+	if (target) {
+		const char *leaf = strrchr(target, '/');
+		if (leaf)
+			string_list_insert(protected, leaf + 1);
+	}
+	strbuf_release(&head);
+	return 0;
+}
+
 static void collect_forked_set(int argc, const char **argv,
 			       struct string_list *out)
 {
@@ -781,6 +802,63 @@ static int list_forked_branches(int argc, const char **argv)
 	return 0;
 }
 
+static int prune_merged_branches(int argc, const char **argv, int quiet)
+{
+	struct ref_store *refs = get_main_ref_store(the_repository);
+	struct string_list candidates = STRING_LIST_INIT_DUP;
+	struct string_list protected_default_names = STRING_LIST_INIT_DUP;
+	struct strvec deletable = STRVEC_INIT;
+	struct strbuf buf = STRBUF_INIT;
+	struct string_list_item *item;
+	int n_not_merged = 0;
+	int ret = 0;
+
+	if (!argc)
+		die(_("--prune-merged requires at least one <branch>"));
+
+	collect_forked_set(argc, argv, &candidates);
+	for_each_remote(collect_default_branch_name, &protected_default_names);
+
+	for_each_string_list_item(item, &candidates) {
+		const char *short_name = item->string;
+		const char *upstream = item->util;
+
+		strbuf_reset(&buf);
+		strbuf_addf(&buf, "refs/heads/%s", short_name);
+		if (branch_checked_out(buf.buf))
+			continue;
+
+		if (string_list_has_string(&protected_default_names,
+					   short_name))
+			continue;
+
+		if (!refs_ref_exists(refs, upstream))
+			continue;
+
+		strvec_push(&deletable, short_name);
+	}
+	strbuf_release(&buf);
+
+	if (deletable.nr)
+		ret = delete_branches(deletable.nr, deletable.v,
+				      0, FILTER_REFS_BRANCHES, quiet,
+				      1, &n_not_merged);
+
+	if (n_not_merged && !quiet)
+		fprintf(stderr,
+			Q_("Skipped %d branch that is not fully merged; "
+			   "delete it with 'git branch -D' if you are sure.\n",
+			   "Skipped %d branches that are not fully merged; "
+			   "delete them with 'git branch -D' if you are sure.\n",
+			   n_not_merged),
+			n_not_merged);
+
+	strvec_clear(&deletable);
+	string_list_clear(&candidates, 1);
+	string_list_clear(&protected_default_names, 0);
+	return ret;
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -823,6 +901,7 @@ 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;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -878,6 +957,8 @@ 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 that are merged into it")),
 		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")),
@@ -922,7 +1003,8 @@ 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 &&
+	    argc == 0)
 		list = 1;
 
 	if (filter.with_commit || filter.no_commit ||
@@ -931,7 +1013,7 @@ 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;
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
@@ -975,6 +1057,9 @@ int cmd_branch(int argc,
 	} else if (forked) {
 		ret = list_forked_branches(argc, argv);
 		goto out;
+	} else if (prune_merged) {
+		ret = prune_merged_branches(argc, argv, quiet);
+		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
 		ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 45455cb8ce..c8589cd3a6 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1798,4 +1798,143 @@ test_expect_success '--forked requires at least one <branch>' '
 	test_grep "at least one <branch>" 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_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 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 with a literal upstream argument' '
+	test_when_finished "rm -rf pm-literal" &&
+	git clone pm-upstream pm-literal &&
+	git -C pm-literal branch one one-commit &&
+	git -C pm-literal branch --set-upstream-to=origin/next one &&
+	git -C pm-literal branch keepme one-commit &&
+	git -C pm-literal branch --set-upstream-to=origin/main keepme &&
+
+	git -C pm-literal branch --prune-merged origin/next &&
+
+	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&
+	git -C pm-literal rev-parse --verify refs/heads/keepme
+'
+
+test_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 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 branch --prune-merged origin/next 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 with a local-branch argument' '
+	test_create_repo pm-local &&
+	test_when_finished "rm -rf pm-local" &&
+	test_commit -C pm-local base &&
+	git -C pm-local branch topic base &&
+	git -C pm-local config branch.topic.remote . &&
+	git -C pm-local config branch.topic.merge refs/heads/main &&
+	git -C pm-local checkout --detach &&
+
+	git -C pm-local branch --prune-merged main &&
+
+	test_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&
+	git -C pm-local rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares branches with un-integrated commits' '
+	test_when_finished "rm -rf pm-unmerged" &&
+	git clone pm-upstream pm-unmerged &&
+	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 "Skipped 1 branch" err &&
+	test_grep "git branch -D" err &&
+	test_grep ! "If you are sure you want to delete it" err &&
+	git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged 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 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 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 the local default branch' '
+	test_when_finished "rm -rf pm-default" &&
+	git clone pm-upstream pm-default &&
+	git -C pm-default checkout --detach &&
+	git -C pm-default branch --prune-merged "origin/*" &&
+	git -C pm-default rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged protects the default branch by name only' '
+	test_when_finished "rm -rf pm-default-alias" &&
+	git clone pm-upstream pm-default-alias &&
+	git -C pm-default-alias branch --track trunk origin/main &&
+	git -C pm-default-alias checkout --detach &&
+	git -C pm-default-alias branch --prune-merged "origin/*" &&
+	git -C pm-default-alias rev-parse --verify refs/heads/main &&
+	test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
+'
+
+test_expect_success '--prune-merged with literal arg also protects default-name' '
+	test_when_finished "rm -rf pm-literal-default" &&
+	git clone pm-upstream pm-literal-default &&
+	git -C pm-literal-default checkout --detach &&
+	git -C pm-literal-default branch --prune-merged origin/main &&
+	git -C pm-literal-default rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged requires at least one <branch>' '
+	test_must_fail git -C pm-upstream branch --prune-merged 2>err &&
+	test_grep "at least one <branch>" err
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v10 3/4] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-05-21 22:40 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Setting branch.<name>.pruneMerged=false exempts that branch
from --prune-merged. Useful for topic branches you intend to
develop further after an initial round has been merged
upstream.

Explicit deletion via 'git branch -d' is unaffected.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/config/branch.adoc |  5 +++++
 Documentation/git-branch.adoc    |  1 +
 builtin/branch.c                 | 13 +++++++++++++
 t/t3200-branch.sh                | 27 +++++++++++++++++++++++++++
 4 files changed, 46 insertions(+)

diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..6402b78a73 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -102,3 +102,8 @@ 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`. Defaults to true. Explicit
+	deletion via `git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index a7c0e29e94..247e4daeb8 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -224,6 +224,7 @@ The following branches are always preserved:
   any configured remote (the target of
   `refs/remotes/<remote>/HEAD`) -- typically `main` or
   `master`;
+* any branch with `branch.<name>.pruneMerged` set to `false`;
 * any branch whose upstream no longer resolves locally.
 --
 
diff --git a/builtin/branch.c b/builtin/branch.c
index 29d38e9060..f995f257f0 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -822,6 +822,7 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
 	for_each_string_list_item(item, &candidates) {
 		const char *short_name = item->string;
 		const char *upstream = item->util;
+		int prune_allowed = 1;
 
 		strbuf_reset(&buf);
 		strbuf_addf(&buf, "refs/heads/%s", short_name);
@@ -835,6 +836,18 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
 		if (!refs_ref_exists(refs, upstream))
 			continue;
 
+		strbuf_reset(&buf);
+		strbuf_addf(&buf, "branch.%s.prunemerged", short_name);
+		if (!repo_config_get_bool(the_repository, buf.buf,
+					  &prune_allowed) &&
+		    !prune_allowed) {
+			if (!quiet)
+				fprintf(stderr, _("Skipping '%s' "
+						  "(branch.%s.pruneMerged is false)\n"),
+					short_name, short_name);
+			continue;
+		}
+
 		strvec_push(&deletable, short_name);
 	}
 	strbuf_release(&buf);
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index c8589cd3a6..b35189ce84 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1937,4 +1937,31 @@ test_expect_success '--prune-merged requires at least one <branch>' '
 	test_grep "at least one <branch>" 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 branch one one-commit &&
+	git -C pm-optout branch --set-upstream-to=origin/next one &&
+	git -C pm-optout branch two two-commit &&
+	git -C pm-optout branch --set-upstream-to=origin/next two &&
+	git -C pm-optout config branch.one.pruneMerged false &&
+
+	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 &&
+	git -C pm-optout-d config 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 v10 4/4] branch: add --dry-run for --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-05-21 22:40 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

With --dry-run, --prune-merged prints the branches it would
delete and exits without touching any ref. Useful for
sanity-checking a glob like 'origin/*' before letting it run.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  6 +++++-
 builtin/branch.c              | 26 ++++++++++++++++++++------
 t/t3200-branch.sh             | 35 +++++++++++++++++++++++++++++++++++
 3 files changed, 60 insertions(+), 7 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 247e4daeb8..349fbfc420 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ 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>...
 
 DESCRIPTION
 -----------
@@ -228,6 +228,10 @@ The following branches are always preserved:
 * any branch whose upstream no longer resolves locally.
 --
 
+`--dry-run`::
+	With `--prune-merged`, print the branches that would be
+	deleted instead of deleting them.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index f995f257f0..b89fd56112 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -41,7 +41,7 @@ static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] [-r | -a] [--points-at]"),
 	N_("git branch [<options>] [-r | -a] [--format]"),
 	N_("git branch [<options>] --forked <branch>..."),
-	N_("git branch [<options>] --prune-merged <branch>..."),
+	N_("git branch [<options>] --prune-merged [--dry-run] <branch>..."),
 	NULL
 };
 
@@ -231,7 +231,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 *n_not_merged)
+			   int quiet, int warn_only, int dry_run,
+			   int *n_not_merged)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -327,6 +328,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 			goto next;
 		}
 
+		if (dry_run) {
+			printf(_("Would delete branch '%s'\n"),
+			       name + branch_name_pos);
+			goto next;
+		}
+
 		item = string_list_append(&refs_to_delete, name);
 		item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
 				    : (flags & REF_ISSYMREF) ? target
@@ -802,7 +809,8 @@ static int list_forked_branches(int argc, const char **argv)
 	return 0;
 }
 
-static int prune_merged_branches(int argc, const char **argv, int quiet)
+static int prune_merged_branches(int argc, const char **argv,
+				 int dry_run, int quiet)
 {
 	struct ref_store *refs = get_main_ref_store(the_repository);
 	struct string_list candidates = STRING_LIST_INIT_DUP;
@@ -855,7 +863,7 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
 	if (deletable.nr)
 		ret = delete_branches(deletable.nr, deletable.v,
 				      0, FILTER_REFS_BRANCHES, quiet,
-				      1, &n_not_merged);
+				      1, dry_run, &n_not_merged);
 
 	if (n_not_merged && !quiet)
 		fprintf(stderr,
@@ -915,6 +923,7 @@ int cmd_branch(int argc,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	int forked = 0;
 	int prune_merged = 0;
+	int dry_run = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -972,6 +981,8 @@ int cmd_branch(int argc,
 			N_("list local branches whose upstream matches the given <branch>...")),
 		OPT_BOOL(0, "prune-merged", &prune_merged,
 			N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
+		OPT_BOOL(0, "dry-run", &dry_run,
+			N_("with --prune-merged, only print what 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")),
@@ -1015,6 +1026,9 @@ int cmd_branch(int argc,
 	argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
 			     0);
 
+	if (dry_run && !prune_merged)
+		die(_("--dry-run requires --prune-merged"));
+
 	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
 	    !show_current && !unset_upstream && !forked && !prune_merged &&
 	    argc == 0)
@@ -1065,13 +1079,13 @@ int cmd_branch(int argc,
 		if (!argc)
 			die(_("branch name required"));
 		ret = delete_branches(argc, argv, delete > 1, filter.kind,
-				      quiet, 0, NULL);
+				      quiet, 0, 0, NULL);
 		goto out;
 	} else if (forked) {
 		ret = list_forked_branches(argc, argv);
 		goto out;
 	} else if (prune_merged) {
-		ret = prune_merged_branches(argc, argv, quiet);
+		ret = prune_merged_branches(argc, argv, dry_run, quiet);
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index b35189ce84..908b184e81 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1964,4 +1964,39 @@ 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 prints but does not delete' '
+	test_when_finished "rm -rf pm-dryrun" &&
+	git clone pm-upstream pm-dryrun &&
+	git -C pm-dryrun branch one one-commit &&
+	git -C pm-dryrun branch --set-upstream-to=origin/next one &&
+
+	git -C pm-dryrun branch --prune-merged --dry-run "origin/*" >out &&
+	test_grep "Would delete branch .one." out &&
+	git -C pm-dryrun rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged --dry-run skips un-integrated branches' '
+	test_when_finished "rm -rf pm-dryrun-unmerged" &&
+	git clone pm-upstream pm-dryrun-unmerged &&
+	git -C pm-dryrun-unmerged checkout -b wip origin/next &&
+	git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&
+	test_commit -C pm-dryrun-unmerged local-only &&
+	git -C pm-dryrun-unmerged checkout - &&
+	git -C pm-dryrun-unmerged branch merged one-commit &&
+	git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&
+
+	git -C pm-dryrun-unmerged branch --prune-merged --dry-run "origin/*" \
+		>out 2>err &&
+	test_grep "Would delete branch .merged." out &&
+	test_grep ! "Would delete branch .wip." out &&
+	test_grep "not fully merged" err &&
+	git -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&
+	git -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged
+'
+
+test_expect_success '--dry-run requires --prune-merged' '
+	test_must_fail git -C pm-upstream branch --dry-run 2>err &&
+	test_grep "requires --prune-merged" err
+'
+
 test_done
-- 
gitgitgadget

^ permalink raw reply related

* Re: [PATCH v3 0/2] commit-reach: use object flags for tips_reachable_from_bases()
From: Derrick Stolee @ 2026-05-21 22:50 UTC (permalink / raw)
  To: Jeff King, Kristofer Karlsson via GitGitGadget; +Cc: git, Kristofer Karlsson
In-Reply-To: <20260519010354.GE1612961@coredump.intra.peff.net>

On 5/18/26 9:03 PM, Jeff King wrote:
> On Sat, May 16, 2026 at 03:59:39PM +0000, Kristofer Karlsson via GitGitGadget wrote:
> 
>> v2 of this patch, addressing Jeff King's feedback:
>>
>>   * Replaced the decoration hash with the RESULT object flag (simpler, no
>>     extra data structure, handles duplicate tips naturally)
>>   * Fixed early-termination bug when multiple refs point to the same commit
>>     (the decoration API overwrites on duplicate keys)
>>   * Removed the now-unused index field from struct commit_and_index
>>   * Diff is +11/-12 lines
> 
> Using the object flag here is so much nicer. I see you're reusing the
> RESULT flag. I'm not sure offhand if there might be any conflict with
> other uses of that flag bit. I think probably not, since it looks like
> it is cleared by the other users after they leave their respective
> functions?
> 
> Using a direct set-inclusion check with the flag is nice, but we still
> look at min_generation_index. If I'm understanding the code right, this
> is mostly about counting the tips we've seen. Which at first glance
> means we could probably replace that code with some kind of counter. But
> I think maybe there is some notion of "crossing off" commits which we
> don't actually visit, but which we know become un-visitable because we
> traverse past their generation numbers.
> 
> I think. This is really the first time I'm looking at this code. So
> AFAICT your patch as-is is correct, but it would be nice to go an ACK
> from Stolee.

Sorry for the delay, but I finally took a close look at this version
and I'm happy with the use of the RESULT bit. You clean it up and
shouldn't interfer with the 19th bit being used in upload-pack.c as
the HIDDEN_REF bit.

Thanks,
-Stolee


^ permalink raw reply

* Re: [PATCH v4 03/13] t/perf: add pack-objects filter and path-walk benchmark
From: Derrick Stolee @ 2026-05-21 22:56 UTC (permalink / raw)
  To: Taylor Blau, Derrick Stolee via GitGitGadget
  Cc: git, christian.couder, gitster, johannes.schindelin, johncai86,
	karthik.188, kristofferhaugsbakk, newren, peff, ps
In-Reply-To: <agz3fOHvVKGLMxgb@nand.local>

On 5/19/26 7:51 PM, Taylor Blau wrote:
> On Wed, May 13, 2026 at 09:18:45PM +0000, Derrick Stolee via GitGitGadget wrote:
>> +	>depth2-dirs &&
>> +	while read tdir
>> +	do
>> +		git ls-tree -d --name-only "HEAD:$tdir" 2>/dev/null || return 1
>> +	done <top-dirs >depth2-dirs.raw &&
>> +	sed "s|^|$tdir/|" <depth2-dirs.raw >depth2-dirs &&
> 
> Ugh, I think that this was a bad suggestion on my part, since $tdir
> should be empty at this point.
> 
> Could we use --format here like so?

The --format option is clean. The full loop will look like this:

	while read tdir
	do
		git ls-tree -d --format="$tdir/%(path)" "HEAD:$tdir" || return 1
	done <top-dirs >depth2-dirs &&

> I guess that breaks if $tdir contains a formatting atom, so perhaps we
> should keep the spirit of the original (but using an intermediary file
> instead of piping the output of Git to another command).

Thanks,
-Stolee



^ permalink raw reply

* Re: [PATCH v4 04/13] path-walk: always emit directly-requested objects
From: Derrick Stolee @ 2026-05-21 23:00 UTC (permalink / raw)
  To: Taylor Blau, Derrick Stolee via GitGitGadget
  Cc: git, christian.couder, gitster, johannes.schindelin, johncai86,
	karthik.188, kristofferhaugsbakk, newren, peff, ps
In-Reply-To: <agzwsxV2KEkkaGPV@nand.local>

On 5/19/26 7:22 PM, Taylor Blau wrote:
> On Wed, May 13, 2026 at 09:18:46PM +0000, Derrick Stolee via GitGitGadget wrote:
>> diff --git a/path-walk.c b/path-walk.c
>> index 6e426af433..05bfc1c114 100644
>> --- a/path-walk.c
>> +++ b/path-walk.c
>> @@ -248,6 +248,17 @@ static int add_tree_entries(struct path_walk_context *ctx,
>>   	return 0;
>>   }
>>
>> +/*
>> + * Paths starting with '/' (e.g., "/tags", "/tagged-blobs") hold objects that
>> + * were directly requested by 'pending' objects rather than discovered during
>> + * tree traversal.
>> + */
>> +static int path_is_for_direct_objects(const char *path)
>> +{
>> +	ASSERT(path);
>> +	return path[0] == '/';
>> +}
>> +
> 
> Hmm, I still find this a little brittle. I think that 'path' here is
> doing a number of jobs: it serves as a strmap key, it's visible to the
> caller, and now also a "direct object" marker.
> 
> Could we instead store this explicitly on the type_and_oid_list, e.g. a
> "direct" flag? I'm not sure whether that type has the right scope for
> this information. If not, I wonder if there is another way to store this
> information, since I worry that future callers may not know about this
> convention and end up changing the result of the path-walk depending on
> how they name their paths.

I don't find this as fragile as you do, because these "direct" paths
_need_ to start with '/' to avoid collisions with other paths that may
exist _and_ this meaning is internal to the data within the API. Callers
can't change this data, though they will see the paths themselves in the
callback function.

And as I mentioned before, this is a memory-efficient storage of this
indicator bit because it only consumes memory when it is "on" and the
vast majority of cases where it is "off" it doesn't take any extra
storage.

Thanks,
-Stolee


^ permalink raw reply

* Re: [PATCH v4 00/13] pack-objects: integrate --path-walk and some --filter options
From: Derrick Stolee @ 2026-05-21 23:01 UTC (permalink / raw)
  To: Taylor Blau, Derrick Stolee via GitGitGadget
  Cc: git, christian.couder, gitster, johannes.schindelin, johncai86,
	karthik.188, kristofferhaugsbakk, newren, peff, ps
In-Reply-To: <agz3/ZxZZHBKofR9@nand.local>

On 5/19/26 7:53 PM, Taylor Blau wrote:
> On Wed, May 13, 2026 at 09:18:42PM +0000, Derrick Stolee via GitGitGadget wrote:
>> UPDATES IN V4
>> =============
>>
>> Thanks, Taylor for the careful review.
>>
>>   * Several typos are fixed.
>>   * The performance test is corrected for issues around piping Git commands
>>     and made more robust to the existence of submodules.
>>   * BIG: The tree:0 patch is significantly updated in this version. Taylor
>>     correctly smelled a problem with the new logic to emit the /tagged-trees
>>     object set, and that signaled that those trees were previously never
>>     emitted. I update the test to demonstrate that changing the data shape
>>     (including tagged trees that are otherwise-unreachable) doesn't change
>>     the test behavior, signaling a bug. The behavior change details all the
>>     complexities of visiting only directly-requested trees under a tree:0
>>     filter and recursing on all trees in other cases.
> 
> Thanks for the new round; I gave this a lighter pass since I had
> reviewed v3 in detail and the range-diff here looks good. I focused in
> on a few patches in particular, and left a couple of minor comments.
> 
> My main reservation is that the "path starts with a '/' slash character
> when directly requested" behavior feels brittle to me, and I am not sure
> if there is a cleaner way to express that.
> 
> I'm curious what your thoughts are there. I think barring that things
> are near-complete here, though I did note one issue with the t/perf
> changes (that is my fault for having a bad suggestion on the earlier
> round).

I like the suggested change to t/perf but I don't share your concerns
around the '/' character in the path (I go deeper into why in the
thread).

Thanks,
-Stolee


^ 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