Git development
 help / color / mirror / Atom feed
* Re: Why do we need to wait 1s between a git add and commit
From: Fabrice SALVAIRE @ 2026-05-22 17:13 UTC (permalink / raw)
  To: Kristoffer Haugsbakk, git
In-Reply-To: <b403477d-5587-4afc-bd02-dbd207c22e67@app.fastmail.com>

Hi,

Yes it throws 3000+ sequential subprocess calls at the speed of Python...

And the sleep time seems to be critical, 1s is ok but 100ms is not so ok.

I also have the feeling this is due to a git upgrade. But I didn't test 
to downgrade.

That is a major issue if we have to be slow while using Git...

It is not unusual to write a shell script with a sequence of add/commit.

Le 22/05/2026 à 17:12, Kristoffer Haugsbakk a écrit :
> Hi
>
> On Fri, May 22, 2026, at 14:28, Fabrice SALVAIRE wrote:
>> I wrote a Python tool to dump a wiki to a git repository, that does
>> basically a succession of subprocess calls to git add and commit.
>>
>> Recently, I discovered this tool doesn't work any longer and that git
>> commit (2.54 on Fedora 42 / 43) crashes randomly.
>>
>> I cannot explain this behavior since my code is trivial.
>>
>> I had the intuition to add a sleep time of 1s just after a git call, and
>> it solves the issue.
>>
>> I noticed for some cases that another call to git commit were
>> successful. For most cases, git fsck and gitk report issues.
>>
>> It looks like the state of the git repository was not yet completed
>> before the end of the git subprocess.
> This might be caused by git-maintenance(1) being run in the background
> without locking? That’s a new issue in Git 2.54.0. See:
>
> https://lore.kernel.org/git/20260509175249.GA2336928@coredump.intra.peff.net/
>
> The following script reproduces the issue on Git 2.53.0. I am guessing
> that your script does something similar? It depends on how many commits
> it creates in a short timeframe.
>
> https://lore.kernel.org/git/20260508180341.GB737125@coredump.intra.peff.net/

^ permalink raw reply

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



On 5/22/26 11:09 AM, Shroom Moo wrote:
> On 5/22/26 1:45 AM, Mark Levedahl wrote:
>>> 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
> The relative path issue with {gitdir} is indeed difficult to trigger 
> in practice. To trigger this problem, the following conditions must 
> be met simultaneously: 
> - git rev-parse --absolute-git-dir succeeds (the repository is valid). 
> - git rev-parse --show-toplevel fails (the working tree is 
> undetectable).
> - The current directory is not $_gitdir.
> - A gitdir file exists under $_gitdir (i.e., the gitdir of a linked 
> worktree). 
>
> Unluckily, this occurs in:
>
> ```MINGW64 Shell
> # Firstly, enter a test folder
> mkdir test-main
> cd test-main
>
> git init
>
> echo main > file.txt
> git add file.txt
> git commit -m "initial"
>
> # In feature branch
> git branch feature
>
> # Add worktree
> git worktree add ../test-feature feature
>
> cd ../
> cd test-feature
> cat .git
>
> cd ../
>
> mkdir outside
> cd outside
>
> export GIT_DIR="/(PREVIOUSPATH)/test-main/.git/worktrees/feature"
> unset GIT_WORK_TREE
> git gui blame HEAD file.txt --trace
> ```
>
> ```Wish
> puts $_gitworktree
> ```
>
> The current directory is "outside", not the expected linked worktree. 
> [file exists {gitdir}] in find_worktree_from_gitdir searched in the 
> current directory but failed, resulting in the inability to identify 
> linked worktree. The blame mode can still work without worktree. 
>
> Shroom
>
You exported GIT_DIR pointing to a valid gitdir, and as documented git takes your current
directory ("outside") to be the worktree. This is working as expected. Had finding a
worktree failed, git-gui would issue an error message and stop because you defined GIT_DIR.

Mark

^ permalink raw reply

* Re: [PATCH v4 00/13] pack-objects: integrate --path-walk and some --filter options
From: Taylor Blau @ 2026-05-22 16:37 UTC (permalink / raw)
  To: Derrick Stolee
  Cc: Derrick Stolee via GitGitGadget, git, christian.couder, gitster,
	johannes.schindelin, johncai86, karthik.188, kristofferhaugsbakk,
	newren, peff, ps
In-Reply-To: <4d7a75e9-8260-4e33-a786-72e0aa3026ae@gmail.com>

On Thu, May 21, 2026 at 07:01:33PM -0400, Derrick Stolee wrote:
> > 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).

Sounds good. I think a minor re-roll for that would be good, but I
better understand your viewpoint around the '/' leading character now,
so I think other than that we're good to go from my perspective.

Thanks,
Taylor

^ permalink raw reply

* Re: [PATCH v4 04/13] path-walk: always emit directly-requested objects
From: Taylor Blau @ 2026-05-22 16:36 UTC (permalink / raw)
  To: Derrick Stolee
  Cc: Derrick Stolee via GitGitGadget, git, christian.couder, gitster,
	johannes.schindelin, johncai86, karthik.188, kristofferhaugsbakk,
	newren, peff, ps
In-Reply-To: <53d46614-7d9d-49ab-ace7-71367f6d2f40@gmail.com>

On Thu, May 21, 2026 at 07:00:39PM -0400, Derrick Stolee wrote:
> > 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.

Ah, I didn't realize that we added '/' at the beginning to avoid
collisions. This approach makes sense to me!


Thanks,
Taylor

^ permalink raw reply

* Re: [PATCH] receive-pack: fix updateInstead with core.worktree
From: Kristoffer Haugsbakk @ 2026-05-22 16:21 UTC (permalink / raw)
  To: Alyssa Ross, git; +Cc: Ævar Arnfjörð Bjarmason, Junio C Hamano
In-Reply-To: <20260522154418.5883-1-hi@alyssa.is>

On Fri, May 22, 2026, at 17:44, Alyssa Ross wrote:
> This used to work, but when push_to_checkout() started being called
> before push_to_deploy(), push_to_checkout()'s side effect of adding
> GIT_WORK_TREE to the same environment that would be used by
> push_to_deploy() wasn't taken into account.  Fix by only mutating the
> environment for push_to_commit(), rather than the shared environment.
>
> Fixes: a8cc594333 ("hooks: fix an obscure TOCTOU "did we just run a hook?" race")

This project doesn’t use `Fixes` trailers.[1] Mentions of commits go in
the commit message body (outside the trailers) using `git log -1
--format-reference <cmt>`.

The Linux project has uses for this structured information since there
is a lot of backporting of bugfixes. But I haven’t heard of a need for
that in this project.

🔗 1: https://lore.kernel.org/git/72839071-153f-4306-a705-3be0dc203109@app.fastmail.com/

> Signed-off-by: Alyssa Ross <hi@alyssa.is>
> ---
>[snip]

^ permalink raw reply

* [PATCH v2 4/4] notes: support an external command to display notes
From: Siddh Raman Pant @ 2026-05-22 16:09 UTC (permalink / raw)
  To: git
  Cc: Elijah Newren, Patrick Steinhardt, Kristoffer Haugsbakk,
	Junio C Hamano, brian m. carlson, Jeff King, Johannes Sixt,
	Oswald Buddenhagen
In-Reply-To: <cover.1779464886.git.siddh.raman.pant@oracle.com>

git notes is a very very helpful feature to show user-supplied
information about a commit alongside its message transparently.

For distributed teams working on large git repos (huge number of
branches/refs, files, etc.) and using the notes feature to mark
information on git commits, the problem is often not that two users
update the same note object at the same time. It is that the local
notes state used while reading history can be stale.

In kernel work, the same logical upstream fix can appear as different
commit objects across many downstream branches, such as the stable
branches and vendor-specific branches (based on which the released
kernel is actually built). Different developers may be working on those
branches in parallel, and a review decision recorded for one backport
is useful context for the others.

Today, seeing that decision in ordinary history output requires first
synchronizing the local notes ref, and then interpreting those notes
for the branch being inspected. The latter step is workflow-specific
and can be cheap, but keeping the local notes state fresh enough can be
expensive in a large kernel repository with a large shared notes
history (and if we are to extrapolate, a slow git server conn/ops can
be a factor too).

This TOCTOU problem exacerbates on scale (rapid updates, more devs,
larger repos, more git server traffic, etc).

One solution to this is to move the freshness policy out of git so that
it is someone else's problem. We can have a realtime fetch or faster
updation via external helper means. But unfortunately we lose the
coherence in the display of information, and so the user would end up
reinventing git log in his quest to have same workflow.

Let's add support for notes.externalCommand, a protected-configuration
command that git runs as a long-lived helper when displaying notes. git
sends commit IDs to the helper and displays any returned text through
the existing notes formatting path. This keeps presentation in git
while letting the helper decide how fresh note text is obtained.

We also add configuration for the displayed notes header name, timeout
enforcement for the helper so that git doesn't hang waiting on it,
optional --grep participation, and command-line controls to enable or
disable external notes. The new help text should make the usage clear.

Assisted-by: Codex:gpt-5.5-xhigh-fast
Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>
---
 Documentation/config/notes.adoc             |  61 +++
 Documentation/git-format-patch.adoc         |  11 +-
 Documentation/git-range-diff.adoc           |   6 +
 Documentation/pretty-options.adoc           |   9 +
 Makefile                                    |   2 +
 builtin/log.c                               |  17 +-
 builtin/name-rev.c                          |   9 +-
 builtin/range-diff.c                        |   2 +
 contrib/completion/git-completion.bash      |   4 +-
 log-tree.c                                  |   7 +-
 meson.build                                 |   1 +
 notes-external.c                            | 414 +++++++++++++++++++
 notes-external.h                            |  53 +++
 notes.c                                     | 264 +++++++++---
 notes.h                                     |  33 +-
 revision.c                                  |  36 +-
 t/helper/meson.build                        |   1 +
 t/helper/test-external-notes                |  64 +++
 t/helper/test-notes-external-config-reset.c |  24 ++
 t/helper/test-tool.c                        |   1 +
 t/helper/test-tool.h                        |   1 +
 t/lib-notes.sh                              |  19 +
 t/t3206-range-diff.sh                       |  68 ++++
 t/t3301-notes.sh                            | 424 ++++++++++++++++++++
 t/t6120-describe.sh                         |  17 +
 25 files changed, 1485 insertions(+), 63 deletions(-)
 create mode 100644 notes-external.c
 create mode 100644 notes-external.h
 create mode 100755 t/helper/test-external-notes
 create mode 100644 t/helper/test-notes-external-config-reset.c
 create mode 100644 t/lib-notes.sh

diff --git a/Documentation/config/notes.adoc b/Documentation/config/notes.adoc
index b7e536496f51..3b58bf684524 100644
--- a/Documentation/config/notes.adoc
+++ b/Documentation/config/notes.adoc
@@ -34,6 +34,67 @@ The effective value of `core.notesRef` (possibly overridden by
 `GIT_NOTES_REF`) is also implicitly added to the list of refs to be
 displayed.
 
+`notes.externalCommand`::
+	Command to invoke as a long-lived helper when showing commit messages
+	with the `git log` family of commands. Git sends one commit object ID
+	per request on the command's standard input:
++
+------------
+<hex-commit-id>
+------------
++
+For each request, the helper must respond on its standard output with either
+`<hex-commit-id> missing` followed by a newline, or `<hex-commit-id> ok <n>`
+followed by a newline and exactly `<n>` bytes of UTF-8 note text followed by a
+newline. The helper must respond to each request as it is received; Git does
+not send all commit object IDs before reading responses. Empty note text is not
+displayed. External notes are only used while formatting output by default; see
+`notes.externalCommandForGrep` to include them when matching commits.
++
+If Git cannot start or communicate with the helper, or the helper sends an
+invalid response, Git warns once and disables it for the rest of the Git run.
+When stopping the helper process, Git sends SIGTERM and waits for 0.1s on POSIX
+systems; if the helper does not exit, Git force-terminates it. On Windows, Git
+terminates the process directly.
++
+This setting is only respected in protected configuration (see
+linkgit:git-config[1]). This prevents untrusted repositories from running
+arbitrary commands when notes are displayed.
++
+This setting does not take effect when:
++
+--
+* the value is empty;
+* `--no-notes` is given;
+* `--no-external-notes` is given; or
+* `--notes=<ref>` is given by itself without `--external-notes` or `--notes`.
+--
+
+`notes.externalCommandName`::
+	Name to use in the `Notes (<name>):` header for notes returned by
+	`notes.externalCommand`. Defaults to `external`. This setting is only
+	respected in protected configuration.
+
+`notes.externalCommandTimeoutMs`::
+	Number of milliseconds to wait when reading each response from
+	`notes.externalCommand`. Defaults to `100`. If the command does not
+	produce the expected response in time, Git warns once and disables it
+	for the rest of the command. A value of `0` disables timeout handling,
+	so reads can block until the command writes output or exits. This
+	setting is only	respected in protected configuration.
+
+`notes.externalCommandForGrep`::
+	Boolean indicating whether notes returned by `notes.externalCommand`
+	are included when matching commits with `--grep`, wherever notes would
+	normally participate in grep matching. Defaults to false. This does
+	not make hidden notes searchable in formats such as `--oneline` or
+	`--pretty=%s`; use `--notes` or `--external-notes` if those formats
+	should search notes too. When enabled, revision traversal may invoke
+	the external command for many commits that are not ultimately
+	displayed, which can be expensive for slow commands. The note output
+	can also change which commits match. This setting is only respected in
+	protected configuration.
+
 `notes.rewrite.<command>`::
 	When rewriting commits with _<command>_ (currently `amend` or
 	`rebase`), if this variable is `false`, git will not copy
diff --git a/Documentation/git-format-patch.adoc b/Documentation/git-format-patch.adoc
index 566238245028..472b37e5237a 100644
--- a/Documentation/git-format-patch.adoc
+++ b/Documentation/git-format-patch.adoc
@@ -26,7 +26,7 @@ SYNOPSIS
 		   [--[no-]cover-letter] [--quiet]
 		   [--commit-list-format=<format-spec>]
 		   [--[no-]encode-email-headers]
-		   [--no-notes | --notes[=<ref>]]
+		   [--no-notes | --notes[=<ref>]] [--[no-]external-notes]
 		   [--interdiff=<previous>]
 		   [--range-diff=<previous> [--creation-factor=<percent>]]
 		   [--filename-max-length=<n>]
@@ -395,6 +395,15 @@ configuration options in linkgit:git-notes[1] to use this workflow).
 The default is `--no-notes`, unless the `format.notes` configuration is
 set.
 
+--external-notes::
+--no-external-notes::
+	Invoke or do not invoke `notes.externalCommand` to obtain external
+	notes. Like `--notes=<ref>`, `--external-notes` names an explicit
+	note source and by itself does not include the default notes refs.
+	Use `--external-notes --notes` to include the default notes refs
+	too, or combine `--external-notes` with `--notes=<ref>` to include
+	external notes with specific notes refs.
+
 --signature=<signature>::
 --no-signature::
 	Add a signature to each message produced. Per RFC 3676 the signature
diff --git a/Documentation/git-range-diff.adoc b/Documentation/git-range-diff.adoc
index 5cc5e2ed5673..1de23f300517 100644
--- a/Documentation/git-range-diff.adoc
+++ b/Documentation/git-range-diff.adoc
@@ -12,6 +12,7 @@ git range-diff [--color=[<when>]] [--no-color] [<diff-options>]
 	[--no-dual-color] [--creation-factor=<factor>]
 	[--left-only | --right-only] [--diff-merges=<format>]
 	[--remerge-diff] [--no-notes | --notes[=<ref>]]
+	[--[no-]external-notes]
 	( <range1> <range2> | <rev1>...<rev2> | <base> <rev1> <rev2> )
 	[[--] <path>...]
 
@@ -101,6 +102,11 @@ diff.
 	This flag is passed to the `git log` program
 	(see linkgit:git-log[1]) that generates the patches.
 
+`--external-notes`::
+`--no-external-notes`::
+	This flag is passed to the `git log` program
+	(see linkgit:git-log[1]) that generates the patches.
+
 `<range1> <range2>`::
 	Compare the commits specified by the two ranges, where
 	_<range1>_ is considered an older version of _<range2>_.
diff --git a/Documentation/pretty-options.adoc b/Documentation/pretty-options.adoc
index 658e462b2533..aad851c92cfd 100644
--- a/Documentation/pretty-options.adoc
+++ b/Documentation/pretty-options.adoc
@@ -93,6 +93,15 @@ being displayed. Examples: "`--notes=foo`" will show only notes from
 	"`--notes --notes=foo --no-notes --notes=bar`" will only show notes
 	from `refs/notes/bar`.
 
+`--external-notes`::
+`--no-external-notes`::
+	Invoke or do not invoke `notes.externalCommand` to obtain external
+	notes. Like `--notes=<ref>`, `--external-notes` names an explicit
+	note source and by itself does not include the default notes refs.
+	Use `--external-notes --notes` to include the default notes refs
+	too, or combine `--external-notes` with `--notes=<ref>` to include
+	external notes with specific notes refs.
+
 `--show-notes-by-default`::
 	Show the default notes unless options for displaying specific
 	notes are given.
diff --git a/Makefile b/Makefile
index fb50c57e4f25..898da8936e84 100644
--- a/Makefile
+++ b/Makefile
@@ -834,6 +834,7 @@ TEST_BUILTINS_OBJS += test-match-trees.o
 TEST_BUILTINS_OBJS += test-mergesort.o
 TEST_BUILTINS_OBJS += test-mktemp.o
 TEST_BUILTINS_OBJS += test-name-hash.o
+TEST_BUILTINS_OBJS += test-notes-external-config-reset.o
 TEST_BUILTINS_OBJS += test-online-cpus.o
 TEST_BUILTINS_OBJS += test-pack-deltas.o
 TEST_BUILTINS_OBJS += test-pack-mtimes.o
@@ -1206,6 +1207,7 @@ LIB_OBJS += negotiator/default.o
 LIB_OBJS += negotiator/noop.o
 LIB_OBJS += negotiator/skipping.o
 LIB_OBJS += notes-cache.o
+LIB_OBJS += notes-external.o
 LIB_OBJS += notes-merge.o
 LIB_OBJS += notes-utils.o
 LIB_OBJS += notes.o
diff --git a/builtin/log.c b/builtin/log.c
index 8c0939dd42ad..bed4c1576f2d 100644
--- a/builtin/log.c
+++ b/builtin/log.c
@@ -1337,9 +1337,24 @@ static void get_notes_args(struct strvec *arg, struct rev_info *rev)
 		   (rev->notes_opt.use_default_notes == -1 &&
 		    !rev->notes_opt.extra_notes_refs.nr)) {
 		strvec_push(arg, "--notes");
-	} else {
+	} else if (rev->notes_opt.extra_notes_refs.nr) {
 		for_each_string_list(&rev->notes_opt.extra_notes_refs, get_notes_refs, arg);
+	} else if (rev->notes_opt.use_external_notes <= 0) {
+		/*
+		 * rev->show_notes can stay set after
+		 * --external-notes --no-external-notes.
+		 *
+		 * Since range-diff's child log starts with
+		 * --show-notes-by-default, explicitly suppress
+		 * notes when no notes source remains.
+		 */
+		strvec_push(arg, "--no-notes");
 	}
+
+	if (rev->notes_opt.use_external_notes > 0)
+		strvec_push(arg, "--external-notes");
+	else if (rev->notes_opt.use_external_notes == 0)
+		strvec_push(arg, "--no-external-notes");
 }
 
 static void generate_shortlog_cover_letter(struct shortlog *log,
diff --git a/builtin/name-rev.c b/builtin/name-rev.c
index 60cbbfb4b7d1..5a0e7daac803 100644
--- a/builtin/name-rev.c
+++ b/builtin/name-rev.c
@@ -277,6 +277,7 @@ struct name_ref_data {
 struct pretty_format {
 	struct pretty_print_context ctx;
 	struct userformat_want want;
+	struct external_notes_state *external_notes_state;
 };
 
 enum command_type {
@@ -525,9 +526,9 @@ static const char *get_format_rev(const struct commit *c,
 	if (format_ctx->want.notes) {
 		struct strbuf notebuf = STRBUF_INIT;
 
-		format_display_notes(&c->object.oid, &notebuf,
-				     get_log_output_encoding(),
-				     format_ctx->ctx.fmt == CMIT_FMT_USERFORMAT);
+		format_display_notes(c, &notebuf, get_log_output_encoding(),
+				     format_ctx->ctx.fmt == CMIT_FMT_USERFORMAT,
+				     format_ctx->external_notes_state);
 		format_ctx->ctx.notes_message = strbuf_detach(&notebuf, NULL);
 	}
 
@@ -879,6 +880,8 @@ int cmd_format_rev(int argc,
 						 &ignore_show_notes,
 						 n->string);
 		load_display_notes(&format_notes_opt);
+		format_pp.external_notes_state =
+			format_notes_opt.external_notes_state;
 	}
 
 	init_format_rev_command(&cmd, &format_pp);
diff --git a/builtin/range-diff.c b/builtin/range-diff.c
index e54c0f7fe156..41c27250404a 100644
--- a/builtin/range-diff.c
+++ b/builtin/range-diff.c
@@ -56,6 +56,8 @@ int cmd_range_diff(int argc,
 		OPT_PASSTHRU_ARGV(0, "notes", &log_arg,
 				  N_("notes"), N_("passed to 'git log'"),
 				  PARSE_OPT_OPTARG),
+		OPT_PASSTHRU_ARGV(0, "external-notes", &log_arg, NULL,
+				  N_("passed to 'git log'"), PARSE_OPT_NOARG),
 		OPT_PASSTHRU_ARGV(0, "diff-merges", &diff_merges_arg,
 				  N_("style"), N_("passed to 'git log'"), 0),
 		OPT_CALLBACK(0, "max-memory", &range_diff_opts.max_memory,
diff --git a/contrib/completion/git-completion.bash b/contrib/completion/git-completion.bash
index a8e7c6ddbfb2..146444e65860 100644
--- a/contrib/completion/git-completion.bash
+++ b/contrib/completion/git-completion.bash
@@ -2023,7 +2023,7 @@ _git_fetch ()
 
 __git_format_patch_extra_options="
 	--full-index --not --all --no-prefix --src-prefix=
-	--dst-prefix= --notes
+	--dst-prefix= --notes --external-notes --no-external-notes
 "
 
 _git_format_patch ()
@@ -2215,7 +2215,7 @@ __git_log_common_options="
 __git_log_gitk_options="
 	--dense --sparse --full-history
 	--simplify-merges --simplify-by-decoration
-	--left-right --notes --no-notes
+	--left-right --notes --no-notes --external-notes --no-external-notes
 "
 # Options that go well for log and shortlog (not gitk)
 __git_log_shortlog_options="
diff --git a/log-tree.c b/log-tree.c
index 4503a42dde6b..f37c8b14e9a1 100644
--- a/log-tree.c
+++ b/log-tree.c
@@ -856,9 +856,12 @@ void show_log(struct rev_info *opt)
 	if (opt->show_notes) {
 		struct strbuf notebuf = STRBUF_INIT;
 		bool raw = (opt->commit_format == CMIT_FMT_USERFORMAT);
+		const struct display_notes_opt *notes_opt = &opt->notes_opt;
+
+		format_display_notes(commit, &notebuf,
+				     get_log_output_encoding(), raw,
+				     notes_opt->external_notes_state);
 
-		format_display_notes(&commit->object.oid, &notebuf,
-				     get_log_output_encoding(), raw);
 		ctx.notes_message = strbuf_detach(&notebuf, NULL);
 	}
 
diff --git a/meson.build b/meson.build
index 052c81f2887b..83845f84fed0 100644
--- a/meson.build
+++ b/meson.build
@@ -397,6 +397,7 @@ libgit_sources = [
   'notes-merge.c',
   'notes-utils.c',
   'notes.c',
+  'notes-external.c',
   'object-file-convert.c',
   'object-file.c',
   'object-name.c',
diff --git a/notes-external.c b/notes-external.c
new file mode 100644
index 000000000000..09da102a7901
--- /dev/null
+++ b/notes-external.c
@@ -0,0 +1,414 @@
+#include "git-compat-util.h"
+#include "gettext.h"
+#include "hex.h"
+#include "notes-external.h"
+#include "run-command.h"
+#include "sigchain.h"
+#include "strbuf.h"
+#include "trace.h"
+
+#define convert_ms_to_ns(ms) (uint64_t)(ms) * 1000000ULL
+#define convert_ns_to_ms(ns) (uint64_t)(ns) / 1000000ULL
+#define EXTERNAL_NOTES_DEFAULT_TIMEOUT_MS 100
+#define EXTERNAL_NOTES_READ_CHUNK_SIZE 16384	/* (16 * 1024) bytes */
+
+/* Configuration helpers. */
+
+static void init_external_notes_config(struct external_notes_config *config)
+{
+	if (!config)
+		return;
+
+	memset(config, 0, sizeof(*config));
+	config->read_timeout_ns =
+		convert_ms_to_ns(EXTERNAL_NOTES_DEFAULT_TIMEOUT_MS);
+}
+
+static void release_external_notes_config(struct external_notes_config *config)
+{
+	if (!config)
+		return;
+
+	FREE_AND_NULL(config->command);
+	FREE_AND_NULL(config->command_name_value);
+}
+
+struct external_notes_state *external_notes_new(void)
+{
+	struct external_notes_state *state = xcalloc(1, sizeof(*state));
+
+	init_external_notes_config(&state->config);
+	child_process_init(&state->process.process);
+	state->process.out_fd = -1;
+
+	return state;
+}
+
+void set_external_notes_command(struct external_notes_state *state,
+				const char *command)
+{
+	struct external_notes_config *config;
+
+	if (!state)
+		return;
+
+	config = &state->config;
+	FREE_AND_NULL(config->command);
+
+	if (command && *command)
+		config->command = xstrdup(command);
+}
+
+bool external_notes_command_configured(const struct external_notes_state *state)
+{
+	return state && state->config.command && !state->process.failed;
+}
+
+void external_notes_reset(struct external_notes_state *state)
+{
+	if (!state)
+		return;
+
+	if (state->process.started)
+		BUG("cannot reset external notes config while cmd is running");
+
+	release_external_notes_config(&state->config);
+	init_external_notes_config(&state->config);
+	state->process.failed = false;
+}
+
+void set_external_notes_command_name(struct external_notes_state *state,
+				     const char *name)
+{
+	struct external_notes_config *config;
+
+	if (!state)
+		return;
+
+	config = &state->config;
+	FREE_AND_NULL(config->command_name_value);
+
+	if (name && *name)
+		config->command_name_value = xstrdup(name);
+}
+
+const char *external_notes_command_name(const struct external_notes_state *state)
+{
+	if (state && state->config.command_name_value)
+		return state->config.command_name_value;
+
+	return "external";
+}
+
+void set_external_notes_command_timeout_ms(struct external_notes_state *state,
+					   int timeout_ms)
+{
+	if (!state)
+		return;
+
+	if (timeout_ms < 0)
+		BUG("negative notes.externalCommandTimeoutMs");
+
+	state->config.read_timeout_ns = convert_ms_to_ns(timeout_ms);
+}
+
+int external_notes_command_timeout_ms(const struct external_notes_state *state)
+{
+	if (!state)
+		return -1;
+
+	return (int)convert_ns_to_ms(state->config.read_timeout_ns);
+}
+
+void set_external_notes_for_grep(struct external_notes_state *state,
+				 int enabled)
+{
+	if (!state)
+		return;
+
+	state->config.for_grep = (bool)enabled;
+}
+
+bool external_notes_for_grep_enabled(const struct external_notes_state *state)
+{
+	return state && state->config.for_grep;
+}
+
+/* Process management helpers. */
+
+static void mute_routine(const char *msg UNUSED, va_list params UNUSED)
+{
+	/* do nothing */
+}
+
+static void close_external_notes_pipes(struct external_notes_process *state)
+{
+	struct child_process *process;
+
+	if (!state)
+		return;
+
+	process = &state->process;
+
+	sigchain_push(SIGPIPE, SIG_IGN);
+
+	if (state->in) {
+		fclose(state->in);
+		state->in = NULL;
+	} else {
+		close(process->in);
+	}
+
+	if (state->out_fd >= 0) {
+		close(state->out_fd);
+		state->out_fd = -1;
+	} else {
+		close(process->out);
+	}
+
+	sigchain_pop(SIGPIPE);
+}
+
+/* We set this as callback later, so can't have void argument. */
+static void cleanup_external_notes_process(struct child_process *process)
+{
+	report_fn old_error = NULL;
+	struct external_notes_process *state;
+
+	if (!process)
+		return;
+
+	state = container_of(process, struct external_notes_process, process);
+
+	kill(process->pid, SIGTERM);
+	old_error = get_error_routine();
+	set_error_routine(mute_routine);
+
+	close_external_notes_pipes(state);
+	finish_command(process);
+
+	if (old_error)
+		set_error_routine(old_error);
+
+	state->started = false;
+}
+
+static void stop_external_notes_process(struct external_notes_process *state)
+{
+	if (!state)
+		return;
+
+	if (!state->started)
+		return;
+
+	state->process.clean_on_exit = 0;
+	cleanup_external_notes_process(&state->process);
+	child_process_init(&state->process);
+	state->out_fd = -1;
+}
+
+static int fail_external_notes_command(struct external_notes_state *state)
+{
+	const struct external_notes_config *config;
+	struct external_notes_process *process;
+
+	if (!state)
+		return -1;
+
+	config = &state->config;
+	process = &state->process;
+	if (!process->failed)
+		warning(_("notes.externalCommand failed: %s"),
+			config->command);
+
+	process->failed = true;
+	stop_external_notes_process(process);
+	return -1;
+}
+
+static int start_external_notes_command(struct external_notes_state *state)
+{
+	const struct external_notes_config *config;
+	struct external_notes_process *process;
+	struct child_process *cmd;
+
+	if (!state)
+		return -1;
+
+	config = &state->config;
+	process = &state->process;
+	cmd = &process->process;
+
+	if (process->started)
+		return 0;
+
+	if (!config->command || process->failed)
+		return -1;
+
+	child_process_init(cmd);
+	strvec_push(&cmd->args, config->command);
+	cmd->use_shell = 1;
+	cmd->in = -1;
+	cmd->out = -1;
+	cmd->clean_on_exit = 1;
+	cmd->clean_on_exit_handler = cleanup_external_notes_process;
+	cmd->trace2_child_class = "notes-external";
+
+	if (start_command(cmd))
+		return fail_external_notes_command(state);
+
+	process->in = xfdopen(cmd->in, "wb");
+	process->out_fd = cmd->out;
+	process->started = true;
+
+	return 0;
+}
+
+void external_notes_free(struct external_notes_state *state)
+{
+	if (!state)
+		return;
+
+	stop_external_notes_process(&state->process);
+	release_external_notes_config(&state->config);
+	free(state);
+}
+
+/* Command parser. Essentially the main() function of this file. */
+int format_external_note(struct external_notes_state *state,
+			 const struct object_id *object_oid,
+			 struct strbuf *note_buf)
+{
+	struct strbuf status = STRBUF_INIT;
+	char commit_id_hex_str[GIT_MAX_HEXSZ + 1];
+	const char *arg;
+	char *end;
+	char ch;
+	unsigned long len;
+	uint64_t deadline_ns;
+	bool input_fail;
+	int ret = 0;
+	const struct external_notes_config *config;
+	struct external_notes_process *process;
+
+	if (!state)
+		return -1;
+
+	/* Exit early if starting the command fails. */
+	if (start_external_notes_command(state) != 0)
+		return -1;
+
+	config = &state->config;
+	process = &state->process;
+
+	/* Fetch the commit ID hex. */
+	oid_to_hex_r(commit_id_hex_str, object_oid);
+
+	/* Pass the input to the external command. */
+	sigchain_push(SIGPIPE, SIG_IGN);
+	input_fail = fprintf(process->in, "%s\n", commit_id_hex_str) < 0
+		     || fflush(process->in) != 0;
+	sigchain_pop(SIGPIPE);
+
+	if (input_fail)
+		goto out_fail;
+
+	if (config->read_timeout_ns == 0)
+		deadline_ns = 0;
+	else
+		deadline_ns = getnanotime() + config->read_timeout_ns;
+
+	/**
+	 * The output for each commit is either of the two:
+	 * 	"{commit id} missing\n"
+	 * 	"{commit id} ok {num_bytes}\n{str_of_num_bytes}\n"
+	 *
+	 * We can have "\r\n" instead of "\n" due to Windows.
+	 */
+
+	/* Read the first line with its delimiter. */
+	if (strbuf_getwholeline_fd_deadline(&status, process->out_fd, '\n',
+					    deadline_ns) == EOF)
+		goto out_fail;
+
+	/* Reject EOF-terminated partial lines. */
+	if (!status.len || status.buf[status.len - 1] != '\n')
+		goto out_fail;
+
+	/**
+	 * Strip LF and then optional CR so both LF and CRLF protocol lines
+	 * are accepted.
+	 */
+	strbuf_setlen(&status, status.len - 1);
+	strbuf_strip_suffix(&status, "\r");
+
+	/* Check if line starts with the commit ID. */
+	if (!skip_prefix(status.buf, commit_id_hex_str, &arg))
+		goto out_fail;
+
+	if (*arg++ != ' ')  /* After commit ID there should be a space. */
+		goto out_fail;
+
+	if (strcmp(arg, "missing") == 0)  /* No note available. */
+		goto out_success;  /* Ending newline is already ensured. */
+
+	if (!skip_prefix(arg, "ok ", &arg))  /* Neither missing nor ok. */
+		goto out_fail;
+
+	/* We are in "ok" case. */
+
+	/* The next thing is length of the note. It must be unsigned digits. */
+	if (!isdigit(*arg))
+		goto out_fail;
+
+	/* Get the length of note. */
+	errno = 0;
+	len = strtoul(arg, &end, 10);
+	if (errno != 0 || *end != '\0' || end == arg)
+		goto out_fail;
+
+	/* Ending newline is already ensured. */
+
+	/* Read the trailing note in bounded-chunks. */
+	while (note_buf->len < len) {
+		ssize_t got;
+		size_t remaining = len - note_buf->len;
+		size_t want = remaining < EXTERNAL_NOTES_READ_CHUNK_SIZE ?
+			      remaining : EXTERNAL_NOTES_READ_CHUNK_SIZE;
+
+		strbuf_grow(note_buf, want);
+
+		got = read_in_full_deadline(process->out_fd,
+					    note_buf->buf + note_buf->len,
+					    want, deadline_ns);
+		if (got < 0 || (size_t)got != want)
+			goto out_fail;
+
+		strbuf_setlen(note_buf, note_buf->len + (size_t)got);
+	}
+
+	/* Ensure the ending newline (LF/CRLF) after the note. */
+	if (xread_deadline(process->out_fd, &ch, 1, deadline_ns) != 1)
+		goto out_fail;
+
+	if (ch != '\n') {  /* Not a LF. */
+		if (ch != '\r')  /* Not a CRLF. */
+			goto out_fail;
+
+		/* We have '\r', let's read the next char. */
+		if (xread_deadline(process->out_fd, &ch, 1,
+				   deadline_ns) != 1)
+			goto out_fail;
+
+		if (ch != '\n')  /* Not a CRLF. */
+			goto out_fail;
+	}
+
+	goto out_success;
+
+out_fail:
+	ret = fail_external_notes_command(state);
+out_success:
+	strbuf_release(&status);
+	return ret;
+}
diff --git a/notes-external.h b/notes-external.h
new file mode 100644
index 000000000000..1b5c2d3919a2
--- /dev/null
+++ b/notes-external.h
@@ -0,0 +1,53 @@
+#ifndef NOTES_EXTERNAL_H
+#define NOTES_EXTERNAL_H
+
+#include "run-command.h"
+
+struct object_id;
+struct strbuf;
+
+struct external_notes_config {
+	char *command;
+	char *command_name_value;
+	uint64_t read_timeout_ns;
+	bool for_grep;
+};
+
+struct external_notes_process {
+	struct child_process process;
+	FILE *in;
+	int out_fd;
+	bool started;
+	bool failed;
+};
+
+struct external_notes_state {
+	struct external_notes_config config;
+	struct external_notes_process process;
+};
+
+struct external_notes_state *external_notes_new(void);
+void external_notes_free(struct external_notes_state *state);
+void external_notes_reset(struct external_notes_state *state);
+
+void set_external_notes_command(struct external_notes_state *state,
+				const char *command);
+bool external_notes_command_configured(const struct external_notes_state *state);
+
+void set_external_notes_command_name(struct external_notes_state *state,
+				     const char *name);
+const char *external_notes_command_name(const struct external_notes_state *state);
+
+void set_external_notes_command_timeout_ms(struct external_notes_state *state,
+					   int timeout_ms);
+int external_notes_command_timeout_ms(const struct external_notes_state *state);
+
+void set_external_notes_for_grep(struct external_notes_state *state,
+				 int enabled);
+bool external_notes_for_grep_enabled(const struct external_notes_state *state);
+
+int format_external_note(struct external_notes_state *state,
+			 const struct object_id *object_oid,
+			 struct strbuf *out);
+
+#endif  /* NOTES_EXTERNAL_H */
diff --git a/notes.c b/notes.c
index 201f1df3dc29..624d4aba223d 100644
--- a/notes.c
+++ b/notes.c
@@ -3,9 +3,12 @@
 
 #include "git-compat-util.h"
 #include "config.h"
+#include "commit.h"
 #include "environment.h"
+#include "gettext.h"
 #include "hex.h"
 #include "notes.h"
+#include "notes-external.h"
 #include "object-file.h"
 #include "object-name.h"
 #include "odb.h"
@@ -983,18 +986,59 @@ void string_list_add_refs_from_colon_sep(struct string_list *list,
 	free(globs_copy);
 }
 
+struct notes_display_config_data {
+	int load_refs;
+	int load_command;
+	struct external_notes_state *external_notes_state;
+};
+
 static int notes_display_config(const char *k, const char *v,
-				const struct config_context *ctx UNUSED,
+				const struct config_context *ctx,
 				void *cb)
 {
-	int *load_refs = cb;
+	struct notes_display_config_data *data = cb;
 
-	if (*load_refs && !strcmp(k, "notes.displayref")) {
+	if (data->load_refs && !strcmp(k, "notes.displayref")) {
 		if (!v)
 			return config_error_nonbool(k);
 		string_list_add_refs_by_glob(&display_notes_refs, v);
 	}
 
+	if (data->load_command && !strcmp(k, "notes.externalcommand")) {
+		if (!v)
+			return config_error_nonbool(k);
+
+		set_external_notes_command(data->external_notes_state, v);
+	}
+
+	if (data->load_command && !strcmp(k, "notes.externalcommandname")) {
+		if (!v)
+			return config_error_nonbool(k);
+
+		if (strchr(v, '\n') || strchr(v, '\r'))
+			return error(_("notes.externalCommandName must not contain a newline"));
+
+		set_external_notes_command_name(data->external_notes_state, v);
+	}
+
+	if (data->load_command && !strcmp(k, "notes.externalcommandtimeoutms")) {
+		int timeout_ms;
+
+		if (!v)
+			return config_error_nonbool(k);
+
+		timeout_ms = git_config_int(k, v, ctx->kvi);
+		if (timeout_ms < 0)
+			return error(_("notes.externalCommandTimeoutMs must be non-negative"));
+
+		set_external_notes_command_timeout_ms(data->external_notes_state,
+						      timeout_ms);
+	}
+
+	if (data->load_command && !strcmp(k, "notes.externalcommandforgrep"))
+		set_external_notes_for_grep(data->external_notes_state,
+					    git_config_bool(k, v));
+
 	return 0;
 }
 
@@ -1075,17 +1119,21 @@ void init_display_notes(struct display_notes_opt *opt)
 {
 	memset(opt, 0, sizeof(*opt));
 	opt->use_default_notes = -1;
+	opt->use_external_notes = -1;
 	string_list_init_dup(&opt->extra_notes_refs);
 }
 
 void release_display_notes(struct display_notes_opt *opt)
 {
 	string_list_clear(&opt->extra_notes_refs, 0);
+	external_notes_free(opt->external_notes_state);
+	opt->external_notes_state = NULL;
 }
 
 void enable_default_display_notes(struct display_notes_opt *opt, int *show_notes)
 {
 	opt->use_default_notes = 1;
+	opt->default_notes_suppressed_by_external = 0;
 	*show_notes = 1;
 }
 
@@ -1102,31 +1150,96 @@ void enable_ref_display_notes(struct display_notes_opt *opt, int *show_notes,
 void disable_display_notes(struct display_notes_opt *opt, int *show_notes)
 {
 	opt->use_default_notes = -1;
+	opt->use_external_notes = -1;
+	opt->default_notes_suppressed_by_external = 0;
 	string_list_clear(&opt->extra_notes_refs, 0);
 	*show_notes = 0;
 }
 
+/*
+ * Resolve the default-notes tri-state in one place. Callers must not test
+ * use_default_notes directly unless they specifically need the unresolved
+ * command-line state.
+ */
+static bool display_notes_use_default(const struct display_notes_opt *opt)
+{
+	/* Options aren't specified, default to true. */
+	if (!opt)
+		return true;
+
+	/* Explicitly enabled. */
+	if (opt->use_default_notes > 0)
+		return true;
+
+	/* Undefined and no explicit notes-ref specified, default to true. */
+	if (opt->use_default_notes == -1 && !opt->extra_notes_refs.nr)
+		return true;
+
+	return false;
+}
+
+/*
+ * Resolve the external-notes tri-state. The unset value follows the resolved
+ * default-notes decision, which means "git log" runs the helper by default
+ * but "git log --notes=<ref>" does not.
+ */
+static bool display_notes_use_external(const struct display_notes_opt *opt)
+{
+	/* Options aren't specified, default to false. */
+	if (!opt)
+		return false;
+
+	/* Explicitly enabled. */
+	if (opt->use_external_notes > 0)
+		return true;
+
+	/* Undefined and to use default notes set, default to true. */
+	if (opt->use_external_notes < 0 && display_notes_use_default(opt))
+		return true;
+
+	return false;
+}
+
 void load_display_notes(struct display_notes_opt *opt)
 {
 	char *display_ref_env;
-	int load_config_refs = 0;
+	struct notes_display_config_data config = { 0, 0 };
+	struct notes_display_config_data protected_config = { 0, 0 };
+	bool use_default_notes = display_notes_use_default(opt);
+	bool use_external_notes = display_notes_use_external(opt);
+
 	display_notes_refs.strdup_strings = 1;
 
+	if (use_external_notes && opt->external_notes_state) {
+		external_notes_reset(opt->external_notes_state);
+	} else if (opt) {
+		external_notes_free(opt->external_notes_state);
+		opt->external_notes_state = NULL;
+	}
+
 	assert(!display_notes_trees);
 
-	if (!opt || opt->use_default_notes > 0 ||
-	    (opt->use_default_notes == -1 && !opt->extra_notes_refs.nr)) {
+	if (use_default_notes) {
 		string_list_append_nodup(&display_notes_refs, default_notes_ref(the_repository));
 		display_ref_env = getenv(GIT_NOTES_DISPLAY_REF_ENVIRONMENT);
 		if (display_ref_env) {
 			string_list_add_refs_from_colon_sep(&display_notes_refs,
 							    display_ref_env);
-			load_config_refs = 0;
+			config.load_refs = 0;
 		} else
-			load_config_refs = 1;
+			config.load_refs = 1;
 	}
 
-	repo_config(the_repository, notes_display_config, &load_config_refs);
+	if (use_external_notes) {
+		if (!opt->external_notes_state)
+			opt->external_notes_state = external_notes_new();
+
+		protected_config.load_command = 1;
+		protected_config.external_notes_state = opt->external_notes_state;
+	}
+
+	repo_config(the_repository, notes_display_config, &config);
+	git_protected_config(notes_display_config, &protected_config);
 
 	if (opt) {
 		struct string_list_item *item;
@@ -1266,47 +1379,31 @@ void free_notes(struct notes_tree *t)
 }
 
 /*
- * Fill the given strbuf with the notes associated with the given object.
+ * Append one already-loaded note message to the given strbuf.
  *
- * If the given notes_tree structure is not initialized, it will be auto-
- * initialized to the default value (see documentation for init_notes() above).
- * If the given notes_tree is NULL, the internal/default notes_tree will be
- * used instead.
+ * Notes read from refs and notes obtained from notes.externalCommand both use
+ * this helper so they share the same encoding, header, and indentation rules.
  *
  * (raw == true) gives the %N userformat; otherwise, the note message is given
  * for human consumption.
  */
-static void format_note(struct notes_tree *t, const struct object_id *object_oid,
-			struct strbuf *sb, const char *output_encoding, bool raw)
+static void format_note_data(const char *ref, const char *msg, size_t msglen,
+			     struct strbuf *sb, const char *output_encoding,
+			     bool raw, bool literal_ref)
 {
 	static const char utf8[] = "utf-8";
-	const struct object_id *oid;
-	char *msg, *msg_p;
-	unsigned long linelen, msglen;
-	enum object_type type;
-
-	if (!t)
-		t = &default_notes_tree;
-	if (!t->initialized)
-		init_notes(t, NULL, NULL, 0);
-
-	oid = get_note(t, object_oid);
-	if (!oid)
-		return;
-
-	if (!(msg = odb_read_object(the_repository->objects, oid, &type, &msglen)) ||
-	    type != OBJ_BLOB) {
-		free(msg);
-		return;
-	}
+	char *reencoded = NULL;
+	const char *msg_p, *msg_end;
 
+	/* Convert the note text from UTF-8 to the requested output encoding. */
 	if (output_encoding && *output_encoding &&
 	    !is_encoding_utf8(output_encoding)) {
-		char *reencoded = reencode_string(msg, output_encoding, utf8);
+		size_t reencoded_len;
+		reencoded = reencode_string_len(msg, msglen, output_encoding,
+						 utf8, &reencoded_len);
 		if (reencoded) {
-			free(msg);
 			msg = reencoded;
-			msglen = strlen(msg);
+			msglen = reencoded_len;
 		}
 	}
 
@@ -1314,37 +1411,106 @@ static void format_note(struct notes_tree *t, const struct object_id *object_oid
 	if (msglen && msg[msglen - 1] == '\n')
 		msglen--;
 
+	/* Raw mode is the %N userformat, so it omits the "Notes" header. */
 	if (!raw) {
-		const char *ref = t->ref;
-		if (!ref || !strcmp(ref, GIT_NOTES_DEFAULT_REF)) {
+		if (!ref)
 			strbuf_addstr(sb, "\nNotes:\n");
-		} else {
-			skip_prefix(ref, "refs/", &ref);
-			skip_prefix(ref, "notes/", &ref);
+		else if (!literal_ref && !strcmp(ref, GIT_NOTES_DEFAULT_REF))
+			strbuf_addstr(sb, "\nNotes:\n");
+		else {
+			if (!literal_ref) {
+				skip_prefix(ref, "refs/", &ref);
+				skip_prefix(ref, "notes/", &ref);
+			}
 			strbuf_addf(sb, "\nNotes (%s):\n", ref);
 		}
 	}
 
-	for (msg_p = msg; msg_p < msg + msglen; msg_p += linelen + 1) {
-		linelen = strchrnul(msg_p, '\n') - msg_p;
+	msg_end = msg + msglen;
+	for (msg_p = msg; msg_p < msg_end; ) {
+		const char *eol = memchr(msg_p, '\n', msg_end - msg_p);
+		size_t linelen = eol ? eol - msg_p : msg_end - msg_p;
 
+		/* Human output indents note body lines under the header. */
 		if (!raw)
 			strbuf_addstr(sb, "    ");
+
 		strbuf_add(sb, msg_p, linelen);
 		strbuf_addch(sb, '\n');
+
+		msg_p += linelen;
+		if (msg_p < msg_end)
+			msg_p++;
+	}
+
+	free(reencoded);
+}
+
+/*
+ * Fill the given strbuf with the notes associated with the given object.
+ *
+ * If the given notes_tree structure is not initialized, it will be auto-
+ * initialized to the default value (see documentation for init_notes() above).
+ * If the given notes_tree is NULL, the internal/default notes_tree will be
+ * used instead.
+ */
+static void format_note_from_tree(struct notes_tree *t,
+				  const struct object_id *object_oid,
+				  struct strbuf *sb,
+				  const char *output_encoding, bool raw)
+{
+	const struct object_id *oid;
+	char *msg;
+	unsigned long msglen;
+	enum object_type type;
+
+	if (!t)
+		t = &default_notes_tree;
+	if (!t->initialized)
+		init_notes(t, NULL, NULL, 0);
+
+	oid = get_note(t, object_oid);
+	if (!oid)
+		return;
+
+	if (!(msg = odb_read_object(the_repository->objects, oid, &type, &msglen)) ||
+	    type != OBJ_BLOB) {
+		free(msg);
+		return;
 	}
 
+	format_note_data(t->ref, msg, msglen, sb, output_encoding, raw, false);
+
 	free(msg);
 }
 
-void format_display_notes(const struct object_id *object_oid,
-			  struct strbuf *sb, const char *output_encoding, bool raw)
+void format_display_notes(const struct commit *commit,
+			  struct strbuf *sb, const char *output_encoding,
+			  bool raw,
+			  struct external_notes_state *external_state)
 {
 	int i;
+	const struct object_id *commit_oid = &commit->object.oid;
+
 	assert(display_notes_trees);
 	for (i = 0; display_notes_trees[i]; i++)
-		format_note(display_notes_trees[i], object_oid, sb,
-			    output_encoding, raw);
+		format_note_from_tree(display_notes_trees[i], commit_oid, sb,
+				      output_encoding, raw);
+
+	if (external_notes_command_configured(external_state)) {
+		struct strbuf out = STRBUF_INIT;
+
+		if (format_external_note(external_state, commit_oid, &out) == 0
+		    && out.len) {
+			const char *label =
+				external_notes_command_name(external_state);
+
+			format_note_data(label, out.buf, out.len, sb,
+					 output_encoding, raw, true);
+		}
+
+		strbuf_release(&out);
+	}
 }
 
 int copy_note(struct notes_tree *t,
diff --git a/notes.h b/notes.h
index f6410b31e1c9..5ac6a7e01dfb 100644
--- a/notes.h
+++ b/notes.h
@@ -6,6 +6,8 @@
 struct object_id;
 struct repository;
 struct strbuf;
+struct commit;
+struct external_notes_state;
 
 /*
  * Function type for combining two notes annotating the same object.
@@ -264,11 +266,31 @@ struct display_notes_opt {
 	 */
 	int use_default_notes;
 
+	/*
+	 * Less than `0` is "unset", which means external notes are shown iff
+	 * the default notes are shown. Otherwise, treat it like a boolean.
+	 */
+	int use_external_notes;
+
+	/*
+	 * Tracks the synthetic "default notes off" state introduced by
+	 * `--external-notes`, so a later deprecated `--show-notes=<ref>`
+	 * can still preserve its historical additive behavior without
+	 * overriding an explicit `--no-standard-notes`.
+	 */
+	int default_notes_suppressed_by_external;
+
 	/*
 	 * A list of globs (in the same style as notes.displayRef) where
 	 * notes should be loaded from.
 	 */
 	struct string_list extra_notes_refs;
+
+	/*
+	 * State for notes.externalCommand. This is initialized lazily by
+	 * load_display_notes() when external notes may be used.
+	 */
+	struct external_notes_state *external_notes_state;
 };
 
 /*
@@ -304,16 +326,21 @@ void disable_display_notes(struct display_notes_opt *opt, int *show_notes);
 void load_display_notes(struct display_notes_opt *opt);
 
 /*
- * Append notes for the given 'object_sha1' from all trees set up by
+ * Append notes for the given commit from all trees set up by
  * load_display_notes() to 'sb'.
  *
  * If 'raw' is false the note will be indented by 4 places and
  * a 'Notes (refname):' header added.
  *
+ * If 'external_state' is not NULL, notes.externalCommand will be used to
+ * append the note from an external source.
+ *
  * You *must* call load_display_notes() before using this function.
  */
-void format_display_notes(const struct object_id *object_oid,
-			  struct strbuf *sb, const char *output_encoding, bool raw);
+void format_display_notes(const struct commit *commit,
+			  struct strbuf *sb, const char *output_encoding,
+			  bool raw,
+			  struct external_notes_state *external_state);
 
 /*
  * Load the notes tree from each ref listed in 'refs'.  The output is
diff --git a/revision.c b/revision.c
index cd9fcefa0a88..f9581fa82f95 100644
--- a/revision.c
+++ b/revision.c
@@ -6,6 +6,7 @@
 #include "environment.h"
 #include "gettext.h"
 #include "hex.h"
+#include "notes-external.h"
 #include "object-name.h"
 #include "object-file.h"
 #include "odb.h"
@@ -2583,18 +2584,40 @@ static int handle_revision_opt(struct rev_info *revs, int argc, const char **arg
 	} else if (skip_prefix(arg, "--show-notes=", &optarg) ||
 		   skip_prefix(arg, "--notes=", &optarg)) {
 		if (starts_with(arg, "--show-notes=") &&
-		    revs->notes_opt.use_default_notes < 0)
+		    (revs->notes_opt.use_default_notes < 0 ||
+		     revs->notes_opt.default_notes_suppressed_by_external)) {
 			revs->notes_opt.use_default_notes = 1;
+			revs->notes_opt.default_notes_suppressed_by_external = 0;
+		}
 		enable_ref_display_notes(&revs->notes_opt, &revs->show_notes, optarg);
 		revs->show_notes_given = 1;
 	} else if (!strcmp(arg, "--no-notes")) {
 		disable_display_notes(&revs->notes_opt, &revs->show_notes);
 		revs->show_notes_given = 1;
+	} else if (!strcmp(arg, "--external-notes")) {
+		revs->notes_opt.use_external_notes = 1;
+		revs->show_notes = 1;
+		revs->show_notes_given = 1;
+		/*
+		 * `--external-notes` names a note source on its own. If the
+		 * default notes ref is still undecided, settle it to "off" so
+		 * this option does not also trigger the "no explicit notes
+		 * refs" fallback. A later use of `--notes` or the deprecated
+		 * `--show-notes=<ref>` can still turn the default ref on.
+		 */
+		if (revs->notes_opt.use_default_notes < 0) {
+			revs->notes_opt.use_default_notes = 0;
+			revs->notes_opt.default_notes_suppressed_by_external = 1;
+		}
+	} else if (!strcmp(arg, "--no-external-notes")) {
+		revs->notes_opt.use_external_notes = 0;
 	} else if (!strcmp(arg, "--standard-notes")) {
 		revs->show_notes_given = 1;
 		revs->notes_opt.use_default_notes = 1;
+		revs->notes_opt.default_notes_suppressed_by_external = 0;
 	} else if (!strcmp(arg, "--no-standard-notes")) {
 		revs->notes_opt.use_default_notes = 0;
+		revs->notes_opt.default_notes_suppressed_by_external = 0;
 	} else if (!strcmp(arg, "--oneline")) {
 		revs->verbose_header = 1;
 		get_commit_format("oneline", revs);
@@ -4105,9 +4128,18 @@ static int commit_match(struct commit *commit, struct rev_info *opt)
 
 	/* Append "fake" message parts as needed */
 	if (opt->show_notes) {
+		const struct display_notes_opt *notes_opt = &opt->notes_opt;
+		struct external_notes_state *external_notes_state =
+			notes_opt->external_notes_state;
+
 		if (!buf.len)
 			strbuf_addstr(&buf, message);
-		format_display_notes(&commit->object.oid, &buf, encoding, true);
+
+		if (!external_notes_for_grep_enabled(external_notes_state))
+			external_notes_state = NULL;
+
+		format_display_notes(commit, &buf, encoding, true,
+				     external_notes_state);
 	}
 
 	/*
diff --git a/t/helper/meson.build b/t/helper/meson.build
index 3235f10ab8aa..15b6198c19fe 100644
--- a/t/helper/meson.build
+++ b/t/helper/meson.build
@@ -35,6 +35,7 @@ test_tool_sources = [
   'test-mergesort.c',
   'test-mktemp.c',
   'test-name-hash.c',
+  'test-notes-external-config-reset.c',
   'test-online-cpus.c',
   'test-pack-deltas.c',
   'test-pack-mtimes.c',
diff --git a/t/helper/test-external-notes b/t/helper/test-external-notes
new file mode 100755
index 000000000000..5e9dde3977ab
--- /dev/null
+++ b/t/helper/test-external-notes
@@ -0,0 +1,64 @@
+#!/bin/sh
+
+prefix=${TEST_EXTERNAL_NOTES_PREFIX:-external-notes}
+response=${TEST_EXTERNAL_NOTES_RESPONSE:-ok}
+line_ending=${TEST_EXTERNAL_NOTES_LINE_ENDING:-lf}
+exit_after_response=${TEST_EXTERNAL_NOTES_EXIT_AFTER_RESPONSE:-}
+exit_delay=${TEST_EXTERNAL_NOTES_EXIT_DELAY:-}
+delay=${TEST_EXTERNAL_NOTES_DELAY:-}
+char_delay=${TEST_EXTERNAL_NOTES_CHAR_DELAY:-}
+ignore_term=${TEST_EXTERNAL_NOTES_IGNORE_TERM:-}
+
+newline='\n'
+case "$line_ending" in
+crlf)
+	newline='\r\n'
+	;;
+none)
+	newline=
+	;;
+esac
+
+echo start >>"$prefix-starts"
+
+test "$ignore_term" = true && trap '' TERM
+
+emit_output() {
+	if test -n "$char_delay"
+	then
+		LC_ALL=C
+		payload=$(printf "$@"; printf .)
+		payload=${payload%.}
+
+		while test -n "$payload"
+		do
+			char=${payload%"${payload#?}"}
+			printf '%s' "$char" || return 1
+			payload=${payload#?}
+			sleep "$char_delay" || return 1
+		done
+	else
+		printf "$@"
+	fi
+}
+
+while IFS= read -r commit; do
+	if test "${TEST_EXTERNAL_NOTES_BODY+x}" = x
+	then
+		note=$TEST_EXTERNAL_NOTES_BODY
+	else
+		note=$commit
+	fi
+	printf "%s\n" "$commit" >>"$prefix-requests"
+	test -z "$delay" || sleep "$delay"
+	if test "$response" = missing
+	then
+		emit_output "%s missing%b" "$commit" "$newline"
+	else
+		emit_output "%s ok %d%b%s%b" \
+			"$commit" "${#note}" "$newline" "$note" "$newline"
+	fi
+	test "$exit_after_response" = true && break
+done
+
+test -z "$exit_delay" || sleep "$exit_delay"
diff --git a/t/helper/test-notes-external-config-reset.c b/t/helper/test-notes-external-config-reset.c
new file mode 100644
index 000000000000..a64d03346fb9
--- /dev/null
+++ b/t/helper/test-notes-external-config-reset.c
@@ -0,0 +1,24 @@
+#include "test-tool.h"
+#include "notes-external.h"
+
+int cmd__notes_external_config_reset(int argc, const char **argv UNUSED)
+{
+	struct external_notes_state *state;
+
+	if (argc != 1)
+		die("usage: test-tool notes-external-config-reset");
+
+	state = external_notes_new();
+	set_external_notes_command(state, "helper");
+	set_external_notes_command_name(state, "label");
+	set_external_notes_command_timeout_ms(state, 250);
+	set_external_notes_for_grep(state, 1);
+	external_notes_reset(state);
+
+	printf("configured=%d\n", external_notes_command_configured(state));
+	printf("name=%s\n", external_notes_command_name(state));
+	printf("timeout_ms=%d\n", external_notes_command_timeout_ms(state));
+	printf("grep=%d\n", external_notes_for_grep_enabled(state));
+	external_notes_free(state);
+	return 0;
+}
diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c
index b71a22b43bbc..b4de5a2f5c06 100644
--- a/t/helper/test-tool.c
+++ b/t/helper/test-tool.c
@@ -45,6 +45,7 @@ static struct test_cmd cmds[] = {
 	{ "mergesort", cmd__mergesort },
 	{ "mktemp", cmd__mktemp },
 	{ "name-hash", cmd__name_hash },
+	{ "notes-external-config-reset", cmd__notes_external_config_reset },
 	{ "online-cpus", cmd__online_cpus },
 	{ "pack-deltas", cmd__pack_deltas },
 	{ "pack-mtimes", cmd__pack_mtimes },
diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h
index f2885b33d58a..e74d4d934b14 100644
--- a/t/helper/test-tool.h
+++ b/t/helper/test-tool.h
@@ -38,6 +38,7 @@ int cmd__match_trees(int argc, const char **argv);
 int cmd__mergesort(int argc, const char **argv);
 int cmd__mktemp(int argc, const char **argv);
 int cmd__name_hash(int argc, const char **argv);
+int cmd__notes_external_config_reset(int argc, const char **argv);
 int cmd__online_cpus(int argc, const char **argv);
 int cmd__pack_deltas(int argc, const char **argv);
 int cmd__pack_mtimes(int argc, const char **argv);
diff --git a/t/lib-notes.sh b/t/lib-notes.sh
new file mode 100644
index 000000000000..07422540d58f
--- /dev/null
+++ b/t/lib-notes.sh
@@ -0,0 +1,19 @@
+# Helpers for scripts testing notes behavior.
+
+# notes.externalCommand is run through a shell, so quote the path.
+external_notes_command=$(
+	printf "%s\n" "$TEST_DIRECTORY/helper/test-external-notes" |
+	sed "s/'/'\\\\''/g; s/^/'/; s/$/'/"
+)
+
+# The helper above is a shell script. Few Windows CI tests (3 out of 10
+# in matrix) are spending more than the production default timeout just
+# starting the shell and exchanging the first response, so tests that
+# are not about timeout behavior fail. So let us opt into a wider 1s
+# deadline for Windows instead of 100ms.
+external_notes_command_timeout_config=
+if test_have_prereq MINGW
+then
+	_timeout_config="notes.externalCommandTimeoutMs=1000"
+	external_notes_command_timeout_config="-c $_timeout_config"
+fi
diff --git a/t/t3206-range-diff.sh b/t/t3206-range-diff.sh
index 1e812df806bb..96adeb9bc4fe 100755
--- a/t/t3206-range-diff.sh
+++ b/t/t3206-range-diff.sh
@@ -6,6 +6,7 @@ GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
 export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
 
 . ./test-lib.sh
+. "$TEST_DIRECTORY"/lib-notes.sh
 
 # Note that because of the range-diff's heuristics, test_commit does more
 # harm than good.  We need some real history.
@@ -690,6 +691,37 @@ test_expect_success 'range-diff with --notes=custom does not show default notes'
 	grep "## Notes (custom) ##" actual
 '
 
+test_expect_success 'range-diff with --external-notes' '
+	topic_oid=$(git rev-parse topic) &&
+	unmodified_oid=$(git rev-parse unmodified) &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		range-diff --no-color --external-notes \
+		main..topic main..unmodified >actual &&
+	test_grep "## Notes (external) ##" actual &&
+	test_grep "^    -    $topic_oid$" actual &&
+	test_grep "^    +    $unmodified_oid$" actual &&
+	! grep "## Notes ##" actual
+'
+
+test_expect_success 'range-diff with disabled external notes' '
+	test_when_finished "git notes remove topic unmodified || :" &&
+	git notes add -m "topic note" topic &&
+	git notes add -m "unmodified note" unmodified &&
+	TEST_EXTERNAL_NOTES_PREFIX=range-diff-external-notes \
+		git -c notes.externalCommand="$external_notes_command" \
+		range-diff --no-color --external-notes --no-external-notes \
+		main..topic main..unmodified >actual &&
+	cat >expect <<-EOF &&
+	1:  $(test_oid t1) = 1:  $(test_oid u1) s/5/A/
+	2:  $(test_oid t2) = 2:  $(test_oid u2) s/4/A/
+	3:  $(test_oid t3) = 3:  $(test_oid u3) s/11/B/
+	4:  $(test_oid t4) = 4:  $(test_oid u4) s/12/B/
+	EOF
+	test_cmp expect actual &&
+	test_path_is_missing range-diff-external-notes-starts
+'
+
 test_expect_success 'format-patch --range-diff does not compare notes by default' '
 	test_when_finished "git notes remove topic unmodified || :" &&
 	git notes add -m "topic note" topic &&
@@ -780,6 +812,42 @@ test_expect_success 'format-patch --range-diff with --notes' '
 	test_cmp expect actual
 '
 
+test_expect_success 'format-patch --range-diff with --external-notes' '
+	topic_oid=$(git rev-parse topic) &&
+	unmodified_oid=$(git rev-parse unmodified) &&
+	test_when_finished "rm -f 000?-*" &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		format-patch --external-notes --cover-letter --range-diff=$prev \
+		main..unmodified >actual &&
+	test_line_count = 5 actual &&
+	test_grep "^Range-diff:$" 0000-* &&
+	test_grep "## Notes (external) ##" 0000-* &&
+	test_grep "^    -    $topic_oid$" 0000-* &&
+	test_grep "^    +    $unmodified_oid$" 0000-* &&
+	! grep "## Notes ##" 0000-*
+'
+
+test_expect_success 'format-patch --range-diff with disabled external notes' '
+	test_when_finished "git notes remove topic unmodified || :" &&
+	git notes add -m "topic note" topic &&
+	git notes add -m "unmodified note" unmodified &&
+	test_when_finished "rm -f 000?-*" &&
+	TEST_EXTERNAL_NOTES_PREFIX=range-diff-external-notes \
+		git -c notes.externalCommand="$external_notes_command" \
+		format-patch --external-notes --no-external-notes \
+		--cover-letter --range-diff=$prev main..unmodified >actual &&
+	test_line_count = 5 actual &&
+	test_grep "^Range-diff:$" 0000-* &&
+	grep "= 1: .* s/5/A" 0000-* &&
+	grep "= 2: .* s/4/A" 0000-* &&
+	grep "= 3: .* s/11/B" 0000-* &&
+	grep "= 4: .* s/12/B" 0000-* &&
+	! grep "Notes" 0000-* &&
+	! grep "note" 0000-* &&
+	test_path_is_missing range-diff-external-notes-starts
+'
+
 test_expect_success 'format-patch --range-diff with format.notes config' '
 	test_when_finished "git notes remove topic unmodified || :" &&
 	git notes add -m "topic note" topic &&
diff --git a/t/t3301-notes.sh b/t/t3301-notes.sh
index 27439010dfbc..7fd82767c1f1 100755
--- a/t/t3301-notes.sh
+++ b/t/t3301-notes.sh
@@ -6,6 +6,7 @@
 test_description='Test commit notes'
 
 . ./test-lib.sh
+. "$TEST_DIRECTORY"/lib-notes.sh
 
 write_script fake_editor <<\EOF
 echo "$MSG" >"$1"
@@ -16,6 +17,11 @@ export GIT_EDITOR
 
 indent="    "
 
+run_with_limited_time () (
+	{ set +x; } 2>/dev/null
+	"$PERL_PATH" -e 'alarm shift; exec @ARGV' -- "$@"
+)
+
 test_expect_success 'cannot annotate non-existing HEAD' '
 	test_must_fail env MSG=3 git notes add
 '
@@ -909,6 +915,424 @@ test_expect_success 'displayed notes are used for grep matching' '
 	test_must_be_empty actual
 '
 
+test_expect_success 'notes.externalCommand shows external notes from protected config' '
+	commit=$(git rev-parse HEAD) &&
+	parent=$(git rev-parse HEAD^) &&
+	rm -f external-notes-starts external-notes-requests &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log -2 >actual &&
+	test_line_count = 1 external-notes-starts &&
+	{
+		printf "%s\n" "$commit" &&
+		printf "%s\n" "$parent"
+	} >expect-requests &&
+	test_cmp expect-requests external-notes-requests &&
+	test_grep "Notes (external):" actual &&
+	test_grep "^    $commit$" actual &&
+	test_grep "^    $parent$" actual
+'
+
+test_expect_success PERL,EXECKEEPSPID 'notes.externalCommand terminates helper during exit cleanup' '
+	commit=$(git rev-parse HEAD) &&
+	test_env TEST_EXTERNAL_NOTES_EXIT_DELAY=10 \
+		run_with_limited_time 2 \
+		git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log --external-notes -1 >actual &&
+	test_grep "^Notes (external):$" actual &&
+	test_grep "^    $commit$" actual
+'
+
+test_expect_success 'notes.externalCommandName labels external notes' '
+	commit=$(git rev-parse HEAD) &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		-c notes.externalCommandName=commit-id log -1 >actual &&
+	test_grep "Notes (commit-id):" actual &&
+	test_grep "^    $commit$" actual
+'
+
+test_expect_success 'notes.externalCommandName is rendered literally' '
+	commit=$(git rev-parse HEAD) &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		-c notes.externalCommandName=refs/notes/commits \
+		log --external-notes -1 >actual &&
+	test_grep "^Notes (refs/notes/commits):$" actual &&
+	! grep "^Notes:$" actual &&
+	test_grep "^    $commit$" actual
+'
+
+test_expect_success 'notes.externalCommandTimeoutMs rejects negative values' '
+	test_must_fail git -c notes.externalCommand="$external_notes_command" \
+		-c notes.externalCommandTimeoutMs=-1 log -1 2>err &&
+	test_grep "notes.externalCommandTimeoutMs must be non-negative" err
+'
+
+test_expect_success 'notes.externalCommandTimeoutMs times out delayed response' '
+	git log -1 >expect &&
+	test_env TEST_EXTERNAL_NOTES_DELAY=1 \
+		git -c notes.externalCommand="$external_notes_command" \
+		-c notes.externalCommandTimeoutMs=1 \
+		log -1 >actual 2>err &&
+	test_cmp expect actual &&
+	test_grep "notes.externalCommand failed" err &&
+	test_line_count = 1 err
+'
+
+test_expect_success 'notes.externalCommandTimeoutMs applies to whole response' '
+	git log -1 >expect &&
+	test_env TEST_EXTERNAL_NOTES_BODY=x \
+		 TEST_EXTERNAL_NOTES_CHAR_DELAY=0.02 \
+		git -c notes.externalCommand="$external_notes_command" \
+		-c notes.externalCommandTimeoutMs=50 \
+		log -1 >actual 2>err &&
+	test_cmp expect actual &&
+	test_grep "notes.externalCommand failed" err &&
+	test_line_count = 1 err
+'
+
+test_expect_success PERL,EXECKEEPSPID 'notes.externalCommandTimeoutMs terminates timed-out helper' '
+	git log -1 >expect &&
+	test_env TEST_EXTERNAL_NOTES_DELAY=10 \
+		run_with_limited_time 2 \
+		git -c notes.externalCommand="$external_notes_command" \
+		-c notes.externalCommandTimeoutMs=1 \
+		log -1 >actual 2>err &&
+	test_cmp expect actual &&
+	test_grep "notes.externalCommand failed" err &&
+	test_line_count = 1 err
+'
+
+test_expect_success 'notes.externalCommandTimeoutMs=0 disables timeout' '
+	commit=$(git rev-parse HEAD) &&
+	test_env TEST_EXTERNAL_NOTES_DELAY=1 \
+		git -c notes.externalCommand="$external_notes_command" \
+		-c notes.externalCommandTimeoutMs=0 \
+		log --external-notes -1 >actual &&
+	test_grep "^Notes (external):$" actual &&
+	test_grep "^    $commit$" actual
+'
+
+test_expect_success 'notes.externalCommand handles CRLF note bodies' '
+	body=$(printf "A\r\nB") &&
+	test_env TEST_EXTERNAL_NOTES_BODY="$body" \
+		git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log --external-notes -1 >actual &&
+	test_grep "^Notes (external):$" actual &&
+	test_grep "^    B$" actual
+'
+
+test_expect_success 'notes.externalCommand accepts CRLF missing response' '
+	git log -1 >expect &&
+	test_env TEST_EXTERNAL_NOTES_RESPONSE=missing \
+		 TEST_EXTERNAL_NOTES_LINE_ENDING=crlf \
+		git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log -1 >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'notes.externalCommand rejects unterminated missing response' '
+	git log -1 >expect &&
+	test_env TEST_EXTERNAL_NOTES_RESPONSE=missing \
+		 TEST_EXTERNAL_NOTES_LINE_ENDING=none \
+		 TEST_EXTERNAL_NOTES_EXIT_AFTER_RESPONSE=true \
+		git -c notes.externalCommand="$external_notes_command" \
+		log -1 >actual 2>err &&
+	test_cmp expect actual &&
+	test_grep "notes.externalCommand failed" err &&
+	test_line_count = 1 err
+'
+
+test_expect_success PERL,EXECKEEPSPID 'notes.externalCommand rejects unterminated live response without deadlock' '
+	git log -1 >expect &&
+	test_env TEST_EXTERNAL_NOTES_RESPONSE=missing \
+		 TEST_EXTERNAL_NOTES_LINE_ENDING=none \
+		run_with_limited_time 2 \
+		git -c notes.externalCommand="$external_notes_command" \
+		log -1 >actual 2>err &&
+	test_cmp expect actual &&
+	test_grep "notes.externalCommand failed" err &&
+	test_line_count = 1 err
+'
+
+test_expect_success 'notes.externalCommand accepts CRLF protocol lines' '
+	commit=$(git rev-parse HEAD) &&
+	test_env TEST_EXTERNAL_NOTES_LINE_ENDING=crlf \
+		git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log --external-notes -1 >actual &&
+	test_grep "^Notes (external):$" actual &&
+	test_grep "^    $commit$" actual
+'
+
+test_expect_success 'notes.externalCommand missing response shows no external notes' '
+	write_script external-notes-missing <<-\EOF &&
+	while IFS= read -r commit
+	do
+		printf "%s missing\n" "$commit"
+	done
+	EOF
+	git log -1 >expect &&
+	git -c notes.externalCommand=./external-notes-missing log -1 >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'notes.externalCommand empty note shows no external notes' '
+	write_script external-notes-empty <<-\EOF &&
+	while IFS= read -r commit
+	do
+		printf "%s ok 0\n\n" "$commit"
+	done
+	EOF
+	git log -1 >expect &&
+	git -c notes.externalCommand=./external-notes-empty log -1 >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'notes.externalCommand rejects invalid note lengths' '
+	write_script external-notes-invalid-length <<-\EOF &&
+	while IFS= read -r commit
+	do
+		printf "%s ok %s\n" "$commit" "$1"
+	done
+	EOF
+	git log -2 >expect &&
+	for bad_length in -1 +1 1x x
+	do
+		git -c notes.externalCommand="./external-notes-invalid-length $bad_length" \
+			log -2 >actual 2>err &&
+		test_cmp expect actual &&
+		test_grep "notes.externalCommand failed" err &&
+		test_line_count = 1 err || return 1
+	done
+'
+
+test_expect_success 'notes.externalCommand is suppressed by --no-notes' '
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" log --no-notes -1 >actual &&
+	test_path_is_missing external-notes-starts &&
+	! grep "Notes (external):" actual
+'
+
+test_expect_success 'notes.externalCommand is suppressed by --no-external-notes' '
+	rm -f external-notes-starts &&
+	git log -1 >expect &&
+	git -c notes.externalCommand="$external_notes_command" \
+		log --no-external-notes -1 >actual &&
+	test_cmp expect actual &&
+	test_path_is_missing external-notes-starts
+'
+
+test_expect_success 'notes.externalCommand combines with explicit notes ref' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log --notes=other --external-notes -1 >actual &&
+	test_line_count = 1 external-notes-starts &&
+	test_grep "Notes (other):" actual &&
+	test_grep "^    other note$" actual &&
+	test_grep "Notes (external):" actual &&
+	test_grep "^    $commit$" actual &&
+	! grep "^    order test$" actual
+'
+
+test_expect_success '--show-notes=ref remains additive after --external-notes' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log --external-notes --show-notes=other -1 >actual &&
+	test_line_count = 1 external-notes-starts &&
+	test_grep "^Notes:$" actual &&
+	test_grep "^    order test$" actual &&
+	test_grep "^Notes (other):$" actual &&
+	test_grep "^    other note$" actual &&
+	test_grep "^Notes (external):$" actual &&
+	test_grep "^    $commit$" actual
+'
+
+test_expect_success 'notes.externalCommand can be enabled without default notes refs' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log --external-notes -1 >actual &&
+	test_line_count = 1 external-notes-starts &&
+	test_grep "Notes (external):" actual &&
+	test_grep "^    $commit$" actual &&
+	! grep "^    order test$" actual &&
+	! grep "^    other note$" actual
+'
+
+test_expect_success 'notes.externalCommand combines with default notes refs' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log --external-notes --notes -1 >actual &&
+	test_line_count = 1 external-notes-starts &&
+	test_grep "Notes:" actual &&
+	test_grep "^    order test$" actual &&
+	test_grep "Notes (external):" actual &&
+	test_grep "^    $commit$" actual &&
+	! grep "^    other note$" actual
+'
+
+test_expect_success 'notes.externalCommand obeys last --external-notes option' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git log --no-notes -1 >expect &&
+	git -c notes.externalCommand="$external_notes_command" \
+		log --external-notes --no-external-notes -1 >actual &&
+	test_cmp expect actual &&
+	test_path_is_missing external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log --notes=other --no-external-notes --external-notes -1 >actual &&
+	test_line_count = 1 external-notes-starts &&
+	test_grep "Notes (other):" actual &&
+	test_grep "^    other note$" actual &&
+	test_grep "Notes (external):" actual &&
+	test_grep "^    $commit$" actual &&
+	! grep "^    order test$" actual
+'
+
+test_expect_success 'notes.externalCommand honors raw notes formatting' '
+	commit=$(git rev-parse HEAD) &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		show -s --format=%N >actual &&
+	test_grep "^$commit$" actual &&
+	! grep "Notes (external):" actual
+'
+
+test_expect_success 'format-patch --external-notes includes external notes only' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		format-patch --external-notes -1 --stdout >actual &&
+	test_line_count = 1 external-notes-starts &&
+	test_grep "^Notes (external):" actual &&
+	test_grep "^    $commit$" actual &&
+	! grep "^    order test$" actual
+'
+
+test_expect_success 'notes.externalCommand is not used for grep matching' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		log --grep="$commit" >actual &&
+	test_must_be_empty actual &&
+	test_path_is_missing external-notes-starts
+'
+
+test_expect_success 'notes.externalCommandForGrep includes external notes in grep matching' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		-c notes.externalCommandForGrep=true \
+		log --grep="$commit" -1 >actual &&
+	test_line_count = 1 external-notes-starts &&
+	test_grep "Notes (external):" actual
+'
+
+test_expect_success 'notes.externalCommandForGrep does not search hidden notes' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		-c notes.externalCommandForGrep=true \
+		log --oneline --grep="$commit" -1 >actual &&
+	test_must_be_empty actual &&
+	test_path_is_missing external-notes-starts
+'
+
+test_expect_success 'notes.externalCommandForGrep honors --no-external-notes' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		-c notes.externalCommandForGrep=true \
+		log --no-external-notes --grep="$commit" -1 >actual &&
+	test_must_be_empty actual &&
+	test_path_is_missing external-notes-starts
+'
+
+test_expect_success 'notes.externalCommandForGrep combines with explicit notes ref' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		-c notes.externalCommandForGrep=true \
+		log --notes=other --external-notes --grep="$commit" -1 >actual &&
+	test_line_count = 1 external-notes-starts &&
+	test_grep "Notes (external):" actual &&
+	test_grep "Notes (other):" actual &&
+	! grep "^    order test$" actual
+'
+
+test_expect_success 'notes.externalCommandForGrep is ignored from local config' '
+	commit=$(git rev-parse HEAD) &&
+	rm -f external-notes-starts &&
+	test_config notes.externalCommandForGrep true &&
+	git -c notes.externalCommand="$external_notes_command" \
+		log --grep="$commit" >actual &&
+	test_must_be_empty actual &&
+	test_path_is_missing external-notes-starts
+'
+
+test_expect_success 'notes.externalCommand is not used with explicit notes ref' '
+	rm -f external-notes-starts &&
+	git -c notes.externalCommand="$external_notes_command" log --notes=other -1 >actual &&
+	test_path_is_missing external-notes-starts &&
+	! grep "Notes (external):" actual
+'
+
+test_expect_success 'notes.externalCommand is ignored from local config' '
+	rm -f external-notes-starts &&
+	test_config notes.externalCommand "$external_notes_command" &&
+	git log -1 >actual &&
+	test_path_is_missing external-notes-starts &&
+	! grep "Notes (external):" actual
+'
+
+test_expect_success 'notes.externalCommandName is ignored from local config' '
+	test_config notes.externalCommandName local &&
+	git -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		log -1 >actual &&
+	test_grep "Notes (external):" actual &&
+	! grep "Notes (local):" actual
+'
+
+test_expect_success 'external_notes_reset clears cached helper config' '
+	test-tool notes-external-config-reset >actual &&
+	cat >expect <<-\EOF &&
+	configured=0
+	name=external
+	timeout_ms=100
+	grep=0
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success 'notes.externalCommand warning is shown once' '
+	write_script external-notes-fail <<-\EOF &&
+	while IFS= read -r commit
+	do
+		printf "%s-mismatch missing\n" "$commit"
+	done
+	EOF
+	git -c notes.externalCommand=./external-notes-fail log -2 >actual 2>err &&
+	test_grep "notes.externalCommand failed" err &&
+	test_line_count = 1 err
+'
+
 test_expect_success 'Allow notes on non-commits (trees, blobs, tags)' '
 	test_config core.notesRef refs/notes/other &&
 	echo "Note on a tree" >expect &&
diff --git a/t/t6120-describe.sh b/t/t6120-describe.sh
index 8ee3d2c37d02..abbdb42dc9f7 100755
--- a/t/t6120-describe.sh
+++ b/t/t6120-describe.sh
@@ -15,6 +15,7 @@ GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
 export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
 
 . ./test-lib.sh
+. "$TEST_DIRECTORY"/lib-notes.sh
 
 check_describe () {
 	indir= &&
@@ -867,6 +868,22 @@ test_expect_success 'format-rev with %N (note)' '
 	test_cmp expect actual
 '
 
+test_expect_success 'format-rev with %N uses external notes' '
+	commit=$(git -C repo-format rev-parse HEAD) &&
+	rm -f repo-format/format-rev-external-notes-starts \
+		repo-format/format-rev-external-notes-requests &&
+	printf "%s\n" "$commit" >input &&
+	printf "%s\n\n" "$commit" >expect &&
+	TEST_EXTERNAL_NOTES_PREFIX=format-rev-external-notes \
+	git -C repo-format -c notes.externalCommand="$external_notes_command" \
+		$external_notes_command_timeout_config \
+		format-rev --stdin-mode=text --format="tformat:%N" \
+		<input >actual &&
+	test_line_count = 1 repo-format/format-rev-external-notes-starts &&
+	test_cmp input repo-format/format-rev-external-notes-requests &&
+	test_cmp expect actual
+'
+
 test_expect_success 'format-rev --notes<ref> (custom notes ref)' '
 	# One custom notes ref
 	test_when_finished "git -C repo-format notes remove" &&
-- 
2.53.0


^ permalink raw reply related

* [PATCH v2 3/4] t3301: cover generic displayed notes behavior
From: Siddh Raman Pant @ 2026-05-22 16:09 UTC (permalink / raw)
  To: git
  Cc: Elijah Newren, Patrick Steinhardt, Kristoffer Haugsbakk,
	Junio C Hamano, brian m. carlson, Jeff King, Johannes Sixt,
	Oswald Buddenhagen
In-Reply-To: <cover.1779464886.git.siddh.raman.pant@oracle.com>

Displayed notes already participate in common log behavior.
Add explicit coverage for raw notes formatting, --no-notes
suppression, explicit notes refs, and --grep matching before
teaching external notes to feed the same display path.

Assisted-by: Codex:gpt-5.5-xhigh-fast
Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>
---
 t/t3301-notes.sh | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/t/t3301-notes.sh b/t/t3301-notes.sh
index d6c50460d086..27439010dfbc 100755
--- a/t/t3301-notes.sh
+++ b/t/t3301-notes.sh
@@ -885,6 +885,30 @@ test_expect_success '--show-notes=ref accumulates' '
 	test_cmp expect-both-reversed actual
 '
 
+test_expect_success 'displayed notes honor raw notes formatting' '
+	git show -s --format=%N >actual &&
+	test_grep "^order test$" actual &&
+	! grep "Notes" actual
+'
+
+test_expect_success 'displayed notes are suppressed by --no-notes' '
+	git log --no-notes -1 >actual &&
+	test_cmp expect-not-other actual
+'
+
+test_expect_success 'explicit notes ref replaces default displayed notes' '
+	git log --notes=other -1 >actual &&
+	test_cmp expect-other actual
+'
+
+test_expect_success 'displayed notes are used for grep matching' '
+	commit=$(git rev-parse HEAD) &&
+	git log --grep="order test" -1 >actual &&
+	test_grep "^commit $commit$" actual &&
+	git log --no-notes --grep="order test" -1 >actual &&
+	test_must_be_empty actual
+'
+
 test_expect_success 'Allow notes on non-commits (trees, blobs, tags)' '
 	test_config core.notesRef refs/notes/other &&
 	echo "Note on a tree" >expect &&
-- 
2.53.0


^ permalink raw reply related

* [PATCH v2 2/4] wrapper: add support for timeout and deadline in read helpers
From: Siddh Raman Pant @ 2026-05-22 16:09 UTC (permalink / raw)
  To: git
  Cc: Elijah Newren, Patrick Steinhardt, Kristoffer Haugsbakk,
	Junio C Hamano, brian m. carlson, Jeff King, Johannes Sixt,
	Oswald Buddenhagen
In-Reply-To: <cover.1779464886.git.siddh.raman.pant@oracle.com>

Add read helpers which allow a caller to enforce a timeout per read,
and a deadline for the read in case multiple reads have to be done
under a common timeout.

Assisted-by: Codex:gpt-5.5-xhigh-fast
Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>
---
 strbuf.c  |  26 +++++++++-
 strbuf.h  |   4 ++
 wrapper.c | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++----
 wrapper.h |  23 +++++++++
 4 files changed, 182 insertions(+), 10 deletions(-)

diff --git a/strbuf.c b/strbuf.c
index 3e04addc22fe..b3fc7c624aa2 100644
--- a/strbuf.c
+++ b/strbuf.c
@@ -749,13 +749,15 @@ int strbuf_getline_nul(struct strbuf *sb, FILE *fp)
 	return strbuf_getdelim(sb, fp, '\0');
 }
 
-int strbuf_getwholeline_fd(struct strbuf *sb, int fd, int term)
+static int strbuf_getwholeline_fd_with(struct strbuf *sb, int fd, int term,
+				       xread_cb_t xread_cb,
+				       void *cb_data)
 {
 	strbuf_reset(sb);
 
 	while (1) {
 		char ch;
-		ssize_t len = xread(fd, &ch, 1);
+		ssize_t len = xread_cb(fd, &ch, 1, cb_data);
 		if (len <= 0)
 			return EOF;
 		strbuf_addch(sb, ch);
@@ -765,6 +767,26 @@ int strbuf_getwholeline_fd(struct strbuf *sb, int fd, int term)
 	return 0;
 }
 
+int strbuf_getwholeline_fd_deadline(struct strbuf *sb, int fd, int term,
+				    uint64_t deadline_ns)
+{
+	return strbuf_getwholeline_fd_with(sb, fd, term, xread_deadline_fn,
+					   &deadline_ns);
+}
+
+int strbuf_getwholeline_fd_timeout(struct strbuf *sb, int fd, int term,
+				   int timeout_ms)
+{
+	return strbuf_getwholeline_fd_with(sb, fd, term, xread_timeout_fn,
+					   &timeout_ms);
+}
+
+/* Non-timeout version for compatibility. */
+int strbuf_getwholeline_fd(struct strbuf *sb, int fd, int term)
+{
+	return strbuf_getwholeline_fd_timeout(sb, fd, term, 0);
+}
+
 ssize_t strbuf_read_file(struct strbuf *sb, const char *path, size_t hint)
 {
 	int fd;
diff --git a/strbuf.h b/strbuf.h
index 06e284f9cca4..f896da1277a6 100644
--- a/strbuf.h
+++ b/strbuf.h
@@ -535,6 +535,10 @@ int strbuf_appendwholeline(struct strbuf *sb, FILE *file, int term);
  * descriptor.
  */
 int strbuf_getwholeline_fd(struct strbuf *sb, int fd, int term);
+int strbuf_getwholeline_fd_timeout(struct strbuf *sb, int fd, int term,
+				   int timeout_ms);
+int strbuf_getwholeline_fd_deadline(struct strbuf *sb, int fd, int term,
+				    uint64_t deadline_ns);
 
 /**
  * Set the buffer to the path of the current working directory.
diff --git a/wrapper.c b/wrapper.c
index 16f5a63fbb61..1f42845e031e 100644
--- a/wrapper.c
+++ b/wrapper.c
@@ -9,6 +9,7 @@
 #include "parse.h"
 #include "gettext.h"
 #include "strbuf.h"
+#include "trace.h"
 #include "trace2.h"
 
 #ifdef HAVE_RTLGENRANDOM
@@ -220,28 +221,129 @@ static int handle_nonblock(int fd, short poll_events, int err)
 	return 1;
 }
 
-/*
- * xread() is the same a read(), but it automatically restarts read()
- * operations with a recoverable error (EAGAIN and EINTR). xread()
+static int wait_for_fd(int fd, short poll_events, int timeout_ms)
+{
+	struct pollfd pfd;
+
+	if (timeout_ms < 0) {
+		/* Negative timeout makes no sense. */
+		errno = EINVAL;
+		return -1;
+	}
+
+	pfd.fd = fd;
+	pfd.events = poll_events;
+
+	while(1) {
+		int ret = poll(&pfd, 1, timeout_ms);
+
+		if (ret <= 0) {
+			/* Retry if interrupted. */
+			if (ret < 0 && errno == EINTR)
+				continue;
+
+			/* Set errno if timeout happened. */
+			if (ret == 0)
+				errno = ETIMEDOUT;
+
+			return -1;
+		}
+
+		/* Invalid FD passed. */
+		if (pfd.revents & POLLNVAL) {
+			errno = EBADF;
+			return -1;
+		}
+
+		/* Some error happened. */
+		if (pfd.revents & POLLERR) {
+			errno = EIO;
+			return -1;
+		}
+
+		/* HangUp => We are ready to consume output till EOF. */
+		if (pfd.revents & (poll_events | POLLHUP))
+			return 0;
+	}
+}
+
+/**
+ * xread_timeout() is the same as read(), but it automatically restarts read()
+ * operations with a recoverable error (EAGAIN and EINTR). xread_timeout()
  * DOES NOT GUARANTEE that "len" bytes is read even if the data is available.
+ *
+ * Fails with ETIMEDOUT when no bytes become available within timeout_ms
+ * milliseconds. A zero timeout disables timeout handling, so reads can
+ * block until the file descriptor is readable. Negative timeouts are invalid.
  */
-ssize_t xread(int fd, void *buf, size_t len)
+ssize_t xread_timeout(int fd, void *buf, size_t len, int timeout_ms)
 {
 	ssize_t nr;
+
 	if (len > MAX_IO_SIZE)
 		len = MAX_IO_SIZE;
+
 	while (1) {
+		if (timeout_ms && wait_for_fd(fd, POLLIN, timeout_ms))
+			return -1;
+
 		nr = read(fd, buf, len);
+
 		if (nr < 0) {
 			if (errno == EINTR)
 				continue;
-			if (handle_nonblock(fd, POLLIN, errno))
-				continue;
+
+			if (timeout_ms) {
+				if (errno == EAGAIN || errno == EWOULDBLOCK)
+					continue;
+			} else {
+				if (handle_nonblock(fd, POLLIN, errno))
+					continue;
+			}
 		}
+
 		return nr;
 	}
 }
 
+/* Non-timeout version for compatibility. */
+ssize_t xread(int fd, void *buf, size_t len)
+{
+	return xread_timeout(fd, buf, len, 0);
+}
+
+static int remaining_timeout_ms(uint64_t deadline_ns)
+{
+	uint64_t now, remaining_ns;
+
+	if (!deadline_ns)
+		return 0;
+
+	now = getnanotime();
+	if (now >= deadline_ns) {
+		errno = ETIMEDOUT;
+		return -1;
+	}
+
+	remaining_ns = deadline_ns - now;
+	return (int)((remaining_ns + 999999ULL) / 1000000ULL);
+}
+
+/* (deadline_ns = 0) disables the deadline and short-circuits to xread(). */
+ssize_t xread_deadline(int fd, void *buf, size_t len, uint64_t deadline_ns)
+{
+	int timeout_ms;
+
+	if (deadline_ns == 0)
+		return xread(fd, buf, len);
+
+	timeout_ms = remaining_timeout_ms(deadline_ns);
+	if (timeout_ms < 0)
+		return -1;
+
+	return xread_timeout(fd, buf, len, timeout_ms);
+}
+
 /*
  * xwrite() is the same a write(), but it automatically restarts write()
  * operations with a recoverable error (EAGAIN and EINTR). xwrite() DOES NOT
@@ -283,13 +385,15 @@ ssize_t xpread(int fd, void *buf, size_t len, off_t offset)
 	}
 }
 
-ssize_t read_in_full(int fd, void *buf, size_t count)
+static ssize_t read_in_full_with(int fd, void *buf, size_t count,
+				 xread_cb_t xread_cb,
+				 void *cb_data)
 {
 	char *p = buf;
 	ssize_t total = 0;
 
 	while (count > 0) {
-		ssize_t loaded = xread(fd, p, count);
+		ssize_t loaded = xread_cb(fd, p, count, cb_data);
 		if (loaded < 0)
 			return -1;
 		if (loaded == 0)
@@ -302,6 +406,25 @@ ssize_t read_in_full(int fd, void *buf, size_t count)
 	return total;
 }
 
+ssize_t read_in_full_deadline(int fd, void *buf, size_t count,
+			      uint64_t deadline_ns)
+{
+	return read_in_full_with(fd, buf, count, xread_deadline_fn,
+				 &deadline_ns);
+}
+
+ssize_t read_in_full_timeout(int fd, void *buf, size_t count, int timeout_ms)
+{
+	return read_in_full_with(fd, buf, count, xread_timeout_fn,
+				 &timeout_ms);
+}
+
+/* Non-timeout version for compatibility. */
+ssize_t read_in_full(int fd, void *buf, size_t count)
+{
+	return read_in_full_timeout(fd, buf, count, 0);
+}
+
 ssize_t write_in_full(int fd, const void *buf, size_t count)
 {
 	const char *p = buf;
diff --git a/wrapper.h b/wrapper.h
index 15ac3bab6e97..10d85c467b86 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -15,6 +15,8 @@ const char *mmap_os_err(void);
 void *xmmap_gently(void *start, size_t length, int prot, int flags, int fd, off_t offset);
 int xopen(const char *path, int flags, ...);
 ssize_t xread(int fd, void *buf, size_t len);
+ssize_t xread_timeout(int fd, void *buf, size_t len, int timeout_ms);
+ssize_t xread_deadline(int fd, void *buf, size_t len, uint64_t deadline_ns);
 ssize_t xwrite(int fd, const void *buf, size_t len);
 ssize_t xpread(int fd, void *buf, size_t len, off_t offset);
 int xdup(int fd);
@@ -44,9 +46,30 @@ int git_mkstemps_mode(char *pattern, int suffix_len, int mode);
 int git_mkstemp_mode(char *pattern, int mode);
 
 ssize_t read_in_full(int fd, void *buf, size_t count);
+ssize_t read_in_full_timeout(int fd, void *buf, size_t count, int timeout_ms);
+ssize_t read_in_full_deadline(int fd, void *buf, size_t count,
+			      uint64_t deadline_ns);
 ssize_t write_in_full(int fd, const void *buf, size_t count);
 ssize_t pread_in_full(int fd, void *buf, size_t count, off_t offset);
 
+typedef ssize_t xread_cb_t(int fd, void *buf, size_t len, const void *cb_data);
+
+static inline ssize_t xread_timeout_fn(int fd, void *buf, size_t len,
+				       const void *cb_data)
+{
+	const int *timeout_ms = cb_data;
+
+	return xread_timeout(fd, buf, len, *timeout_ms);
+}
+
+static inline ssize_t xread_deadline_fn(int fd, void *buf, size_t len,
+					const void *cb_data)
+{
+	const uint64_t *deadline_ns = cb_data;
+
+	return xread_deadline(fd, buf, len, *deadline_ns);
+}
+
 static inline ssize_t write_str_in_full(int fd, const char *str)
 {
 	return write_in_full(fd, str, strlen(str));
-- 
2.53.0


^ permalink raw reply related

* [PATCH v2 1/4] notes: convert raw arg in format_display_notes() to bool
From: Siddh Raman Pant @ 2026-05-22 16:09 UTC (permalink / raw)
  To: git
  Cc: Elijah Newren, Patrick Steinhardt, Kristoffer Haugsbakk,
	Junio C Hamano, brian m. carlson, Jeff King, Johannes Sixt,
	Oswald Buddenhagen
In-Reply-To: <cover.1779464886.git.siddh.raman.pant@oracle.com>

It's used as a boolean flag, let's not use an int.

Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>
---
 log-tree.c | 3 +--
 notes.c    | 6 +++---
 notes.h    | 2 +-
 revision.c | 2 +-
 4 files changed, 6 insertions(+), 7 deletions(-)

diff --git a/log-tree.c b/log-tree.c
index 7e048701d0c5..4503a42dde6b 100644
--- a/log-tree.c
+++ b/log-tree.c
@@ -854,10 +854,9 @@ void show_log(struct rev_info *opt)
 	}
 
 	if (opt->show_notes) {
-		int raw;
 		struct strbuf notebuf = STRBUF_INIT;
+		bool raw = (opt->commit_format == CMIT_FMT_USERFORMAT);
 
-		raw = (opt->commit_format == CMIT_FMT_USERFORMAT);
 		format_display_notes(&commit->object.oid, &notebuf,
 				     get_log_output_encoding(), raw);
 		ctx.notes_message = strbuf_detach(&notebuf, NULL);
diff --git a/notes.c b/notes.c
index 8f315e2a00d2..201f1df3dc29 100644
--- a/notes.c
+++ b/notes.c
@@ -1273,11 +1273,11 @@ void free_notes(struct notes_tree *t)
  * If the given notes_tree is NULL, the internal/default notes_tree will be
  * used instead.
  *
- * (raw != 0) gives the %N userformat; otherwise, the note message is given
+ * (raw == true) gives the %N userformat; otherwise, the note message is given
  * for human consumption.
  */
 static void format_note(struct notes_tree *t, const struct object_id *object_oid,
-			struct strbuf *sb, const char *output_encoding, int raw)
+			struct strbuf *sb, const char *output_encoding, bool raw)
 {
 	static const char utf8[] = "utf-8";
 	const struct object_id *oid;
@@ -1338,7 +1338,7 @@ static void format_note(struct notes_tree *t, const struct object_id *object_oid
 }
 
 void format_display_notes(const struct object_id *object_oid,
-			  struct strbuf *sb, const char *output_encoding, int raw)
+			  struct strbuf *sb, const char *output_encoding, bool raw)
 {
 	int i;
 	assert(display_notes_trees);
diff --git a/notes.h b/notes.h
index 6dc6d7b26548..f6410b31e1c9 100644
--- a/notes.h
+++ b/notes.h
@@ -313,7 +313,7 @@ void load_display_notes(struct display_notes_opt *opt);
  * You *must* call load_display_notes() before using this function.
  */
 void format_display_notes(const struct object_id *object_oid,
-			  struct strbuf *sb, const char *output_encoding, int raw);
+			  struct strbuf *sb, const char *output_encoding, bool raw);
 
 /*
  * Load the notes tree from each ref listed in 'refs'.  The output is
diff --git a/revision.c b/revision.c
index 599b3a66c369..cd9fcefa0a88 100644
--- a/revision.c
+++ b/revision.c
@@ -4107,7 +4107,7 @@ static int commit_match(struct commit *commit, struct rev_info *opt)
 	if (opt->show_notes) {
 		if (!buf.len)
 			strbuf_addstr(&buf, message);
-		format_display_notes(&commit->object.oid, &buf, encoding, 1);
+		format_display_notes(&commit->object.oid, &buf, encoding, true);
 	}
 
 	/*
-- 
2.53.0


^ permalink raw reply related

* [PATCH v2 0/4] Add support for an external command for fetching notes
From: Siddh Raman Pant @ 2026-05-22 16:09 UTC (permalink / raw)
  To: git
  Cc: Elijah Newren, Patrick Steinhardt, Kristoffer Haugsbakk,
	Junio C Hamano, brian m. carlson, Jeff King, Johannes Sixt,
	Oswald Buddenhagen

v1: https://lore.kernel.org/git/cover.1779207350.git.siddh.raman.pant@oracle.com/

<...insert text from v1 cover here...>

Changes since v1:
- Removed Documentation commit and sent as a standalone patch.
- Removed finish_command_with_timeout addition (and thus sleep_nanosec).
- Squashed the external notes command code, doc, and test commits.
- Removed horizontal separators from note-external.c.
- Removed global variables from translation unit and instead store config in
  a dedicated new struct member in struct display_notes_opt.
- Reworded the main commit to have better explanation of the motivation.

Testing: https://github.com/siddhpant/git/actions/runs/26295998442

Please review and let me know if any changes are needed or errors are there,
hopefully this gets accepted.

Thanks,
Siddh

Siddh Raman Pant (4):
  notes: convert raw arg in format_display_notes() to bool
  wrapper: add support for timeout and deadline in read helpers
  t3301: cover generic displayed notes behavior
  notes: support an external command to display notes

 Documentation/config/notes.adoc             |  61 +++
 Documentation/git-format-patch.adoc         |  11 +-
 Documentation/git-range-diff.adoc           |   6 +
 Documentation/pretty-options.adoc           |   9 +
 Makefile                                    |   2 +
 builtin/log.c                               |  17 +-
 builtin/name-rev.c                          |   9 +-
 builtin/range-diff.c                        |   2 +
 contrib/completion/git-completion.bash      |   4 +-
 log-tree.c                                  |  10 +-
 meson.build                                 |   1 +
 notes-external.c                            | 414 ++++++++++++++++++
 notes-external.h                            |  53 +++
 notes.c                                     | 266 +++++++++---
 notes.h                                     |  33 +-
 revision.c                                  |  36 +-
 strbuf.c                                    |  26 +-
 strbuf.h                                    |   4 +
 t/helper/meson.build                        |   1 +
 t/helper/test-external-notes                |  64 +++
 t/helper/test-notes-external-config-reset.c |  24 ++
 t/helper/test-tool.c                        |   1 +
 t/helper/test-tool.h                        |   1 +
 t/lib-notes.sh                              |  19 +
 t/t3206-range-diff.sh                       |  68 +++
 t/t3301-notes.sh                            | 448 ++++++++++++++++++++
 t/t6120-describe.sh                         |  17 +
 wrapper.c                                   | 139 +++++-
 wrapper.h                                   |  23 +
 29 files changed, 1693 insertions(+), 76 deletions(-)
 create mode 100644 notes-external.c
 create mode 100644 notes-external.h
 create mode 100755 t/helper/test-external-notes
 create mode 100644 t/helper/test-notes-external-config-reset.c
 create mode 100644 t/lib-notes.sh

-- 
2.53.0


^ permalink raw reply

* [PATCH] receive-pack: fix updateInstead with core.worktree
From: Alyssa Ross @ 2026-05-22 15:44 UTC (permalink / raw)
  To: git; +Cc: Ævar Arnfjörð Bjarmason, Junio C Hamano

This used to work, but when push_to_checkout() started being called
before push_to_deploy(), push_to_checkout()'s side effect of adding
GIT_WORK_TREE to the same environment that would be used by
push_to_deploy() wasn't taken into account.  Fix by only mutating the
environment for push_to_commit(), rather than the shared environment.

Fixes: a8cc594333 ("hooks: fix an obscure TOCTOU "did we just run a hook?" race")
Signed-off-by: Alyssa Ross <hi@alyssa.is>
---
 builtin/receive-pack.c |  2 +-
 t/t5516-fetch-push.sh  | 11 +++++++++++
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
index c7b2818f20..7ee157532d 100644
--- a/builtin/receive-pack.c
+++ b/builtin/receive-pack.c
@@ -1460,8 +1460,8 @@ static const char *push_to_checkout(unsigned char *hash,
 
 	opt.invoked_hook = invoked_hook;
 
-	strvec_pushf(env, "GIT_WORK_TREE=%s", absolute_path(work_tree));
 	strvec_pushv(&opt.env, env->v);
+	strvec_pushf(&opt.env, "GIT_WORK_TREE=%s", absolute_path(work_tree));
 	strvec_push(&opt.args, hash_to_hex(hash));
 	if (run_hooks_opt(the_repository, push_to_checkout_hook, &opt))
 		return "push-to-checkout hook declined";
diff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh
index 117cfa051f..f51fb11a6d 100755
--- a/t/t5516-fetch-push.sh
+++ b/t/t5516-fetch-push.sh
@@ -1791,6 +1791,17 @@ test_expect_success 'updateInstead with push-to-checkout hook' '
 	)
 '
 
+test_expect_success 'denyCurrentBranch and core.worktree' '
+	test_when_finished "rm -fr cloned cloned.git" &&
+	git clone --separate-git-dir cloned.git . cloned &&
+	git --git-dir cloned.git config receive.denyCurrentBranch updateInstead &&
+	git --git-dir cloned.git config core.worktree "$PWD/cloned" &&
+        test_commit raspberry &&
+	git push cloned.git HEAD:main &&
+	test_path_exists cloned/raspberry.t &&
+	test_must_fail git push --delete cloned.git main
+'
+
 test_expect_success 'denyCurrentBranch and worktrees' '
 	test_when_finished "rm -fr cloned && git worktree remove --force new-wt" &&
 	git worktree add new-wt &&

base-commit: aec3f587505a472db67e9462d0702e7d463a449d
-- 
2.53.0


^ permalink raw reply related

* Re: Why do we need to wait 1s between a git add and commit
From: Kristoffer Haugsbakk @ 2026-05-22 15:12 UTC (permalink / raw)
  To: Fabrice SALVAIRE, git
In-Reply-To: <36eec9c1-1d2b-4cd1-a64c-a02936d8a2ae@orange.fr>

Hi

On Fri, May 22, 2026, at 14:28, Fabrice SALVAIRE wrote:
> I wrote a Python tool to dump a wiki to a git repository, that does
> basically a succession of subprocess calls to git add and commit.
>
> Recently, I discovered this tool doesn't work any longer and that git
> commit (2.54 on Fedora 42 / 43) crashes randomly.
>
> I cannot explain this behavior since my code is trivial.
>
> I had the intuition to add a sleep time of 1s just after a git call, and
> it solves the issue.
>
> I noticed for some cases that another call to git commit were
> successful. For most cases, git fsck and gitk report issues.
>
> It looks like the state of the git repository was not yet completed
> before the end of the git subprocess.

This might be caused by git-maintenance(1) being run in the background
without locking? That’s a new issue in Git 2.54.0. See:

https://lore.kernel.org/git/20260509175249.GA2336928@coredump.intra.peff.net/

The following script reproduces the issue on Git 2.53.0. I am guessing
that your script does something similar? It depends on how many commits
it creates in a short timeframe.

https://lore.kernel.org/git/20260508180341.GB737125@coredump.intra.peff.net/

^ permalink raw reply

* Re: [PATCH v2 07/11] git-gui: try harder to find worktree from gitdir
From: Shroom Moo @ 2026-05-22 15:09 UTC (permalink / raw)
  To: Mark Levedahl; +Cc: git, Johannes Sixt, Aina Boot
In-Reply-To: <780a52d9-7119-4870-990a-16280005503d@gmail.com>

On 5/22/26 1:45 AM, Mark Levedahl wrote:
> >
> > 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

The relative path issue with {gitdir} is indeed difficult to trigger 
in practice. To trigger this problem, the following conditions must 
be met simultaneously: 
- git rev-parse --absolute-git-dir succeeds (the repository is valid). 
- git rev-parse --show-toplevel fails (the working tree is 
undetectable).
- The current directory is not $_gitdir.
- A gitdir file exists under $_gitdir (i.e., the gitdir of a linked 
worktree). 

Unluckily, this occurs in:

```MINGW64 Shell
# Firstly, enter a test folder
mkdir test-main
cd test-main

git init

echo main > file.txt
git add file.txt
git commit -m "initial"

# In feature branch
git branch feature

# Add worktree
git worktree add ../test-feature feature

cd ../
cd test-feature
cat .git

cd ../

mkdir outside
cd outside

export GIT_DIR="/(PREVIOUSPATH)/test-main/.git/worktrees/feature"
unset GIT_WORK_TREE
git gui blame HEAD file.txt --trace
```

```Wish
puts $_gitworktree
```

The current directory is "outside", not the expected linked worktree. 
[file exists {gitdir}] in find_worktree_from_gitdir searched in the 
current directory but failed, resulting in the inability to identify 
linked worktree. The blame mode can still work without worktree. 

Shroom


^ permalink raw reply

* Re: [PATCH v2 10/11] git-gui: adapt blame/browser parsing for bare operation
From: Shroom Moo @ 2026-05-22 15:05 UTC (permalink / raw)
  To: Mark Levedahl; +Cc: git, Johannes Sixt, Aina Boot
In-Reply-To: <bb38c5d4-b388-4eca-badd-69ec7ce67b90@gmail.com>

On 5/22/26 1:35 AM, Mark Levedahl wrote: 
> 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

You are right. The input is subdir instead of subdir/. All the 
current tests work well. Patch 10/11 does not cause damage to the 
functionality of browser and blame commands. 

Shroom


^ permalink raw reply

* Why do we need to wait 1s between a git add and commit
From: Fabrice SALVAIRE @ 2026-05-22 12:28 UTC (permalink / raw)
  To: git

Dear all,

I wrote a Python tool to dump a wiki to a git repository, that does 
basically a succession of subprocess calls to git add and commit.

Recently, I discovered this tool doesn't work any longer and that git 
commit (2.54 on Fedora 42 / 43) crashes randomly.

I cannot explain this behavior since my code is trivial.

I had the intuition to add a sleep time of 1s just after a git call, and 
it solves the issue.

I noticed for some cases that another call to git commit were 
successful. For most cases, git fsck and gitk report issues.

It looks like the state of the git repository was not yet completed 
before the end of the git subprocess.

Cheers,


^ permalink raw reply

* Re: [PATCH v2 04/11] git-gui: use rev-parse exclusively to find a repository
From: Mark Levedahl @ 2026-05-22 12:04 UTC (permalink / raw)
  To: Johannes Sixt; +Cc: egg_mushroomcow, bootaina702, git
In-Reply-To: <8d1488ec-c4de-4ddd-b3cd-e1e8b4a343bf@kdbg.org>



On 5/22/26 4:46 AM, Johannes Sixt wrote:
> Sorry, but I cannot agree with "prefix is only known after the worktree
> is found". The prefix is a property that can be known even if we haven't
> asked where the top-level of the working tree is. See more below.
Rewording is necessary, but:
This patch already adds
set _prefix {}
to the globals init block.


_prefix can only be non-empty if normal gitdir / gitworktree discovery (without GIT_DIR)
runs and finds the current directory is a descendent of the worktree root. That step comes
later, even if the picker is run.

Mark

^ permalink raw reply

* Re: [PATCH v10 2/4] branch: add --prune-merged <branch>
From: Junio C Hamano @ 2026-05-22 11:58 UTC (permalink / raw)
  To: Harald Nordgren
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	Johannes Sixt, Phillip Wood
In-Reply-To: <CAHwyqnXVnFOeKRXQHHZMLbj_WL55A5gPxof=VFhZxDeXTT8kqw@mail.gmail.com>

Harald Nordgren <haraldnordgren@gmail.com> writes:

>> Please discard this version.  I had unnecessary draft comments that
>> I used as reference in it.
>
> I'm taking this to mean starting over from v9 and implementing the
> 'origin/*' idea again from there. Correct?

No, what I meant was "please discard the review message I was
responding to".


^ permalink raw reply

* Re: [PATCH v2 01/11] git-gui: guard set/unset of GIT_DIR and GIT_WORK_TREE
From: Mark Levedahl @ 2026-05-22 11:54 UTC (permalink / raw)
  To: Johannes Sixt, git; +Cc: egg_mushroomcow, bootaina702
In-Reply-To: <e5a9a410-bf62-4afd-9560-2dea01fe936b@kdbg.org>



On 5/22/26 4:06 AM, Johannes Sixt wrote:
> Am 20.05.26 um 22:24 schrieb Mark Levedahl:
>> 2.54.0.215.g4fe990ec16
>>
>> ------ 8< ------
>> Subject: [PATCH 2/2] git-gui: operate git commands without GIT_WORK_TREE
>>
>> The manual page of the git command states about the --git-dir option:
>>
>>    Specifying the location of the ".git" directory using this option
>>    (or GIT_DIR environment variable) turns off the repository
>>    discovery [...], and tells Git that you are at the top level of
>>    the working tree.
>>
>> Use this to our advantage:
>>
>> - Set GIT_DIR in the environment to the value that was discovered, so
>>   that the invoked git commands operate on the same repository
>>   database that Git GUI uses even after it changes the working
>>   directory.
>>
>> - After changing the working directory to the top level of the working
>>   tree, ensure that GIT_WORK_TREE is not set, because, as per
>>   documentation, all git invocations from then on will assume that the
>>   current working directory is also the top level working tree.
>>
>> - Remove the now obsolete GIT_WORK_TREE dance when subordinate Gitk or
>>   Git GUI are invoked for a submodule.
>
> 2.54.0.215.g4fe990ec16
>
> ------ 8< ------
> Subject: [PATCH 2/2] git-gui: operate git commands without GIT_WORK_TREE
>
> The manual page of the git command states about the --git-dir option:
>
>    Specifying the location of the ".git" directory using this option
>    (or GIT_DIR environment variable) turns off the repository
>    discovery [...], and tells Git that you are at the top level of
>    the working tree.
>
> Use this to our advantage:
>
> - Set GIT_DIR in the environment to the value that was discovered, so
>   that the invoked git commands operate on the same repository
>   database that Git GUI uses even after it changes the working
>   directory.
>
> - After changing the working directory to the top level of the working
>   tree, ensure that GIT_WORK_TREE is not set, because, as per
>   documentation, all git invocations from then on will assume that the
>   current working directory is also the top level working tree.
>
> - Remove the now obsolete GIT_WORK_TREE dance when subordinate Gitk or
>   Git GUI are invoked for a submodule.
> 2.54.0.215.g4fe990ec16
>
> ------ 8< ------
> Subject: [PATCH 2/2] git-gui: operate git commands without GIT_WORK_TREE
>
> The manual page of the git command states about the --git-dir option:
>
>    Specifying the location of the ".git" directory using this option
>    (or GIT_DIR environment variable) turns off the repository
>    discovery [...], and tells Git that you are at the top level of
>    the working tree.
>
> Use this to our advantage:
>
> - Set GIT_DIR in the environment to the value that was discovered, so
>   that the invoked git commands operate on the same repository
>   database that Git GUI uses even after it changes the working
>   directory.
>
> - After changing the working directory to the top level of the working
>   tree, ensure that GIT_WORK_TREE is not set, because, as per
>   documentation, all git invocations from then on will assume that the
>   current working directory is also the top level working tree.
>
> - Remove the now obsolete GIT_WORK_TREE dance when subordinate Gitk or
>   Git GUI are invoked for a submodule.

The manual page is incomplete: if the repository has set core.worktree=/somehere, that is
the root of the worktree and the current directory is always ignored. git rev-parse will
report /somewhere as the answer to --show-toplevel regardless of current directory, even
if inside the gitdir, and even if GIT_DIR is used.

The user can override with GIT_WORK_TREE, and if so we must keep GIT_WORK_TREE in the
environment if it was set. So, an alternative for deciding when to export is

if core.worktree and GIT_WORK_TREE are both set AND GIT_WORK_TREE != core.worktree
    export GIT_WORK_TREE

If the user set GIT_WORK_TREE empty, git rev-parse will throw an error so we would never
to far enough to try to export that.

Mark



^ permalink raw reply

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

From: Harald Nordgren <haraldnordgren@gmail.com>

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

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

Reachability is read from the local refs only -- nothing is
fetched. Users who want fresh upstream refs run "git fetch" first;
the deletion path stays a separate, idempotent step that also
works offline.

Three classes of branches are spared:

  * any branch checked out in any worktree;
  * any branch whose upstream no longer resolves locally (its
    disappearance is not, on its own, evidence of integration);
  * any branch whose push destination equals its upstream
    (<branch>@{push} == <branch>@{upstream}). Such a branch
    cannot be distinguished from a freshly pulled trunk that
    just looks "fully merged" -- e.g. local "main" tracking and
    pushing to "origin/main" right after a pull. Only branches
    that push somewhere other than their upstream (typically
    topics in a fork-based workflow) are treated as candidates.

Deletion goes through the existing delete_branches() in warn-only
mode and with the HEAD-fallback disabled: a branch that is not
yet fully merged to its upstream is reported as a one-line warning
and skipped, so a single un-mergeable topic does not abort the
whole sweep, and there is no fallback to "merged into the
currently checked out branch" -- we only act on upstream-merged
status.

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

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index a37d3a12cb..c521b5f4ca 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
 -----------
@@ -212,6 +213,27 @@ wildmatch pattern like `'origin/*'`.  A bare configured-remote name
 to match the way `git checkout -b topic origin` picks a starting
 point.  Multiple _<branch>_ arguments are unioned.
 
+`--prune-merged`::
+	Delete the local branches that `--forked` would list for the
+	same _<branch>_ arguments, but only those whose tip is
+	reachable from their configured upstream.  In other words,
+	the work on the branch has already landed on the upstream it
+	tracks, so the local copy is no longer needed.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched.  Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
+its upstream no longer resolves locally; it is checked out in any
+worktree; or its push destination (`<branch>@{push}`) equals its
+upstream (`<branch>@{upstream}`), so it cannot be distinguished
+from a freshly pulled trunk that just looks "fully merged".
++
+Branches refused by the "fully merged" safety check are listed as
+warnings and skipped; pass them to `git branch -D` explicitly if
+you want them gone.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 08c1237624..1569f29573 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -821,34 +821,92 @@ 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_forked_set(int argc, const char **argv,
+			       struct string_list *out)
 {
 	struct upstream_pattern *patterns = NULL;
 	size_t nr_patterns = 0;
-	struct string_list out = STRING_LIST_INIT_DUP;
-	struct string_list_item *item;
 	struct forked_cb cb;
 
-	if (!argc)
-		die(_("--forked requires at least one <branch>"));
-
 	parse_forked_args(argc, argv, &patterns, &nr_patterns);
 	cb.patterns = patterns;
 	cb.nr_patterns = nr_patterns;
-	cb.out = &out;
+	cb.out = out;
 
 	refs_for_each_branch_ref(get_main_ref_store(the_repository),
 				 collect_forked_branch, &cb);
 
-	string_list_sort(&out);
+	string_list_sort(out);
+
+	upstream_pattern_list_clear(patterns, nr_patterns);
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+	struct string_list out = STRING_LIST_INIT_DUP;
+	struct string_list_item *item;
+
+	if (!argc)
+		die(_("--forked requires at least one <branch>"));
+
+	collect_forked_set(argc, argv, &out);
 	for_each_string_list_item(item, &out)
 		puts(item->string);
 
-	upstream_pattern_list_clear(patterns, nr_patterns);
 	string_list_clear(&out, 0);
 	return 0;
 }
 
+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 strvec deletable = STRVEC_INIT;
+	struct string_list_item *item;
+	int ret = 0;
+
+	if (!argc)
+		die(_("--prune-merged requires at least one <branch>"));
+
+	collect_forked_set(argc, argv, &candidates);
+
+	for_each_string_list_item(item, &candidates) {
+		const char *short_name = item->string;
+		struct branch *branch = branch_get(short_name);
+		const char *upstream, *push;
+		struct strbuf full = STRBUF_INIT;
+		int skip;
+
+		strbuf_addf(&full, "refs/heads/%s", short_name);
+		skip = !!branch_checked_out(full.buf);
+		strbuf_release(&full);
+		if (skip)
+			continue;
+
+		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+		if (!upstream || !refs_ref_exists(refs, upstream))
+			continue;
+		push = branch ? branch_get_push(branch, NULL) : NULL;
+		if (!push || !strcmp(push, upstream))
+			continue;
+
+		strvec_push(&deletable, short_name);
+	}
+
+	if (deletable.nr)
+		ret = delete_branches(deletable.nr, deletable.v,
+				      0, /* force */
+				      FILTER_REFS_BRANCHES,
+				      quiet,
+				      1, /* warn_only */
+				      1, /* no_head_fallback */
+				      0  /* dry_run */);
+
+	strvec_clear(&deletable);
+	string_list_clear(&candidates, 0);
+	return ret;
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -891,6 +949,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 */
@@ -946,6 +1005,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 is merged")),
 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -990,7 +1051,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 ||
@@ -999,7 +1061,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);
 
@@ -1043,6 +1105,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 013ddfb65d..ad87946081 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1807,4 +1807,187 @@ 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_create_repo pm-fork
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+	test_when_finished "rm -rf pm-merged" &&
+	git clone pm-upstream pm-merged &&
+	git -C pm-merged remote add fork ../pm-fork &&
+	test_config -C pm-merged remote.pushDefault fork &&
+	test_config -C pm-merged push.default current &&
+	git -C pm-merged branch one one-commit &&
+	git -C pm-merged branch --set-upstream-to=origin/next one &&
+	git -C pm-merged branch two two-commit &&
+	git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+	git -C pm-merged branch --prune-merged "origin/*" &&
+
+	test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a literal upstream' '
+	test_when_finished "rm -rf pm-literal" &&
+	git clone pm-upstream pm-literal &&
+	git -C pm-literal remote add fork ../pm-fork &&
+	test_config -C pm-literal remote.pushDefault fork &&
+	test_config -C pm-literal push.default current &&
+	git -C pm-literal branch one one-commit &&
+	git -C pm-literal branch --set-upstream-to=origin/next one &&
+
+	git -C pm-literal branch --prune-merged origin/next &&
+
+	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+	test_when_finished "rm -rf pm-union" &&
+	git clone pm-upstream pm-union &&
+	git -C pm-union remote add fork ../pm-fork &&
+	test_config -C pm-union remote.pushDefault fork &&
+	test_config -C pm-union push.default current &&
+	git -C pm-union branch one one-commit &&
+	git -C pm-union branch --set-upstream-to=origin/next one &&
+	git -C pm-union branch two base &&
+	git -C pm-union branch --set-upstream-to=origin/main two &&
+	git -C pm-union checkout --detach &&
+
+	git -C pm-union branch --prune-merged origin/next origin/main &&
+
+	test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a local upstream' '
+	test_when_finished "rm -rf pm-local" &&
+	git clone pm-upstream pm-local &&
+	git -C pm-local remote add fork ../pm-fork &&
+	test_config -C pm-local remote.pushDefault fork &&
+	test_config -C pm-local push.default current &&
+	git -C pm-local checkout -b trunk &&
+	git -C pm-local branch one one-commit &&
+	git -C pm-local branch --set-upstream-to=trunk one &&
+	git -C pm-local merge --ff-only one-commit &&
+
+	git -C pm-local branch --prune-merged trunk &&
+
+	test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
+	test_when_finished "rm -rf pm-unmerged" &&
+	git clone pm-upstream pm-unmerged &&
+	git -C pm-unmerged remote add fork ../pm-fork &&
+	test_config -C pm-unmerged remote.pushDefault fork &&
+	test_config -C pm-unmerged push.default current &&
+	git -C pm-unmerged checkout -b wip origin/wip &&
+	git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+	test_commit -C pm-unmerged local-only &&
+	git -C pm-unmerged checkout - &&
+
+	git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+	test_grep "not fully merged" err &&
+	test_grep ! "If you are sure you want to delete it" err &&
+	git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
+	test_when_finished "rm -rf pm-nohead" &&
+	git clone pm-upstream pm-nohead &&
+	git -C pm-nohead remote add fork ../pm-fork &&
+	test_config -C pm-nohead remote.pushDefault fork &&
+	test_config -C pm-nohead push.default current &&
+	git -C pm-nohead branch topic one-commit &&
+	git -C pm-nohead branch --set-upstream-to=origin/next topic &&
+
+	git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
+
+	test_grep ! "not yet merged to HEAD" err &&
+	test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+	test_when_finished "rm -rf pm-upstream-gone" &&
+	git clone pm-upstream pm-upstream-gone &&
+	git -C pm-upstream-gone remote add fork ../pm-fork &&
+	test_config -C pm-upstream-gone remote.pushDefault fork &&
+	test_config -C pm-upstream-gone push.default current &&
+	git -C pm-upstream-gone branch one one-commit &&
+	git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+	git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+	git -C pm-upstream-gone branch --prune-merged "origin/*" &&
+
+	git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+	test_when_finished "rm -rf pm-head" &&
+	git clone pm-upstream pm-head &&
+	git -C pm-head remote add fork ../pm-fork &&
+	test_config -C pm-head remote.pushDefault fork &&
+	test_config -C pm-head push.default current &&
+	git -C pm-head checkout -b one one-commit &&
+	git -C pm-head branch --set-upstream-to=origin/next one &&
+
+	git -C pm-head branch --prune-merged "origin/*" &&
+
+	git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares branches that push back to their upstream' '
+	test_when_finished "rm -rf pm-push-eq" &&
+	git clone pm-upstream pm-push-eq &&
+	git -C pm-push-eq checkout --detach &&
+
+	git -C pm-push-eq branch --prune-merged "origin/*" &&
+
+	git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
+	test_when_finished "rm -rf pm-push-branch" &&
+	git clone pm-upstream pm-push-branch &&
+	git -C pm-push-branch remote add fork ../pm-fork &&
+	test_config -C pm-push-branch remote.pushDefault fork &&
+	test_config -C pm-push-branch push.default current &&
+	test_config -C pm-push-branch branch.main.pushRemote origin &&
+	git -C pm-push-branch checkout --detach &&
+
+	git -C pm-push-branch branch --prune-merged "origin/*" &&
+
+	git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
+	test_when_finished "rm -rf pm-push-diff" &&
+	git clone pm-upstream pm-push-diff &&
+	git -C pm-push-diff remote add fork ../pm-fork &&
+	test_config -C pm-push-diff remote.pushDefault fork &&
+	test_config -C pm-push-diff push.default current &&
+	git -C pm-push-diff branch topic one-commit &&
+	git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
+	git -C pm-push-diff checkout --detach &&
+
+	git -C pm-push-diff branch --prune-merged "origin/*" &&
+
+	test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged requires at least one <branch>' '
+	test_must_fail git -C forked branch --prune-merged 2>err &&
+	test_grep "at least one <branch>" err
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v11 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-05-22 11:31 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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

Unless --quiet is given, the skip is reported per branch so the
user knows why their topic was preserved.

Explicit deletion via "git branch -d" continues to consult the
normal merge check and is not affected by this setting.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/config/branch.adoc |  7 +++++++
 Documentation/git-branch.adoc    |  5 +++--
 builtin/branch.c                 | 14 ++++++++++++++
 t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++
 4 files changed, 54 insertions(+), 2 deletions(-)

diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..6c1b5bb9cd 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -102,3 +102,10 @@ for details).
 	`git branch --edit-description`. Branch description is
 	automatically added to the `format-patch` cover letter or
 	`request-pull` summary.
+
+`branch.<name>.pruneMerged`::
+	If set to `false`, branch _<name>_ is exempt from
+	`git branch --prune-merged`.  Useful for a topic branch you
+	intend to develop further after an initial round has been
+	merged upstream.  Defaults to true.  Explicit deletion via
+	`git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c521b5f4ca..1bd28c4e37 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -226,9 +226,10 @@ the upstream refs refreshed.
 +
 A branch is left alone if any of the following holds:
 its upstream no longer resolves locally; it is checked out in any
-worktree; or its push destination (`<branch>@{push}`) equals its
+worktree; its push destination (`<branch>@{push}`) equals its
 upstream (`<branch>@{upstream}`), so it cannot be distinguished
-from a freshly pulled trunk that just looks "fully merged".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.pruneMerged` is set to `false`.
 +
 Branches refused by the "fully merged" safety check are listed as
 warnings and skipped; pass them to `git branch -D` explicitly if
diff --git a/builtin/branch.c b/builtin/branch.c
index 1569f29573..187d5d1563 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -875,7 +875,9 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
 		struct branch *branch = branch_get(short_name);
 		const char *upstream, *push;
 		struct strbuf full = STRBUF_INIT;
+		struct strbuf key = STRBUF_INIT;
 		int skip;
+		int opt_out;
 
 		strbuf_addf(&full, "refs/heads/%s", short_name);
 		skip = !!branch_checked_out(full.buf);
@@ -890,6 +892,18 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
 		if (!push || !strcmp(push, upstream))
 			continue;
 
+		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+		    !opt_out) {
+			if (!quiet)
+				fprintf(stderr,
+					_("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
+					short_name, short_name);
+			strbuf_release(&key);
+			continue;
+		}
+		strbuf_release(&key);
+
 		strvec_push(&deletable, short_name);
 	}
 
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index ad87946081..da7e174e09 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1990,4 +1990,34 @@ 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 remote add fork ../pm-fork &&
+	test_config -C pm-optout remote.pushDefault fork &&
+	test_config -C pm-optout push.default current &&
+	git -C pm-optout branch one one-commit &&
+	git -C pm-optout branch --set-upstream-to=origin/next one &&
+	git -C pm-optout branch two two-commit &&
+	git -C pm-optout branch --set-upstream-to=origin/next two &&
+	test_config -C pm-optout branch.one.pruneMerged false &&
+
+	git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+	git -C pm-optout rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+	test_grep "Skipping .one." err
+'
+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+	test_when_finished "rm -rf pm-optout-d" &&
+	git clone pm-upstream pm-optout-d &&
+	git -C pm-optout-d branch one one-commit &&
+	git -C pm-optout-d branch --set-upstream-to=origin/next one &&
+	test_config -C pm-optout-d branch.one.pruneMerged false &&
+
+	git -C pm-optout-d branch -d one &&
+	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v11 6/6] branch: add --dry-run for --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-05-22 11:31 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

With --dry-run, --prune-merged prints the local branches it would
delete -- one "Would delete branch <name>" line per candidate --
and exits without touching any ref.

This is the natural sanity check before letting a broad pattern
like 'origin/*' run for real: the @{push}-vs-@{upstream} and
unmerged filtering still applies, so the dry-run output is
exactly the set that the live run would delete.

--dry-run is only meaningful in combination with --prune-merged
and is rejected otherwise.

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

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 1bd28c4e37..ee9a6354fd 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
 -----------
@@ -235,6 +235,12 @@ Branches refused by the "fully merged" safety check are listed as
 warnings and skipped; pass them to `git branch -D` explicitly if
 you want them gone.
 
+`--dry-run`::
+	With `--prune-merged`, print which branches would be
+	deleted and exit without touching any ref.  Useful for
+	sanity-checking a wide pattern like `'origin/*'` before
+	committing to the deletion.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 187d5d1563..7a2db11cd4 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -857,7 +857,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 quiet,
+				 int dry_run)
 {
 	struct ref_store *refs = get_main_ref_store(the_repository);
 	struct string_list candidates = STRING_LIST_INIT_DUP;
@@ -914,7 +915,7 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
 				      quiet,
 				      1, /* warn_only */
 				      1, /* no_head_fallback */
-				      0  /* dry_run */);
+				      dry_run);
 
 	strvec_clear(&deletable);
 	string_list_clear(&candidates, 0);
@@ -964,6 +965,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 */
@@ -1021,6 +1023,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 is merged")),
+		OPT_BOOL(0, "dry-run", &dry_run,
+			N_("with --prune-merged, only print which branches would be deleted")),
 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1079,6 +1083,9 @@ int cmd_branch(int argc,
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
+	if (dry_run && !prune_merged)
+		die(_("--dry-run requires --prune-merged"));
+
 	if (recurse_submodules_explicit) {
 		if (!submodule_propagate_branches)
 			die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ -1120,7 +1127,7 @@ int cmd_branch(int argc,
 		ret = list_forked_branches(argc, argv);
 		goto out;
 	} else if (prune_merged) {
-		ret = prune_merged_branches(argc, argv, quiet);
+		ret = prune_merged_branches(argc, argv, quiet, dry_run);
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index da7e174e09..0e0629d19e 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2020,4 +2020,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
 	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
 '
 
+test_expect_success '--prune-merged --dry-run lists but does not delete' '
+	test_when_finished "rm -rf pm-dry" &&
+	git clone pm-upstream pm-dry &&
+	git -C pm-dry remote add fork ../pm-fork &&
+	test_config -C pm-dry remote.pushDefault fork &&
+	test_config -C pm-dry push.default current &&
+	git -C pm-dry branch one one-commit &&
+	git -C pm-dry branch --set-upstream-to=origin/next one &&
+	git -C pm-dry branch two two-commit &&
+	git -C pm-dry branch --set-upstream-to=origin/next two &&
+
+	git -C pm-dry branch --prune-merged --dry-run "origin/*" >actual &&
+	test_grep "Would delete branch one " actual &&
+	test_grep "Would delete branch two " actual &&
+
+	git -C pm-dry rev-parse --verify refs/heads/one &&
+	git -C pm-dry rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
+	test_when_finished "rm -rf pm-dry-mixed" &&
+	git clone pm-upstream pm-dry-mixed &&
+	git -C pm-dry-mixed remote add fork ../pm-fork &&
+	test_config -C pm-dry-mixed remote.pushDefault fork &&
+	test_config -C pm-dry-mixed push.default current &&
+	git -C pm-dry-mixed checkout -b wip origin/next &&
+	git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
+	test_commit -C pm-dry-mixed local-only &&
+	git -C pm-dry-mixed checkout - &&
+	git -C pm-dry-mixed branch merged one-commit &&
+	git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
+
+	git -C pm-dry-mixed branch --prune-merged --dry-run "origin/*" >out &&
+	test_grep "Would delete branch merged" out &&
+	test_grep ! "Would delete branch wip" out &&
+	git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
+	git -C pm-dry-mixed rev-parse --verify refs/heads/merged
+'
+
+test_expect_success '--dry-run without --prune-merged is rejected' '
+	test_must_fail git -C forked branch --dry-run 2>err &&
+	test_grep "requires --prune-merged" err
+'
+
 test_done
-- 
gitgitgadget

^ permalink raw reply related

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

From: Harald Nordgren <haraldnordgren@gmail.com>

Add a warn_only flag to delete_branches() and check_branch_commit()
so a bulk caller can report not-fully-merged branches as one-line
warnings and continue, instead of erroring with the four-line "use
'git branch -D'" advice that the standalone "git branch -d" path
emits.  Default callers pass 0 and are unaffected.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/branch.c | 26 +++++++++++++++++---------
 1 file changed, 17 insertions(+), 9 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index 2d34ad34dc..96f6ae6dec 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -193,7 +193,7 @@ static int branch_merged(int kind, const char *name,
 
 static int check_branch_commit(const char *branchname, const char *refname,
 			       const struct object_id *oid, struct commit *head_rev,
-			       int kinds, int force)
+			       int kinds, int force, int warn_only)
 {
 	struct commit *rev = lookup_commit_reference(the_repository, oid);
 	if (!force && !rev) {
@@ -201,10 +201,16 @@ static int check_branch_commit(const char *branchname, const char *refname,
 		return -1;
 	}
 	if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
-		error(_("the branch '%s' is not fully merged"), branchname);
-		advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
-				  _("If you are sure you want to delete it, "
-				  "run 'git branch -D %s'"), branchname);
+		if (warn_only) {
+			warning(_("the branch '%s' is not fully merged"),
+				branchname);
+		} else {
+			error(_("the branch '%s' is not fully merged"),
+			      branchname);
+			advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
+					  _("If you are sure you want to delete it, "
+					  "run 'git branch -D %s'"), branchname);
+		}
 		return -1;
 	}
 	return 0;
@@ -220,7 +226,7 @@ static void delete_branch_config(const char *branchname)
 }
 
 static int delete_branches(int argc, const char **argv, int force, int kinds,
-			   int quiet)
+			   int quiet, int warn_only)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -310,8 +316,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 
 		if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
-					force)) {
-			ret = 1;
+					force, warn_only)) {
+			if (!warn_only)
+				ret = 1;
 			goto next;
 		}
 
@@ -1019,7 +1026,8 @@ int cmd_branch(int argc,
 	if (delete) {
 		if (!argc)
 			die(_("branch name required"));
-		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
+		ret = delete_branches(argc, argv, delete > 1, filter.kind,
+				      quiet, 0);
 		goto out;
 	} else if (forked) {
 		ret = list_forked_branches(argc, argv);
-- 
gitgitgadget


^ permalink raw reply related

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

From: Harald Nordgren <haraldnordgren@gmail.com>

Add no_head_fallback and dry_run flags to delete_branches() so a bulk
caller (the upcoming --prune-merged) can ask strictly about
merged-into-upstream without a silent fallback to HEAD, and rehearse
deletions with the same "Would delete branch ..." wording as the live
run.  Existing callers pass 0 for both and keep current behavior.

When no_head_fallback is set, head_rev stays NULL through to
branch_merged(), whose "merged to X but not yet merged to HEAD"
reminder otherwise compares against HEAD.  That reminder is only
meaningful when the caller actually cares about HEAD; for the
bulk caller every candidate is known to have an upstream and HEAD
is irrelevant to the decision.  Guard the block on head_rev so the
NULL case skips it instead of treating "NULL != reference_rev" as
"diverges from HEAD" and emitting a spurious warning.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/branch.c | 27 +++++++++++++++++++--------
 1 file changed, 19 insertions(+), 8 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index 96f6ae6dec..08c1237624 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -170,10 +170,13 @@ static int branch_merged(int kind, const char *name,
 	 * upstream, if any, otherwise with HEAD", we should just
 	 * return the result of the repo_in_merge_bases() above without
 	 * any of the following code, but during the transition period,
-	 * a gentle reminder is in order.
+	 * a gentle reminder is in order.  Callers that opt out of the
+	 * HEAD fallback by passing head_rev=NULL are not interested in
+	 * the reminder either: they have already established that the
+	 * branch has an upstream, so HEAD is irrelevant to the decision.
 	 */
-	if (head_rev != reference_rev) {
-		int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+	if (head_rev && head_rev != reference_rev) {
+		int expect = repo_in_merge_bases(the_repository, rev, head_rev);
 		if (expect < 0)
 			exit(128);
 		if (expect == merged)
@@ -226,7 +229,8 @@ static void delete_branch_config(const char *branchname)
 }
 
 static int delete_branches(int argc, const char **argv, int force, int kinds,
-			   int quiet, int warn_only)
+			   int quiet, int warn_only, int no_head_fallback,
+			   int dry_run)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -260,7 +264,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 	}
 	branch_name_pos = strcspn(fmt, "%");
 
-	if (!force)
+	if (!force && !no_head_fallback)
 		head_rev = lookup_commit_reference(the_repository, &head_oid);
 
 	for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
@@ -331,13 +335,20 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 		free(target);
 	}
 
-	if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
+	if (!dry_run &&
+	    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
 		ret = 1;
 
 	for_each_string_list_item(item, &refs_to_delete) {
 		char *describe_ref = item->util;
 		char *name = item->string;
-		if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
+		if (dry_run) {
+			if (!quiet)
+				printf(remote_branch
+					? _("Would delete remote-tracking branch %s (was %s).\n")
+					: _("Would delete branch %s (was %s).\n"),
+					name + branch_name_pos, describe_ref);
+		} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
 			char *refname = name + branch_name_pos;
 			if (!quiet)
 				printf(remote_branch
@@ -1027,7 +1038,7 @@ int cmd_branch(int argc,
 		if (!argc)
 			die(_("branch name required"));
 		ret = delete_branches(argc, argv, delete > 1, filter.kind,
-				      quiet, 0);
+				      quiet, 0, 0, 0);
 		goto out;
 	} else if (forked) {
 		ret = list_forked_branches(argc, argv);
-- 
gitgitgadget


^ permalink raw reply related

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

After releasing v10, I hard-reset back to v9 and reworked the series from
there.

 * The flags now take a branch, not a remote. --forked and --prune-merged
   accept a literal upstream short name like origin/main or a wildmatch
   pattern like origin/*. The old --all-remotes flag is gone, since origin/*
   covers that case.
 * The prune guard now compares @{push} against @{upstream}. A branch is
   spared when these are equal. That is the trunk like case, such as local
   main tracking and pushing to origin/main, where "fully merged to
   upstream" cannot be told apart from "just pulled". Only branches that
   push somewhere other than their upstream, typically fork based topics,
   are candidates. The earlier <remote>/HEAD by name guard that the reviewer
   rejected is gone.
 * New --dry-run for --prune-merged.

Harald Nordgren (6):
  branch: add --forked <branch>
  branch: let delete_branches warn instead of error on bulk refusal
  branch: prepare delete_branches for a bulk caller
  branch: add --prune-merged <branch>
  branch: add branch.<name>.pruneMerged opt-out
  branch: add --dry-run for --prune-merged

 Documentation/config/branch.adoc |   7 +
 Documentation/git-branch.adoc    |  42 ++++
 builtin/branch.c                 | 303 +++++++++++++++++++++++++--
 t/t3200-branch.sh                | 347 +++++++++++++++++++++++++++++++
 4 files changed, 682 insertions(+), 17 deletions(-)


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

Range-diff vs v10:

 1:  f2df159830 ! 1:  b9fddd124a branch: add --forked <branch>
     @@ Metadata
       ## Commit message ##
          branch: add --forked <branch>
      
     -            git branch --forked <branch>...
     +    List local branches whose configured upstream
     +    (branch.<name>.merge resolved against branch.<name>.remote)
     +    matches any of the given <branch> arguments.
      
     -    lists local branches whose configured upstream matches any
     -    of the given <branch> arguments.
     +    Each <branch> is interpreted against the local repository, not
     +    against any specific remote:
      
     -    Each <branch> is resolved to the same kind of ref that
     -    branch.<name>.remote and branch.<name>.merge together point at:
     -    a remote-tracking branch (e.g. origin/master), or, for branches
     -    tracking a local upstream, a local branch (e.g. master).
     -    Shell-style globs are also accepted (e.g. 'origin/*'). Multiple
     -    arguments are unioned.
     +      * a literal upstream short name, e.g. "origin/main" or "master"
     +        for a branch whose upstream is local;
     +      * a wildmatch pattern, e.g. "origin/*";
     +      * a bare configured-remote name, e.g. "origin", which resolves
     +        to whatever refs/remotes/origin/HEAD points at, matching how
     +        "git checkout -b topic origin" picks a starting point.
      
     -    This is the building block for --prune-merged.
     +    The literal-vs-wildcard distinction is settled at parse time so
     +    the per-branch matching loop calls wildmatch() only for genuine
     +    wildcards. Multiple <branch> arguments are unioned. Output is
     +    sorted by branch name.
     +
     +    This is the building block for --prune-merged, which deletes the
     +    listed branches once they have landed on their upstream.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
       	nothing is printed.
       
      +`--forked`::
     -+	List local branches whose configured upstream matches any
     -+	of the given _<branch>_ arguments. Each argument is either
     -+	a ref (e.g. `origin/master`, `master`) or a shell-style
     -+	glob (e.g. `'origin/*'`). Multiple arguments are unioned.
     ++	List local branches whose configured upstream
     ++	(`branch.<name>.merge` resolved against `branch.<name>.remote`)
     ++	matches any of the given _<branch>_ arguments.
     +++
     ++Each _<branch>_ is interpreted against the local repository: a literal
     ++upstream like `origin/main` or a local branch like `master`, or a
     ++wildmatch pattern like `'origin/*'`.  A bare configured-remote name
     ++(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,
     ++to match the way `git checkout -b topic origin` picks a starting
     ++point.  Multiple _<branch>_ arguments are unioned.
      +
       `-v`::
       `-vv`::
     @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
       	NULL
       };
       
     -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
     - 
     - static int check_branch_commit(const char *branchname, const char *refname,
     - 			       const struct object_id *oid, struct commit *head_rev,
     --			       int kinds, int force)
     -+			       int kinds, int force, int warn_only,
     -+			       int *n_not_merged)
     - {
     - 	struct commit *rev = lookup_commit_reference(the_repository, oid);
     - 	if (!force && !rev) {
     -@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
     - 		return -1;
     - 	}
     - 	if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
     --		error(_("the branch '%s' is not fully merged"), branchname);
     --		advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
     --				  _("If you are sure you want to delete it, "
     --				  "run 'git branch -D %s'"), branchname);
     -+		if (warn_only) {
     -+			warning(_("the branch '%s' is not fully merged"),
     -+				branchname);
     -+		} else {
     -+			error(_("the branch '%s' is not fully merged"),
     -+			      branchname);
     -+			advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
     -+					  _("If you are sure you want to delete it, "
     -+					  "run 'git branch -D %s'"), branchname);
     -+		}
     -+		if (n_not_merged)
     -+			(*n_not_merged)++;
     - 		return -1;
     - 	}
     - 	return 0;
     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
     - }
     - 
     - static int delete_branches(int argc, const char **argv, int force, int kinds,
     --			   int quiet)
     -+			   int quiet, int warn_only, int *n_not_merged)
     - {
     - 	struct commit *head_rev = NULL;
     - 	struct object_id oid;
     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     - 
     - 		if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
     - 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
     --					force)) {
     --			ret = 1;
     -+					force, warn_only, n_not_merged)) {
     -+			if (!warn_only)
     -+				ret = 1;
     - 			goto next;
     - 		}
     - 
      @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
       	free_worktrees(worktrees);
       }
       
     ++struct upstream_pattern {
     ++	char *name;
     ++	int is_wildcard;
     ++};
     ++
     ++static void upstream_pattern_list_clear(struct upstream_pattern *items,
     ++					size_t nr)
     ++{
     ++	size_t i;
     ++	for (i = 0; i < nr; i++)
     ++		free(items[i].name);
     ++	free(items);
     ++}
     ++
     ++static const char *short_upstream_name(const char *full_ref)
     ++{
     ++	const char *short_name = full_ref;
     ++	(void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
     ++	       skip_prefix(short_name, "refs/remotes/", &short_name));
     ++	return short_name;
     ++}
     ++
     ++static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
     ++{
     ++	struct ref_store *refs = get_main_ref_store(the_repository);
     ++	struct remote *remote;
     ++	struct object_id oid;
     ++	char *full_ref = NULL;
     ++	struct strbuf head_ref = STRBUF_INIT;
     ++	const char *resolved;
     ++
     ++	if (has_glob_specials(arg)) {
     ++		out->name = xstrdup(arg);
     ++		out->is_wildcard = 1;
     ++		return 0;
     ++	}
     ++
     ++	remote = remote_get(arg);
     ++	if (remote && remote_is_configured(remote, 0)) {
     ++		strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote->name);
     ++		resolved = refs_resolve_ref_unsafe(refs, head_ref.buf,
     ++						   RESOLVE_REF_NO_RECURSE,
     ++						   NULL, NULL);
     ++		if (resolved && starts_with(resolved, "refs/remotes/")) {
     ++			out->name = xstrdup(short_upstream_name(resolved));
     ++			out->is_wildcard = 0;
     ++			strbuf_release(&head_ref);
     ++			return 0;
     ++		}
     ++		strbuf_release(&head_ref);
     ++	}
     ++
     ++	if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
     ++			  &full_ref, 0) == 1 &&
     ++	    (starts_with(full_ref, "refs/heads/") ||
     ++	     starts_with(full_ref, "refs/remotes/"))) {
     ++		out->name = xstrdup(short_upstream_name(full_ref));
     ++		out->is_wildcard = 0;
     ++		free(full_ref);
     ++		return 0;
     ++	}
     ++	free(full_ref);
     ++	return -1;
     ++}
     ++
      +static void parse_forked_args(int argc, const char **argv,
     -+			      struct string_list *upstream_patterns)
     ++			      struct upstream_pattern **patterns_out,
     ++			      size_t *nr_out)
      +{
     ++	struct upstream_pattern *patterns;
      +	int i;
      +
     ++	ALLOC_ARRAY(patterns, argc);
      +	for (i = 0; i < argc; i++) {
     -+		const char *arg = argv[i];
     -+		struct object_id oid;
     -+		char *full_ref = NULL;
     -+		const char *short_ref;
     -+
     -+		if (has_glob_specials(arg)) {
     -+			string_list_insert(upstream_patterns, arg);
     -+			continue;
     ++		if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
     ++			upstream_pattern_list_clear(patterns, i);
     ++			die(_("'%s' is not a valid branch or pattern"),
     ++			    argv[i]);
      +		}
     ++	}
     ++	*patterns_out = patterns;
     ++	*nr_out = argc;
     ++}
      +
     -+		if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
     -+				  &full_ref, 0) == 1 &&
     -+		    (skip_prefix(full_ref, "refs/heads/", &short_ref) ||
     -+		     skip_prefix(full_ref, "refs/remotes/", &short_ref))) {
     -+			string_list_insert(upstream_patterns, short_ref);
     -+			free(full_ref);
     -+			continue;
     -+		}
     -+		free(full_ref);
     ++static int upstream_matches(const char *short_upstream,
     ++			    const struct upstream_pattern *patterns,
     ++			    size_t nr)
     ++{
     ++	size_t i;
      +
     -+		die(_("'%s' is not a valid branch or pattern"), arg);
     ++	for (i = 0; i < nr; i++) {
     ++		const struct upstream_pattern *p = &patterns[i];
     ++		if (p->is_wildcard) {
     ++			if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
     ++				return 1;
     ++		} else if (!strcmp(p->name, short_upstream)) {
     ++			return 1;
     ++		}
      +	}
     ++	return 0;
      +}
      +
      +struct forked_cb {
     -+	const struct string_list *upstream_patterns;
     ++	const struct upstream_pattern *patterns;
     ++	size_t nr_patterns;
      +	struct string_list *out;
      +};
      +
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
      +{
      +	struct forked_cb *cb = cb_data;
      +	struct branch *branch;
     -+	const char *upstream, *short_upstream;
     -+	const struct string_list_item *item;
     ++	const char *upstream;
      +
      +	if (ref->flags & REF_ISSYMREF)
      +		return 0;
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
      +	upstream = branch_get_upstream(branch, NULL);
      +	if (!upstream)
      +		return 0;
     -+	short_upstream = upstream;
     -+	(void)(skip_prefix(short_upstream, "refs/heads/", &short_upstream) ||
     -+	       skip_prefix(short_upstream, "refs/remotes/", &short_upstream));
     -+
     -+	for_each_string_list_item(item, cb->upstream_patterns)
     -+		if (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {
     -+			string_list_append(cb->out, ref->name)->util =
     -+				xstrdup(upstream);
     -+			return 0;
     -+		}
     ++	if (upstream_matches(short_upstream_name(upstream),
     ++			     cb->patterns, cb->nr_patterns))
     ++		string_list_append(cb->out, ref->name);
      +	return 0;
      +}
      +
     -+static void collect_forked_set(int argc, const char **argv,
     -+			       struct string_list *out)
     -+{
     -+	struct string_list upstream_patterns = STRING_LIST_INIT_DUP;
     -+	struct forked_cb cb = {
     -+		.upstream_patterns = &upstream_patterns,
     -+		.out = out,
     -+	};
     -+
     -+	parse_forked_args(argc, argv, &upstream_patterns);
     -+
     -+	refs_for_each_branch_ref(get_main_ref_store(the_repository),
     -+				 collect_forked_branch, &cb);
     -+
     -+	string_list_clear(&upstream_patterns, 0);
     -+}
     -+
      +static int list_forked_branches(int argc, const char **argv)
      +{
     ++	struct upstream_pattern *patterns = NULL;
     ++	size_t nr_patterns = 0;
      +	struct string_list out = STRING_LIST_INIT_DUP;
      +	struct string_list_item *item;
     ++	struct forked_cb cb;
      +
      +	if (!argc)
      +		die(_("--forked requires at least one <branch>"));
      +
     -+	collect_forked_set(argc, argv, &out);
     ++	parse_forked_args(argc, argv, &patterns, &nr_patterns);
     ++	cb.patterns = patterns;
     ++	cb.nr_patterns = nr_patterns;
     ++	cb.out = &out;
     ++
     ++	refs_for_each_branch_ref(get_main_ref_store(the_repository),
     ++				 collect_forked_branch, &cb);
     ++
     ++	string_list_sort(&out);
      +	for_each_string_list_item(item, &out)
      +		puts(item->string);
      +
     -+	string_list_clear(&out, 1);
     ++	upstream_pattern_list_clear(patterns, nr_patterns);
     ++	string_list_clear(&out, 0);
      +	return 0;
      +}
      +
     @@ builtin/branch.c: int cmd_branch(int argc,
       		usage_with_options(builtin_branch_usage, options);
       
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 	if (delete) {
     - 		if (!argc)
       			die(_("branch name required"));
     --		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
     -+		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     -+				      quiet, 0, NULL);
     -+		goto out;
     + 		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
     + 		goto out;
      +	} else if (forked) {
      +		ret = list_forked_branches(argc, argv);
     - 		goto out;
     ++		goto out;
       	} else if (show_current) {
       		print_current_branch_name();
     + 		ret = 0;
      
       ## t/t3200-branch.sh ##
      @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +	git clone forked-upstream forked &&
      +	git -C forked remote add other ../forked-other &&
      +	git -C forked fetch other &&
     ++	git -C forked branch local-base &&
      +	git -C forked branch --track local-one origin/one &&
      +	git -C forked branch --track local-two origin/two &&
      +	git -C forked branch --track local-foreign other/foreign &&
      +	git -C forked branch detached &&
     -+	git -C forked branch --track topic-on-main main
     ++	git -C forked branch --track local-trunk local-base
      +'
      +
     -+test_expect_success '--forked <remote-tracking-branch> lists matching branches' '
     ++test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
      +	git -C forked branch --forked origin/one >actual &&
      +	echo local-one >expect &&
      +	test_cmp expect actual
      +'
      +
     -+test_expect_success '--forked <local-branch> lists branches tracking that local branch' '
     -+	git -C forked branch --forked main >actual &&
     -+	echo topic-on-main >expect &&
     -+	test_cmp expect actual
     -+'
     -+
     -+test_expect_success '--forked <glob> matches every upstream under the pattern' '
     ++test_expect_success '--forked <glob> matches by wildmatch' '
      +	git -C forked branch --forked "origin/*" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-one
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +	test_cmp expect actual
      +'
      +
     ++test_expect_success '--forked <local-branch> matches branches with local upstream' '
     ++	git -C forked branch --forked local-base >actual &&
     ++	echo local-trunk >expect &&
     ++	test_cmp expect actual
     ++'
     ++
     ++test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '
     ++	test_when_finished "git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main" &&
     ++	git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&
     ++	git -C forked branch --forked origin >actual &&
     ++	echo local-one >expect &&
     ++	test_cmp expect actual
     ++'
     ++
      +test_expect_success '--forked unions multiple <branch> arguments' '
      +	git -C forked branch --forked origin/one other/foreign >actual &&
      +	cat >expect <<-\EOF &&
     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
      +'
      +
      +test_expect_success '--forked combines literal and glob arguments' '
     -+	git -C forked branch --forked main "other/*" >actual &&
     ++	git -C forked branch --forked local-base "other/*" >actual &&
      +	cat >expect <<-\EOF &&
      +	local-foreign
     -+	topic-on-main
     ++	local-trunk
      +	EOF
      +	test_cmp expect actual
      +'
 -:  ---------- > 2:  b666d09bf5 branch: let delete_branches warn instead of error on bulk refusal
 -:  ---------- > 3:  6e6580270e branch: prepare delete_branches for a bulk caller
 2:  718e28c7e0 ! 4:  e7e03c1338 branch: add --prune-merged <branch>
     @@ Commit message
      
                  git branch --prune-merged <branch>...
      
     -    deletes the local branches that --forked <branch> would list,
     -    but only those whose tip is reachable from their configured
     -    upstream: the work has already landed on the upstream the
     -    branch tracks, so the local copy is no longer needed.
     +    deletes the local branches that "--forked <branch>" would list,
     +    restricted to those whose tip is reachable from their configured
     +    upstream: the work has already landed on the upstream they track,
     +    so the local copy is no longer needed.
      
     -    The following branches are always preserved:
     +    Reachability is read from the local refs only -- nothing is
     +    fetched. Users who want fresh upstream refs run "git fetch" first;
     +    the deletion path stays a separate, idempotent step that also
     +    works offline.
      
     -    * the currently checked-out branch in any worktree;
     -    * any local branch whose name matches the default branch of
     -      any configured remote (the target of
     -      refs/remotes/<remote>/HEAD) -- typically 'main' or
     -      'master';
     -    * any branch whose upstream no longer resolves locally.
     +    Three classes of branches are spared:
      
     -    Reachability is read from whatever branch.<name>.merge
     -    resolves to locally, which is usually a remote-tracking ref
     -    but may also be a local branch. When the upstream is a
     -    remote-tracking ref, the natural workflow is
     +      * any branch checked out in any worktree;
     +      * any branch whose upstream no longer resolves locally (its
     +        disappearance is not, on its own, evidence of integration);
     +      * any branch whose push destination equals its upstream
     +        (<branch>@{push} == <branch>@{upstream}). Such a branch
     +        cannot be distinguished from a freshly pulled trunk that
     +        just looks "fully merged" -- e.g. local "main" tracking and
     +        pushing to "origin/main" right after a pull. Only branches
     +        that push somewhere other than their upstream (typically
     +        topics in a fork-based workflow) are treated as candidates.
      
     -            git fetch <remote>
     -            git branch --prune-merged <upstream-pattern>
     -
     -    so the upstream reflects the current state before pruning.
     +    Deletion goes through the existing delete_branches() in warn-only
     +    mode and with the HEAD-fallback disabled: a branch that is not
     +    yet fully merged to its upstream is reported as a one-line warning
     +    and skipped, so a single un-mergeable topic does not abort the
     +    whole sweep, and there is no fallback to "merged into the
     +    currently checked out branch" -- we only act on upstream-merged
     +    status.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
       
       DESCRIPTION
       -----------
     -@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
     - 	a ref (e.g. `origin/master`, `master`) or a shell-style
     - 	glob (e.g. `'origin/*'`). Multiple arguments are unioned.
     +@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`.  A bare configured-remote name
     + to match the way `git checkout -b topic origin` picks a starting
     + point.  Multiple _<branch>_ arguments are unioned.
       
      +`--prune-merged`::
     -+	Delete the local branches that `--forked` would list for
     -+	the same _<branch>_ arguments, but only those whose tip is
     -+	reachable from their configured upstream.
     ++	Delete the local branches that `--forked` would list for the
     ++	same _<branch>_ arguments, but only those whose tip is
     ++	reachable from their configured upstream.  In other words,
     ++	the work on the branch has already landed on the upstream it
     ++	tracks, so the local copy is no longer needed.
      ++
     -+For arguments that refer to remote-tracking branches, run
     -+`git fetch` first so reachability is checked against the
     -+current upstream state; refs are read locally.
     ++Reachability is checked against whatever the upstream refs say
     ++locally; nothing is fetched.  Run `git fetch` first if you want
     ++the upstream refs refreshed.
      ++
     -+The following branches are always preserved:
     ++A branch is left alone if any of the following holds:
     ++its upstream no longer resolves locally; it is checked out in any
     ++worktree; or its push destination (`<branch>@{push}`) equals its
     ++upstream (`<branch>@{upstream}`), so it cannot be distinguished
     ++from a freshly pulled trunk that just looks "fully merged".
      ++
     -+--
     -+* the currently checked-out branch in any worktree;
     -+* any local branch whose name matches the default branch of
     -+  any configured remote (the target of
     -+  `refs/remotes/<remote>/HEAD`) -- typically `main` or
     -+  `master`;
     -+* any branch whose upstream no longer resolves locally.
     -+--
     ++Branches refused by the "fully merged" safety check are listed as
     ++warnings and skipped; pass them to `git branch -D` explicitly if
     ++you want them gone.
      +
       `-v`::
       `-vv`::
       `--verbose`::
      
       ## builtin/branch.c ##
     -@@
     - #include "branch.h"
     - #include "path.h"
     - #include "string-list.h"
     -+#include "strvec.h"
     - #include "column.h"
     - #include "utf8.h"
     - #include "ref-filter.h"
     -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
     - 	N_("git branch [<options>] [-r | -a] [--points-at]"),
     - 	N_("git branch [<options>] [-r | -a] [--format]"),
     - 	N_("git branch [<options>] --forked <branch>..."),
     -+	N_("git branch [<options>] --prune-merged <branch>..."),
     - 	NULL
     - };
     - 
     -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
     - 	 * any of the following code, but during the transition period,
     - 	 * a gentle reminder is in order.
     - 	 */
     --	if (head_rev != reference_rev) {
     --		int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
     -+	if (head_rev && head_rev != reference_rev) {
     -+		int expect = repo_in_merge_bases(the_repository, rev, head_rev);
     - 		if (expect < 0)
     - 			exit(128);
     - 		if (expect == merged)
      @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
       	return 0;
       }
       
     -+static int collect_default_branch_name(struct remote *remote, void *cb_data)
     -+{
     -+	struct string_list *protected = cb_data;
     -+	struct ref_store *refs = get_main_ref_store(the_repository);
     -+	struct strbuf head = STRBUF_INIT;
     -+	const char *target;
     -+
     -+	strbuf_addf(&head, "refs/remotes/%s/HEAD", remote->name);
     -+	target = refs_resolve_ref_unsafe(refs, head.buf,
     -+					 RESOLVE_REF_NO_RECURSE, NULL, NULL);
     -+	if (target) {
     -+		const char *leaf = strrchr(target, '/');
     -+		if (leaf)
     -+			string_list_insert(protected, leaf + 1);
     -+	}
     -+	strbuf_release(&head);
     -+	return 0;
     +-static int list_forked_branches(int argc, const char **argv)
     ++static void collect_forked_set(int argc, const char **argv,
     ++			       struct string_list *out)
     + {
     + 	struct upstream_pattern *patterns = NULL;
     + 	size_t nr_patterns = 0;
     +-	struct string_list out = STRING_LIST_INIT_DUP;
     +-	struct string_list_item *item;
     + 	struct forked_cb cb;
     + 
     +-	if (!argc)
     +-		die(_("--forked requires at least one <branch>"));
     +-
     + 	parse_forked_args(argc, argv, &patterns, &nr_patterns);
     + 	cb.patterns = patterns;
     + 	cb.nr_patterns = nr_patterns;
     +-	cb.out = &out;
     ++	cb.out = out;
     + 
     + 	refs_for_each_branch_ref(get_main_ref_store(the_repository),
     + 				 collect_forked_branch, &cb);
     + 
     +-	string_list_sort(&out);
     ++	string_list_sort(out);
     ++
     ++	upstream_pattern_list_clear(patterns, nr_patterns);
      +}
      +
     - static void collect_forked_set(int argc, const char **argv,
     - 			       struct string_list *out)
     - {
     -@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
     ++static int list_forked_branches(int argc, const char **argv)
     ++{
     ++	struct string_list out = STRING_LIST_INIT_DUP;
     ++	struct string_list_item *item;
     ++
     ++	if (!argc)
     ++		die(_("--forked requires at least one <branch>"));
     ++
     ++	collect_forked_set(argc, argv, &out);
     + 	for_each_string_list_item(item, &out)
     + 		puts(item->string);
     + 
     +-	upstream_pattern_list_clear(patterns, nr_patterns);
     + 	string_list_clear(&out, 0);
       	return 0;
       }
       
     @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
      +{
      +	struct ref_store *refs = get_main_ref_store(the_repository);
      +	struct string_list candidates = STRING_LIST_INIT_DUP;
     -+	struct string_list protected_default_names = STRING_LIST_INIT_DUP;
      +	struct strvec deletable = STRVEC_INIT;
     -+	struct strbuf buf = STRBUF_INIT;
      +	struct string_list_item *item;
     -+	int n_not_merged = 0;
      +	int ret = 0;
      +
      +	if (!argc)
      +		die(_("--prune-merged requires at least one <branch>"));
      +
      +	collect_forked_set(argc, argv, &candidates);
     -+	for_each_remote(collect_default_branch_name, &protected_default_names);
      +
      +	for_each_string_list_item(item, &candidates) {
      +		const char *short_name = item->string;
     -+		const char *upstream = item->util;
     -+
     -+		strbuf_reset(&buf);
     -+		strbuf_addf(&buf, "refs/heads/%s", short_name);
     -+		if (branch_checked_out(buf.buf))
     ++		struct branch *branch = branch_get(short_name);
     ++		const char *upstream, *push;
     ++		struct strbuf full = STRBUF_INIT;
     ++		int skip;
     ++
     ++		strbuf_addf(&full, "refs/heads/%s", short_name);
     ++		skip = !!branch_checked_out(full.buf);
     ++		strbuf_release(&full);
     ++		if (skip)
      +			continue;
      +
     -+		if (string_list_has_string(&protected_default_names,
     -+					   short_name))
     ++		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
     ++		if (!upstream || !refs_ref_exists(refs, upstream))
      +			continue;
     -+
     -+		if (!refs_ref_exists(refs, upstream))
     ++		push = branch ? branch_get_push(branch, NULL) : NULL;
     ++		if (!push || !strcmp(push, upstream))
      +			continue;
      +
      +		strvec_push(&deletable, short_name);
      +	}
     -+	strbuf_release(&buf);
      +
      +	if (deletable.nr)
      +		ret = delete_branches(deletable.nr, deletable.v,
     -+				      0, FILTER_REFS_BRANCHES, quiet,
     -+				      1, &n_not_merged);
     -+
     -+	if (n_not_merged && !quiet)
     -+		fprintf(stderr,
     -+			Q_("Skipped %d branch that is not fully merged; "
     -+			   "delete it with 'git branch -D' if you are sure.\n",
     -+			   "Skipped %d branches that are not fully merged; "
     -+			   "delete them with 'git branch -D' if you are sure.\n",
     -+			   n_not_merged),
     -+			n_not_merged);
     ++				      0, /* force */
     ++				      FILTER_REFS_BRANCHES,
     ++				      quiet,
     ++				      1, /* warn_only */
     ++				      1, /* no_head_fallback */
     ++				      0  /* dry_run */);
      +
      +	strvec_clear(&deletable);
     -+	string_list_clear(&candidates, 1);
     -+	string_list_clear(&protected_default_names, 0);
     ++	string_list_clear(&candidates, 0);
      +	return ret;
      +}
      +
     @@ builtin/branch.c: int cmd_branch(int argc,
       		OPT_BOOL(0, "forked", &forked,
       			N_("list local branches whose upstream matches the given <branch>...")),
      +		OPT_BOOL(0, "prune-merged", &prune_merged,
     -+			N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
     ++			N_("delete local branches whose upstream matches the given <branch>... and is merged")),
       		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
       		OPT_MERGED(&filter, N_("print only branches that are merged")),
       		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
      +	git -C pm-upstream branch one HEAD~ &&
      +	git -C pm-upstream branch two HEAD &&
      +	git -C pm-upstream branch wip main &&
     -+	git -C pm-upstream checkout main
     ++	git -C pm-upstream checkout main &&
     ++	test_create_repo pm-fork
      +'
      +
      +test_expect_success '--prune-merged deletes branches integrated into upstream' '
      +	test_when_finished "rm -rf pm-merged" &&
      +	git clone pm-upstream pm-merged &&
     ++	git -C pm-merged remote add fork ../pm-fork &&
     ++	test_config -C pm-merged remote.pushDefault fork &&
     ++	test_config -C pm-merged push.default current &&
      +	git -C pm-merged branch one one-commit &&
      +	git -C pm-merged branch --set-upstream-to=origin/next one &&
      +	git -C pm-merged branch two two-commit &&
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
      +	test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
      +'
      +
     -+test_expect_success '--prune-merged with a literal upstream argument' '
     ++test_expect_success '--prune-merged accepts a literal upstream' '
      +	test_when_finished "rm -rf pm-literal" &&
      +	git clone pm-upstream pm-literal &&
     ++	git -C pm-literal remote add fork ../pm-fork &&
     ++	test_config -C pm-literal remote.pushDefault fork &&
     ++	test_config -C pm-literal push.default current &&
      +	git -C pm-literal branch one one-commit &&
      +	git -C pm-literal branch --set-upstream-to=origin/next one &&
     -+	git -C pm-literal branch keepme one-commit &&
     -+	git -C pm-literal branch --set-upstream-to=origin/main keepme &&
      +
      +	git -C pm-literal branch --prune-merged origin/next &&
      +
     -+	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&
     -+	git -C pm-literal rev-parse --verify refs/heads/keepme
     ++	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
      +'
      +
      +test_expect_success '--prune-merged unions multiple <branch> arguments' '
      +	test_when_finished "rm -rf pm-union" &&
      +	git clone pm-upstream pm-union &&
     ++	git -C pm-union remote add fork ../pm-fork &&
     ++	test_config -C pm-union remote.pushDefault fork &&
     ++	test_config -C pm-union push.default current &&
      +	git -C pm-union branch one one-commit &&
      +	git -C pm-union branch --set-upstream-to=origin/next one &&
      +	git -C pm-union branch two base &&
      +	git -C pm-union branch --set-upstream-to=origin/main two &&
     ++	git -C pm-union checkout --detach &&
      +
      +	git -C pm-union branch --prune-merged origin/next origin/main &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
      +	test_must_fail git -C pm-union rev-parse --verify refs/heads/two
      +'
      +
     -+test_expect_success '--prune-merged with a local-branch argument' '
     -+	test_create_repo pm-local &&
     ++test_expect_success '--prune-merged accepts a local upstream' '
      +	test_when_finished "rm -rf pm-local" &&
     -+	test_commit -C pm-local base &&
     -+	git -C pm-local branch topic base &&
     -+	git -C pm-local config branch.topic.remote . &&
     -+	git -C pm-local config branch.topic.merge refs/heads/main &&
     -+	git -C pm-local checkout --detach &&
     -+
     -+	git -C pm-local branch --prune-merged main &&
     -+
     -+	test_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&
     -+	git -C pm-local rev-parse --verify refs/heads/main
     ++	git clone pm-upstream pm-local &&
     ++	git -C pm-local remote add fork ../pm-fork &&
     ++	test_config -C pm-local remote.pushDefault fork &&
     ++	test_config -C pm-local push.default current &&
     ++	git -C pm-local checkout -b trunk &&
     ++	git -C pm-local branch one one-commit &&
     ++	git -C pm-local branch --set-upstream-to=trunk one &&
     ++	git -C pm-local merge --ff-only one-commit &&
     ++
     ++	git -C pm-local branch --prune-merged trunk &&
     ++
     ++	test_must_fail git -C pm-local rev-parse --verify refs/heads/one
      +'
      +
     -+test_expect_success '--prune-merged spares branches with un-integrated commits' '
     ++test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
      +	test_when_finished "rm -rf pm-unmerged" &&
      +	git clone pm-upstream pm-unmerged &&
     ++	git -C pm-unmerged remote add fork ../pm-fork &&
     ++	test_config -C pm-unmerged remote.pushDefault fork &&
     ++	test_config -C pm-unmerged push.default current &&
      +	git -C pm-unmerged checkout -b wip origin/wip &&
      +	git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
      +	test_commit -C pm-unmerged local-only &&
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
      +
      +	git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
      +	test_grep "not fully merged" err &&
     -+	test_grep "Skipped 1 branch" err &&
     -+	test_grep "git branch -D" err &&
      +	test_grep ! "If you are sure you want to delete it" err &&
      +	git -C pm-unmerged rev-parse --verify refs/heads/wip
      +'
      +
     ++test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
     ++	test_when_finished "rm -rf pm-nohead" &&
     ++	git clone pm-upstream pm-nohead &&
     ++	git -C pm-nohead remote add fork ../pm-fork &&
     ++	test_config -C pm-nohead remote.pushDefault fork &&
     ++	test_config -C pm-nohead push.default current &&
     ++	git -C pm-nohead branch topic one-commit &&
     ++	git -C pm-nohead branch --set-upstream-to=origin/next topic &&
     ++
     ++	git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
     ++
     ++	test_grep ! "not yet merged to HEAD" err &&
     ++	test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
     ++'
     ++
      +test_expect_success '--prune-merged skips branches whose upstream is gone' '
      +	test_when_finished "rm -rf pm-upstream-gone" &&
      +	git clone pm-upstream pm-upstream-gone &&
     ++	git -C pm-upstream-gone remote add fork ../pm-fork &&
     ++	test_config -C pm-upstream-gone remote.pushDefault fork &&
     ++	test_config -C pm-upstream-gone push.default current &&
      +	git -C pm-upstream-gone branch one one-commit &&
      +	git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
      +test_expect_success '--prune-merged never deletes the checked-out branch' '
      +	test_when_finished "rm -rf pm-head" &&
      +	git clone pm-upstream pm-head &&
     ++	git -C pm-head remote add fork ../pm-fork &&
     ++	test_config -C pm-head remote.pushDefault fork &&
     ++	test_config -C pm-head push.default current &&
      +	git -C pm-head checkout -b one one-commit &&
      +	git -C pm-head branch --set-upstream-to=origin/next one &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
      +	git -C pm-head rev-parse --verify refs/heads/one
      +'
      +
     -+test_expect_success '--prune-merged spares the local default branch' '
     -+	test_when_finished "rm -rf pm-default" &&
     -+	git clone pm-upstream pm-default &&
     -+	git -C pm-default checkout --detach &&
     -+	git -C pm-default branch --prune-merged "origin/*" &&
     -+	git -C pm-default rev-parse --verify refs/heads/main
     ++test_expect_success '--prune-merged spares branches that push back to their upstream' '
     ++	test_when_finished "rm -rf pm-push-eq" &&
     ++	git clone pm-upstream pm-push-eq &&
     ++	git -C pm-push-eq checkout --detach &&
     ++
     ++	git -C pm-push-eq branch --prune-merged "origin/*" &&
     ++
     ++	git -C pm-push-eq rev-parse --verify refs/heads/main
      +'
      +
     -+test_expect_success '--prune-merged protects the default branch by name only' '
     -+	test_when_finished "rm -rf pm-default-alias" &&
     -+	git clone pm-upstream pm-default-alias &&
     -+	git -C pm-default-alias branch --track trunk origin/main &&
     -+	git -C pm-default-alias checkout --detach &&
     -+	git -C pm-default-alias branch --prune-merged "origin/*" &&
     -+	git -C pm-default-alias rev-parse --verify refs/heads/main &&
     -+	test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
     ++test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
     ++	test_when_finished "rm -rf pm-push-branch" &&
     ++	git clone pm-upstream pm-push-branch &&
     ++	git -C pm-push-branch remote add fork ../pm-fork &&
     ++	test_config -C pm-push-branch remote.pushDefault fork &&
     ++	test_config -C pm-push-branch push.default current &&
     ++	test_config -C pm-push-branch branch.main.pushRemote origin &&
     ++	git -C pm-push-branch checkout --detach &&
     ++
     ++	git -C pm-push-branch branch --prune-merged "origin/*" &&
     ++
     ++	git -C pm-push-branch rev-parse --verify refs/heads/main
      +'
      +
     -+test_expect_success '--prune-merged with literal arg also protects default-name' '
     -+	test_when_finished "rm -rf pm-literal-default" &&
     -+	git clone pm-upstream pm-literal-default &&
     -+	git -C pm-literal-default checkout --detach &&
     -+	git -C pm-literal-default branch --prune-merged origin/main &&
     -+	git -C pm-literal-default rev-parse --verify refs/heads/main
     ++test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
     ++	test_when_finished "rm -rf pm-push-diff" &&
     ++	git clone pm-upstream pm-push-diff &&
     ++	git -C pm-push-diff remote add fork ../pm-fork &&
     ++	test_config -C pm-push-diff remote.pushDefault fork &&
     ++	test_config -C pm-push-diff push.default current &&
     ++	git -C pm-push-diff branch topic one-commit &&
     ++	git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
     ++	git -C pm-push-diff checkout --detach &&
     ++
     ++	git -C pm-push-diff branch --prune-merged "origin/*" &&
     ++
     ++	test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
      +'
      +
      +test_expect_success '--prune-merged requires at least one <branch>' '
     -+	test_must_fail git -C pm-upstream branch --prune-merged 2>err &&
     ++	test_must_fail git -C forked branch --prune-merged 2>err &&
      +	test_grep "at least one <branch>" err
      +'
      +
 3:  6e38d7af3a ! 5:  75b6d2366a branch: add branch.<name>.pruneMerged opt-out
     @@ Metadata
       ## Commit message ##
          branch: add branch.<name>.pruneMerged opt-out
      
     -    Setting branch.<name>.pruneMerged=false exempts that branch
     -    from --prune-merged. Useful for topic branches you intend to
     -    develop further after an initial round has been merged
     +    Setting branch.<name>.pruneMerged=false exempts that branch from
     +    "git branch --prune-merged". Useful for a topic branch you want
     +    to develop further after an initial round has been merged
          upstream.
      
     -    Explicit deletion via 'git branch -d' is unaffected.
     +    Unless --quiet is given, the skip is reported per branch so the
     +    user knows why their topic was preserved.
     +
     +    Explicit deletion via "git branch -d" continues to consult the
     +    normal merge check and is not affected by this setting.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/config/branch.adoc: for details).
      +
      +`branch.<name>.pruneMerged`::
      +	If set to `false`, branch _<name>_ is exempt from
     -+	`git branch --prune-merged`. Defaults to true. Explicit
     -+	deletion via `git branch -d` is unaffected.
     ++	`git branch --prune-merged`.  Useful for a topic branch you
     ++	intend to develop further after an initial round has been
     ++	merged upstream.  Defaults to true.  Explicit deletion via
     ++	`git branch -d` is unaffected.
      
       ## Documentation/git-branch.adoc ##
     -@@ Documentation/git-branch.adoc: The following branches are always preserved:
     -   any configured remote (the target of
     -   `refs/remotes/<remote>/HEAD`) -- typically `main` or
     -   `master`;
     -+* any branch with `branch.<name>.pruneMerged` set to `false`;
     - * any branch whose upstream no longer resolves locally.
     - --
     - 
     +@@ Documentation/git-branch.adoc: the upstream refs refreshed.
     + +
     + A branch is left alone if any of the following holds:
     + its upstream no longer resolves locally; it is checked out in any
     +-worktree; or its push destination (`<branch>@{push}`) equals its
     ++worktree; its push destination (`<branch>@{push}`) equals its
     + upstream (`<branch>@{upstream}`), so it cannot be distinguished
     +-from a freshly pulled trunk that just looks "fully merged".
     ++from a freshly pulled trunk that just looks "fully merged"; or
     ++`branch.<name>.pruneMerged` is set to `false`.
     + +
     + Branches refused by the "fully merged" safety check are listed as
     + warnings and skipped; pass them to `git branch -D` explicitly if
      
       ## builtin/branch.c ##
      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
     - 	for_each_string_list_item(item, &candidates) {
     - 		const char *short_name = item->string;
     - 		const char *upstream = item->util;
     -+		int prune_allowed = 1;
     + 		struct branch *branch = branch_get(short_name);
     + 		const char *upstream, *push;
     + 		struct strbuf full = STRBUF_INIT;
     ++		struct strbuf key = STRBUF_INIT;
     + 		int skip;
     ++		int opt_out;
       
     - 		strbuf_reset(&buf);
     - 		strbuf_addf(&buf, "refs/heads/%s", short_name);
     + 		strbuf_addf(&full, "refs/heads/%s", short_name);
     + 		skip = !!branch_checked_out(full.buf);
      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
     - 		if (!refs_ref_exists(refs, upstream))
     + 		if (!push || !strcmp(push, upstream))
       			continue;
       
     -+		strbuf_reset(&buf);
     -+		strbuf_addf(&buf, "branch.%s.prunemerged", short_name);
     -+		if (!repo_config_get_bool(the_repository, buf.buf,
     -+					  &prune_allowed) &&
     -+		    !prune_allowed) {
     ++		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
     ++		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
     ++		    !opt_out) {
      +			if (!quiet)
     -+				fprintf(stderr, _("Skipping '%s' "
     -+						  "(branch.%s.pruneMerged is false)\n"),
     ++				fprintf(stderr,
     ++					_("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
      +					short_name, short_name);
     ++			strbuf_release(&key);
      +			continue;
      +		}
     ++		strbuf_release(&key);
      +
       		strvec_push(&deletable, short_name);
       	}
     - 	strbuf_release(&buf);
     + 
      
       ## t/t3200-branch.sh ##
      @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '
     @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br
      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
      +	test_when_finished "rm -rf pm-optout" &&
      +	git clone pm-upstream pm-optout &&
     ++	git -C pm-optout remote add fork ../pm-fork &&
     ++	test_config -C pm-optout remote.pushDefault fork &&
     ++	test_config -C pm-optout push.default current &&
      +	git -C pm-optout branch one one-commit &&
      +	git -C pm-optout branch --set-upstream-to=origin/next one &&
      +	git -C pm-optout branch two two-commit &&
      +	git -C pm-optout branch --set-upstream-to=origin/next two &&
     -+	git -C pm-optout config branch.one.pruneMerged false &&
     ++	test_config -C pm-optout branch.one.pruneMerged false &&
      +
      +	git -C pm-optout branch --prune-merged "origin/*" 2>err &&
      +
     @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br
      +	git clone pm-upstream pm-optout-d &&
      +	git -C pm-optout-d branch one one-commit &&
      +	git -C pm-optout-d branch --set-upstream-to=origin/next one &&
     -+	git -C pm-optout-d config branch.one.pruneMerged false &&
     ++	test_config -C pm-optout-d branch.one.pruneMerged false &&
      +
      +	git -C pm-optout-d branch -d one &&
      +	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
 4:  c68d162e22 ! 6:  a1a42a6b19 branch: add --dry-run for --prune-merged
     @@ Metadata
       ## Commit message ##
          branch: add --dry-run for --prune-merged
      
     -    With --dry-run, --prune-merged prints the branches it would
     -    delete and exits without touching any ref. Useful for
     -    sanity-checking a glob like 'origin/*' before letting it run.
     +    With --dry-run, --prune-merged prints the local branches it would
     +    delete -- one "Would delete branch <name>" line per candidate --
     +    and exits without touching any ref.
     +
     +    This is the natural sanity check before letting a broad pattern
     +    like 'origin/*' run for real: the @{push}-vs-@{upstream} and
     +    unmerged filtering still applies, so the dry-run output is
     +    exactly the set that the live run would delete.
     +
     +    --dry-run is only meaningful in combination with --prune-merged
     +    and is rejected otherwise.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
       
       DESCRIPTION
       -----------
     -@@ Documentation/git-branch.adoc: The following branches are always preserved:
     - * any branch whose upstream no longer resolves locally.
     - --
     +@@ Documentation/git-branch.adoc: Branches refused by the "fully merged" safety check are listed as
     + warnings and skipped; pass them to `git branch -D` explicitly if
     + you want them gone.
       
      +`--dry-run`::
     -+	With `--prune-merged`, print the branches that would be
     -+	deleted instead of deleting them.
     ++	With `--prune-merged`, print which branches would be
     ++	deleted and exit without touching any ref.  Useful for
     ++	sanity-checking a wide pattern like `'origin/*'` before
     ++	committing to the deletion.
      +
       `-v`::
       `-vv`::
       `--verbose`::
      
       ## builtin/branch.c ##
     -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
     - 	N_("git branch [<options>] [-r | -a] [--points-at]"),
     - 	N_("git branch [<options>] [-r | -a] [--format]"),
     - 	N_("git branch [<options>] --forked <branch>..."),
     --	N_("git branch [<options>] --prune-merged <branch>..."),
     -+	N_("git branch [<options>] --prune-merged [--dry-run] <branch>..."),
     - 	NULL
     - };
     - 
     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
     - }
     - 
     - static int delete_branches(int argc, const char **argv, int force, int kinds,
     --			   int quiet, int warn_only, int *n_not_merged)
     -+			   int quiet, int warn_only, int dry_run,
     -+			   int *n_not_merged)
     - {
     - 	struct commit *head_rev = NULL;
     - 	struct object_id oid;
     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     - 			goto next;
     - 		}
     - 
     -+		if (dry_run) {
     -+			printf(_("Would delete branch '%s'\n"),
     -+			       name + branch_name_pos);
     -+			goto next;
     -+		}
     -+
     - 		item = string_list_append(&refs_to_delete, name);
     - 		item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
     - 				    : (flags & REF_ISSYMREF) ? target
      @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
       	return 0;
       }
       
      -static int prune_merged_branches(int argc, const char **argv, int quiet)
     -+static int prune_merged_branches(int argc, const char **argv,
     -+				 int dry_run, int quiet)
     ++static int prune_merged_branches(int argc, const char **argv, int quiet,
     ++				 int dry_run)
       {
       	struct ref_store *refs = get_main_ref_store(the_repository);
       	struct string_list candidates = STRING_LIST_INIT_DUP;
      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
     - 	if (deletable.nr)
     - 		ret = delete_branches(deletable.nr, deletable.v,
     - 				      0, FILTER_REFS_BRANCHES, quiet,
     --				      1, &n_not_merged);
     -+				      1, dry_run, &n_not_merged);
     + 				      quiet,
     + 				      1, /* warn_only */
     + 				      1, /* no_head_fallback */
     +-				      0  /* dry_run */);
     ++				      dry_run);
       
     - 	if (n_not_merged && !quiet)
     - 		fprintf(stderr,
     + 	strvec_clear(&deletable);
     + 	string_list_clear(&candidates, 0);
      @@ builtin/branch.c: int cmd_branch(int argc,
       	    unset_upstream = 0, show_current = 0, edit_description = 0;
       	int forked = 0;
     @@ builtin/branch.c: int cmd_branch(int argc,
      @@ builtin/branch.c: int cmd_branch(int argc,
       			N_("list local branches whose upstream matches the given <branch>...")),
       		OPT_BOOL(0, "prune-merged", &prune_merged,
     - 			N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
     + 			N_("delete local branches whose upstream matches the given <branch>... and is merged")),
      +		OPT_BOOL(0, "dry-run", &dry_run,
     -+			N_("with --prune-merged, only print what would be deleted")),
     ++			N_("with --prune-merged, only print which branches would be deleted")),
       		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
       		OPT_MERGED(&filter, N_("print only branches that are merged")),
       		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 	argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
     - 			     0);
     + 	if (noncreate_actions > 1)
     + 		usage_with_options(builtin_branch_usage, options);
       
      +	if (dry_run && !prune_merged)
      +		die(_("--dry-run requires --prune-merged"));
      +
     - 	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
     - 	    !show_current && !unset_upstream && !forked && !prune_merged &&
     - 	    argc == 0)
     + 	if (recurse_submodules_explicit) {
     + 		if (!submodule_propagate_branches)
     + 			die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 		if (!argc)
     - 			die(_("branch name required"));
     - 		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     --				      quiet, 0, NULL);
     -+				      quiet, 0, 0, NULL);
     - 		goto out;
     - 	} else if (forked) {
       		ret = list_forked_branches(argc, argv);
       		goto out;
       	} else if (prune_merged) {
      -		ret = prune_merged_branches(argc, argv, quiet);
     -+		ret = prune_merged_branches(argc, argv, dry_run, quiet);
     ++		ret = prune_merged_branches(argc, argv, quiet, dry_run);
       		goto out;
       	} else if (show_current) {
       		print_current_branch_name();
     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
       	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
       '
       
     -+test_expect_success '--prune-merged --dry-run prints but does not delete' '
     -+	test_when_finished "rm -rf pm-dryrun" &&
     -+	git clone pm-upstream pm-dryrun &&
     -+	git -C pm-dryrun branch one one-commit &&
     -+	git -C pm-dryrun branch --set-upstream-to=origin/next one &&
     ++test_expect_success '--prune-merged --dry-run lists but does not delete' '
     ++	test_when_finished "rm -rf pm-dry" &&
     ++	git clone pm-upstream pm-dry &&
     ++	git -C pm-dry remote add fork ../pm-fork &&
     ++	test_config -C pm-dry remote.pushDefault fork &&
     ++	test_config -C pm-dry push.default current &&
     ++	git -C pm-dry branch one one-commit &&
     ++	git -C pm-dry branch --set-upstream-to=origin/next one &&
     ++	git -C pm-dry branch two two-commit &&
     ++	git -C pm-dry branch --set-upstream-to=origin/next two &&
     ++
     ++	git -C pm-dry branch --prune-merged --dry-run "origin/*" >actual &&
     ++	test_grep "Would delete branch one " actual &&
     ++	test_grep "Would delete branch two " actual &&
      +
     -+	git -C pm-dryrun branch --prune-merged --dry-run "origin/*" >out &&
     -+	test_grep "Would delete branch .one." out &&
     -+	git -C pm-dryrun rev-parse --verify refs/heads/one
     ++	git -C pm-dry rev-parse --verify refs/heads/one &&
     ++	git -C pm-dry rev-parse --verify refs/heads/two
      +'
      +
     -+test_expect_success '--prune-merged --dry-run skips un-integrated branches' '
     -+	test_when_finished "rm -rf pm-dryrun-unmerged" &&
     -+	git clone pm-upstream pm-dryrun-unmerged &&
     -+	git -C pm-dryrun-unmerged checkout -b wip origin/next &&
     -+	git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&
     -+	test_commit -C pm-dryrun-unmerged local-only &&
     -+	git -C pm-dryrun-unmerged checkout - &&
     -+	git -C pm-dryrun-unmerged branch merged one-commit &&
     -+	git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&
     ++test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
     ++	test_when_finished "rm -rf pm-dry-mixed" &&
     ++	git clone pm-upstream pm-dry-mixed &&
     ++	git -C pm-dry-mixed remote add fork ../pm-fork &&
     ++	test_config -C pm-dry-mixed remote.pushDefault fork &&
     ++	test_config -C pm-dry-mixed push.default current &&
     ++	git -C pm-dry-mixed checkout -b wip origin/next &&
     ++	git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
     ++	test_commit -C pm-dry-mixed local-only &&
     ++	git -C pm-dry-mixed checkout - &&
     ++	git -C pm-dry-mixed branch merged one-commit &&
     ++	git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
      +
     -+	git -C pm-dryrun-unmerged branch --prune-merged --dry-run "origin/*" \
     -+		>out 2>err &&
     -+	test_grep "Would delete branch .merged." out &&
     -+	test_grep ! "Would delete branch .wip." out &&
     -+	test_grep "not fully merged" err &&
     -+	git -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&
     -+	git -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged
     ++	git -C pm-dry-mixed branch --prune-merged --dry-run "origin/*" >out &&
     ++	test_grep "Would delete branch merged" out &&
     ++	test_grep ! "Would delete branch wip" out &&
     ++	git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
     ++	git -C pm-dry-mixed rev-parse --verify refs/heads/merged
      +'
      +
     -+test_expect_success '--dry-run requires --prune-merged' '
     -+	test_must_fail git -C pm-upstream branch --dry-run 2>err &&
     ++test_expect_success '--dry-run without --prune-merged is rejected' '
     ++	test_must_fail git -C forked branch --dry-run 2>err &&
      +	test_grep "requires --prune-merged" err
      +'
      +

-- 
gitgitgadget

^ permalink raw reply

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

From: Harald Nordgren <haraldnordgren@gmail.com>

List local branches whose configured upstream
(branch.<name>.merge resolved against branch.<name>.remote)
matches any of the given <branch> arguments.

Each <branch> is interpreted against the local repository, not
against any specific remote:

  * a literal upstream short name, e.g. "origin/main" or "master"
    for a branch whose upstream is local;
  * a wildmatch pattern, e.g. "origin/*";
  * a bare configured-remote name, e.g. "origin", which resolves
    to whatever refs/remotes/origin/HEAD points at, matching how
    "git checkout -b topic origin" picks a starting point.

The literal-vs-wildcard distinction is settled at parse time so
the per-branch matching loop calls wildmatch() only for genuine
wildcards. Multiple <branch> arguments are unioned. Output is
sorted by branch name.

This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  13 +++
 builtin/branch.c              | 168 +++++++++++++++++++++++++++++++++-
 t/t3200-branch.sh             |  90 ++++++++++++++++++
 3 files changed, 269 insertions(+), 2 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..a37d3a12cb 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,18 @@ This option is only applicable in non-verbose mode.
 	Print the name of the current branch. In detached `HEAD` state,
 	nothing is printed.
 
+`--forked`::
+	List local branches whose configured upstream
+	(`branch.<name>.merge` resolved against `branch.<name>.remote`)
+	matches any of the given _<branch>_ arguments.
++
+Each _<branch>_ is interpreted against the local repository: a literal
+upstream like `origin/main` or a local branch like `master`, or a
+wildmatch pattern like `'origin/*'`.  A bare configured-remote name
+(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,
+to match the way `git checkout -b topic origin` picks a starting
+point.  Multiple _<branch>_ arguments are unioned.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..2d34ad34dc 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
 };
 
@@ -673,6 +675,162 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
 	free_worktrees(worktrees);
 }
 
+struct upstream_pattern {
+	char *name;
+	int is_wildcard;
+};
+
+static void upstream_pattern_list_clear(struct upstream_pattern *items,
+					size_t nr)
+{
+	size_t i;
+	for (i = 0; i < nr; i++)
+		free(items[i].name);
+	free(items);
+}
+
+static const char *short_upstream_name(const char *full_ref)
+{
+	const char *short_name = full_ref;
+	(void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
+	       skip_prefix(short_name, "refs/remotes/", &short_name));
+	return short_name;
+}
+
+static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
+{
+	struct ref_store *refs = get_main_ref_store(the_repository);
+	struct remote *remote;
+	struct object_id oid;
+	char *full_ref = NULL;
+	struct strbuf head_ref = STRBUF_INIT;
+	const char *resolved;
+
+	if (has_glob_specials(arg)) {
+		out->name = xstrdup(arg);
+		out->is_wildcard = 1;
+		return 0;
+	}
+
+	remote = remote_get(arg);
+	if (remote && remote_is_configured(remote, 0)) {
+		strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote->name);
+		resolved = refs_resolve_ref_unsafe(refs, head_ref.buf,
+						   RESOLVE_REF_NO_RECURSE,
+						   NULL, NULL);
+		if (resolved && starts_with(resolved, "refs/remotes/")) {
+			out->name = xstrdup(short_upstream_name(resolved));
+			out->is_wildcard = 0;
+			strbuf_release(&head_ref);
+			return 0;
+		}
+		strbuf_release(&head_ref);
+	}
+
+	if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+			  &full_ref, 0) == 1 &&
+	    (starts_with(full_ref, "refs/heads/") ||
+	     starts_with(full_ref, "refs/remotes/"))) {
+		out->name = xstrdup(short_upstream_name(full_ref));
+		out->is_wildcard = 0;
+		free(full_ref);
+		return 0;
+	}
+	free(full_ref);
+	return -1;
+}
+
+static void parse_forked_args(int argc, const char **argv,
+			      struct upstream_pattern **patterns_out,
+			      size_t *nr_out)
+{
+	struct upstream_pattern *patterns;
+	int i;
+
+	ALLOC_ARRAY(patterns, argc);
+	for (i = 0; i < argc; i++) {
+		if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
+			upstream_pattern_list_clear(patterns, i);
+			die(_("'%s' is not a valid branch or pattern"),
+			    argv[i]);
+		}
+	}
+	*patterns_out = patterns;
+	*nr_out = argc;
+}
+
+static int upstream_matches(const char *short_upstream,
+			    const struct upstream_pattern *patterns,
+			    size_t nr)
+{
+	size_t i;
+
+	for (i = 0; i < nr; i++) {
+		const struct upstream_pattern *p = &patterns[i];
+		if (p->is_wildcard) {
+			if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
+				return 1;
+		} else if (!strcmp(p->name, short_upstream)) {
+			return 1;
+		}
+	}
+	return 0;
+}
+
+struct forked_cb {
+	const struct upstream_pattern *patterns;
+	size_t nr_patterns;
+	struct string_list *out;
+};
+
+static int collect_forked_branch(const struct reference *ref, void *cb_data)
+{
+	struct forked_cb *cb = cb_data;
+	struct branch *branch;
+	const char *upstream;
+
+	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;
+	if (upstream_matches(short_upstream_name(upstream),
+			     cb->patterns, cb->nr_patterns))
+		string_list_append(cb->out, ref->name);
+	return 0;
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+	struct upstream_pattern *patterns = NULL;
+	size_t nr_patterns = 0;
+	struct string_list out = STRING_LIST_INIT_DUP;
+	struct string_list_item *item;
+	struct forked_cb cb;
+
+	if (!argc)
+		die(_("--forked requires at least one <branch>"));
+
+	parse_forked_args(argc, argv, &patterns, &nr_patterns);
+	cb.patterns = patterns;
+	cb.nr_patterns = nr_patterns;
+	cb.out = &out;
+
+	refs_for_each_branch_ref(get_main_ref_store(the_repository),
+				 collect_forked_branch, &cb);
+
+	string_list_sort(&out);
+	for_each_string_list_item(item, &out)
+		puts(item->string);
+
+	upstream_pattern_list_clear(patterns, nr_patterns);
+	string_list_clear(&out, 0);
+	return 0;
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -714,6 +872,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 +926,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 +972,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 +981,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);
 
@@ -860,6 +1021,9 @@ int cmd_branch(int argc,
 			die(_("branch name required"));
 		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
 		goto out;
+	} else if (forked) {
+		ret = list_forked_branches(argc, argv);
+		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
 		ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..013ddfb65d 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,94 @@ test_expect_success 'errors if given a bad branch name' '
 	test_cmp expect actual
 '
 
+test_expect_success '--forked: setup' '
+	test_create_repo forked-upstream &&
+	test_commit -C forked-upstream base &&
+	git -C forked-upstream branch one base &&
+	git -C forked-upstream branch two base &&
+
+	test_create_repo forked-other &&
+	test_commit -C forked-other other-base &&
+	git -C forked-other branch foreign other-base &&
+
+	git clone forked-upstream forked &&
+	git -C forked remote add other ../forked-other &&
+	git -C forked fetch other &&
+	git -C forked branch local-base &&
+	git -C forked branch --track local-one origin/one &&
+	git -C forked branch --track local-two origin/two &&
+	git -C forked branch --track local-foreign other/foreign &&
+	git -C forked branch detached &&
+	git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
+	git -C forked branch --forked origin/one >actual &&
+	echo local-one >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> matches by wildmatch' '
+	git -C forked branch --forked "origin/*" >actual &&
+	cat >expect <<-\EOF &&
+	local-one
+	local-two
+	main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+	git -C forked branch --forked local-base >actual &&
+	echo local-trunk >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '
+	test_when_finished "git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main" &&
+	git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&
+	git -C forked branch --forked origin >actual &&
+	echo local-one >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked unions multiple <branch> arguments' '
+	git -C forked branch --forked origin/one other/foreign >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+	git -C forked branch --forked local-base "other/*" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-trunk
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+	git -C forked branch --forked "*/*" >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


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