Git development
 help / color / mirror / Atom feed
* Re: [PATCH 8/8] setup: construct object database in `apply_repository_format()`
From: Patrick Steinhardt @ 2026-05-22  6:03 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git
In-Reply-To: <xmqq4ik0zls3.fsf@gitster.g>

On Fri, May 22, 2026 at 02:59:24AM +0900, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > With the preceding changes we now always construct the repository's
> > object database before applying the repository format. Remove this
> > duplication by constructing it in `apply_repository_format()` instead.
> >
> > Note that we create the object database _after_ having set up the
> > repository's hash algorithm, but _before_ setting the compat hash
> > algorithm. This is intentional:
> >
> >   - Constructing the object database may require knowledge of its
> >     intended object format.
> >
> >   - Setting up the compatibility hash requires the object database to be
> >     initialized already, because we immediately read the loose object
> >     map.
> >
> > The first point is sensible, the second maybe a little less so. Ideally,
> > it should be the responsibility of the object database itself to
> > initialize any data structures required for the compatibility hash. But
> > this would require further changes, so this is kept as-is for now.
> 
> Yeah, I guess it is a good place to stop, instead of solving the
> chicken-and-egg problem in one go.
> 
> > Further note that this requires us to move handling of the environment
> > variables GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES into
> > the repository format, as well. This allows the caller more flexibility
> > around whether or not those environment variables are being honored, as
> > we do do want to respect them in "setup.c", but not in "repository.c".
> 
> It seems that we really really really want to do so ;-).  "do do
> want to" -> "do want to" or even "want to", perhaps.

Fixed locally, thanks! :)

Patrick

^ permalink raw reply

* Re: [PATCH 1/8] t0001: plug test gaps for git-init(1) with GIT_OBJECT_DIRECTORY
From: Patrick Steinhardt @ 2026-05-22  6:06 UTC (permalink / raw)
  To: Kristoffer Haugsbakk; +Cc: git
In-Reply-To: <741c2a26-7380-4d8e-aa91-fb237e9f10dc@app.fastmail.com>

On Thu, May 21, 2026 at 07:51:59PM +0200, Kristoffer Haugsbakk wrote:
> On Thu, May 21, 2026, at 09:42, Patrick Steinhardt wrote:
> > In subsequent commits we'll rework how we set up the repository. This is
> > a somewhat intricate and thus fragile sequence, there's many things that
> 
> Should this be s/, there/; there/ ? Depends on if this is a list of
> three items or if “This is” is a subclause that is supposed to point at
> “there's many”.

That reads a bit better.

> > can go subtly wrong, and there are lots of interesting interactions that
> > one can discover.
> >
> > One such discovered edge case was the interaction between git-init(1)
> > and the "GIT_OBJECT_DIRECTORY" enviroment variable. When set, the
> > behaviour is that the object directory should be created at the path
> > that the variable points to. This behaviour is documented as such in
> > its man page:
> >
> >   If the object storage directory is specified via the
> >   GIT_OBJECT_DIRECTORY environment variable then the sha1 directories
> >   are created underneath; otherwise, the default $GIT_DIR/objects
> >   directory is used.
> >
> > Curiously enough though we don't seem to have any tests that exercise
> > this directly, and thus a subsequent commit inadvertently broke this
> > expectation.
> 
> Isn’t it more that “the upcoming changes *would have* broken” them if
> not for this change? This seems to refer to a an alternative commit
> history where this change does not exist?

Grammar is hard :) But yeah, this of course refers to an alternative
commit history I had at one point in time that did break this.

Fixed locally, will wait a bit before sending out the next version.

Patrick

^ permalink raw reply

* Re: [PATCH 4/9] run-command: add support for timeout in command finisher
From: Siddh Raman Pant @ 2026-05-22  5:59 UTC (permalink / raw)
  To: j6t@kdbg.org, peff@peff.net
  Cc: git@vger.kernel.org, gitster@pobox.com, newren@gmail.com,
	ps@pks.im, oswald.buddenhagen@gmx.de, code@khaugsbakk.name
In-Reply-To: <20260522051048.GA862219@coredump.intra.peff.net>

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

On Fri, May 22 2026 at 10:40:48 +0530, Jeff King wrote:
> On Thu, May 21, 2026 at 04:36:05PM +0200, Johannes Sixt wrote:
> 
> > Am 21.05.26 um 11:59 schrieb Siddh Raman Pant:
> > > The timeout is for the failure path, where the external helper has
> > > already stopped following that protocol or is blocked on something
> > > outside git's control. Since git starts the helper and puts it on the
> > > log/grep path, git also needs a bounded way to recover when that helper
> > > does not make progress. Otherwise an optional note source can prevent
> > > the main git command from completing.
> > 
> > That Git communicates with a process that looks like it stopped is the
> > normal case, for example:
> > 
> > - Output is sent to the pager. The user can take their time to study the
> > output. All the while, git waits patiently for the user to advance the
> > pager.
> > 
> > - Git fetch transfers large amounts of data across the network. Most of
> > the time it waits for data to arrive and does nothing. The peer process
> > looks like it hangs. Git does not decide to kill the connection at any
> > time. It is the user's decision to do so.
> > 
> > If the notes provider hangs, then it is not on Git to decide when it has
> > waited long enough.
> 
> Yeah, I agree with your point of view. If I understand this patch series
> correctly, it is about adding an external process to map commit ids to
> note data. So I can think of some existing features that are quite close
> to that in nature, none of which use timeouts:
> 
>   - textconv filters and external diffs which process data in the middle
>     of a git-log invocation
> 
>   - long-lived clean/smudge filters map blobs to arbitrarily large text
> 
>   - cat-file's batch mode maps object ids to user-specified data about
>     that object
> 
> As you note, it's up to the command to be well-behaved. Git should
> notice and respond appropriately if the command closes the pipe, of
> course. Sometimes a timeout can help with a poorly behaved command, but
> IMHO it is not worth the cost of non-determinism that it brings.
> 
> Moreover, the bits touching run-command here make me nervous, especially
> after the challenges we saw in the child-cleanup topic that was reverted
> just after v2.54. There is often a shell interposed between Git and the
> sub-command, and we don't always know how the shell will react to
> signals. Using SIGKILL will eventually get us _something_ to wait() on,
> but it might not even be the process we care about!
> 
> I don't really care much about this external-notes feature one way or
> the other, but if we are going to do it, I don't see any reason why it
> would not behave like all of the other similar parts of Git.
> 
> -Peff

Okay, since the consensus here is pretty clear, I will remove this
commit and send a v2.

Thanks,
Siddh

[-- Attachment #2: This is a digitally signed message part --]
[-- Type: application/pgp-signature, Size: 833 bytes --]

^ permalink raw reply

* Re: [PATCH 16/18] odb/source-loose: wire up `write_object_stream()` callback
From: Patrick Steinhardt @ 2026-05-22  6:12 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git
In-Reply-To: <xmqq8q9czm8r.fsf@gitster.g>

On Fri, May 22, 2026 at 02:49:24AM +0900, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > -int odb_source_loose_write_stream(struct odb_source_loose *loose,
> > +/*
> > + * Write the given stream into the loose object source. The only difference to
> > + * the generic implementation of this function is that we don't perform an
> 
> "difference to" -> "difference from"???

I guess this is a difference between American and British English. "to"
is more popular in British English, but basically not used at all in
American English. Will adapt, thanks.

Patrick

^ permalink raw reply

* [PATCH] compat/mingw: Allow SIGKILL to kill in mingw_kill.
From: Siddh Raman Pant @ 2026-05-22  6:16 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano, Kristoffer Haugsbakk, Elijah Newren,
	Patrick Steinhardt

mingw_kill() only allows SIGTERM for killing a process.

Let's also allow the natural SIGKILL for the same so that callers don't
have to do ifdef soup for special Windows handling.

Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>
---
 compat/mingw.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/compat/mingw.c b/compat/mingw.c
index aa7525f419cb..00a994aa9f47 100644
--- a/compat/mingw.c
+++ b/compat/mingw.c
@@ -2250,7 +2250,7 @@ int mingw_execvp(const char *cmd, char *const *argv)
 
 int mingw_kill(pid_t pid, int sig)
 {
-	if (pid > 0 && sig == SIGTERM) {
+	if (pid > 0 && (sig == SIGTERM || sig == SIGKILL)) {
 		HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
 
 		if (TerminateProcess(h, -1)) {
-- 
2.53.0


^ permalink raw reply related

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

Am 22.05.26 um 00:40 schrieb Harald Nordgren via GitGitGadget:
> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index c0afddc424..3a421f6663 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
>  git branch (-c|-C) [<old-branch>] <new-branch>
>  git branch (-d|-D) [-r] <branch-name>...
>  git branch --edit-description [<branch-name>]
> +git branch --forked <branch>...

I would have preferred that this option is another filter of --list
mode, not its own mode of operation. Consequently, each --forked option
would take only a single argument (which can contain globs), and can be
given multiple times.

>  
>  DESCRIPTION
>  -----------
> @@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.
>  	Print the name of the current branch. In detached `HEAD` state,
>  	nothing is printed.
>  
> +`--forked`::
> +	List local branches whose configured upstream matches any
> +	of the given _<branch>_ arguments. Each argument is either
> +	a ref (e.g. `origin/master`, `master`) or a shell-style
> +	glob (e.g. `'origin/*'`). Multiple arguments are unioned.

So this could perhaps read:

`--forked`::
	List only branches whose configured upstream matches
	_<branch>_. The argument can contain a shell-style glob
	 (e.g. `'origin/*'`). The option can be repeated to
	widen the filter.

Note that there is no reason to say "local branches". ("... are unioned"
sounds strange, so this is may attempt to express the same in a
different way.)

The icing on the cake would now be that

    git branch --merged origin/main --forked origin/*

provides the list of branches forked from origin that have already been
integrated.

-- Hannes


^ permalink raw reply

* Re: [PATCH] compat/mingw: Allow SIGKILL to kill in mingw_kill.
From: Junio C Hamano @ 2026-05-22  6:32 UTC (permalink / raw)
  To: Siddh Raman Pant, Johannes Sixt, Johannes Schindelin
  Cc: git, Kristoffer Haugsbakk, Elijah Newren, Patrick Steinhardt
In-Reply-To: <20260522061652.50078-1-siddh.raman.pant@oracle.com>

Siddh Raman Pant <siddh.raman.pant@oracle.com> writes:

> mingw_kill() only allows SIGTERM for killing a process.
>
> Let's also allow the natural SIGKILL for the same so that callers don't
> have to do ifdef soup for special Windows handling.
>
> Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>
> ---
>  compat/mingw.c | 2 +-
>  1 file changed, 1 insertion(+), 1 deletion(-)

I do not do windows, so I'd like to ask those much more clueful than
I am to see if they see any downsides.

The current code only handles TERM (to terminate) or 0 (to probe)
and everything else results in EINVAL, so the updated behaviour is
to pretend as if TERM is sent and do whatever PROCESS_TERMINATE
does, instead of doing nothing and erroring with EINVAL.  Which does
sound like an improvement over the status quo.

What I am wondering is if there are different kind of "kill" in the
Windows land, just like there are distinction between TERM and KILL.
For example, the program ought to be able to block TERM but not
KILL.  There are other termination-inducing signals like SIGQUIT but
until we start using them in our code, this emulation layer does not
have to know about them, I think.

Thanks.

> diff --git a/compat/mingw.c b/compat/mingw.c
> index aa7525f419cb..00a994aa9f47 100644
> --- a/compat/mingw.c
> +++ b/compat/mingw.c
> @@ -2250,7 +2250,7 @@ int mingw_execvp(const char *cmd, char *const *argv)
>  
>  int mingw_kill(pid_t pid, int sig)
>  {
> -	if (pid > 0 && sig == SIGTERM) {
> +	if (pid > 0 && (sig == SIGTERM || sig == SIGKILL)) {
>  		HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
>  
>  		if (TerminateProcess(h, -1)) {

^ permalink raw reply

* Re: [PATCH v10 1/4] branch: add --forked <branch>
From: Junio C Hamano @ 2026-05-22  6:36 UTC (permalink / raw)
  To: Johannes Sixt
  Cc: Harald Nordgren, Kristoffer Haugsbakk, Phillip Wood, git,
	Harald Nordgren via GitGitGadget
In-Reply-To: <273103d7-c816-4cde-9e89-b630c37b0749@kdbg.org>

Johannes Sixt <j6t@kdbg.org> writes:

> The icing on the cake would now be that
>
>     git branch --merged origin/main --forked origin/*
>
> provides the list of branches forked from origin that have already been
> integrated.

Yup, that is very nice.  Also with "--merged" replaced with
"--not-merged", i.e., "our work building on top of origin's, and
still need to be finished", would give us a good list to work on.

^ permalink raw reply

* [PATCH] doc: clarify push.default=simple in triangular workflows
From: Ivan Baluta via GitGitGadget @ 2026-05-22  6:58 UTC (permalink / raw)
  To: git; +Cc: Ivan Baluta, ivanbaluta

From: ivanbaluta <ivanbaluta.dev@gmail.com>

The documentation for 'simple' push mode currently focuses on the
centralized workflow. However, the implementation in builtin/push.c
falls back to 'current' behavior when pushing to a remote different
from the upstream (a triangular workflow).

Clarify this in the manual to align the documentation with the
long-standing implementation and prevent user confusion.

Signed-off-by: ivanbaluta <ivanbaluta.dev@gmail.com>
---
    doc: clarify push.default=simple in triangular workflows

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2115%2Fivanbaluta%2Fdoc-push-simple-triangular-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2115/ivanbaluta/doc-push-simple-triangular-v1
Pull-Request: https://github.com/gitgitgadget/git/pull/2115

 Documentation/config/push.adoc | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/Documentation/config/push.adoc b/Documentation/config/push.adoc
index d9112b2260..979e40c3a4 100644
--- a/Documentation/config/push.adoc
+++ b/Documentation/config/push.adoc
@@ -45,6 +45,9 @@ If you are working on a centralized workflow (pushing to the same repository you
 pull from, which is typically `origin`), then you need to configure an upstream
 branch with the same name.
 +
+In a triangular workflow (pushing to a remote different from the upstream),
+`simple` behaves like `current`.
++
 This mode is the default since Git 2.0, and is the safest option suited for
 beginners.
 

base-commit: 59ff4886a579f4bc91e976fe18590b9ae02c7a08
-- 
gitgitgadget

^ permalink raw reply related

* Re: [PATCH v3] git-jump: pick a mode automatically when invoked without arguments
From: Greg Hurrell @ 2026-05-22  7:33 UTC (permalink / raw)
  To: Jeff King, Greg Hurrell
  Cc: git, Erik Cervin Edin, Junio C Hamano,
	Gregory Luke Hurrell Stewart
In-Reply-To: <20260522052821.GC861761@coredump.intra.peff.net>

On Fri, May 22, 2026, at 7:28 AM, Jeff King wrote:
> 
> My impression of the "auto" feature is: I am too lazy to type, so just
> take me to the interesting bits. And interesting in my experience with
> git-jump is either "I am merging, take me to the conflict" or "I am
> writing new code, take me to what I already did". Limiting the second
> case just to whitespace violations (assuming there is at least one)
> would probably be more confusing than helpful.

> If sounds like Greg has been living with "auto" and finding it useful
> for a while. So I'm mostly inclined to take the patch as-is, and people
> can experiment with it and suggest changes after using it in practice.

Yes, the "take me to the interesting bits" is very much the mental model
I've been operating with, using the simplest definition of "interesting"
("merge conflicts", followed by "changes in the worktree"). I think that
starting simple, but leaving the door open to possibly introducing more
subtleties in the future makes the most sense.

- Greg

^ permalink raw reply

* I discovered a minor issue with `git fetch`.
From: SURA @ 2026-05-22  7:45 UTC (permalink / raw)
  To: git

Hello everyone

The child processes spawned by `git fetch` can become zombie processes.
In most scenarios, these zombie processes are reaped by Process 1, so
this typically doesn't cause any problems.

However, within a Docker container, the application service itself is
sometimes designated as Process 1 (for instance, a service written in
Go). Since these application services lack the capability to reap
zombie processes, the zombies will gradually exhaust the available PID
resources.

Here are the simple steps to reproduce this issue:
1. `git clone https://github.com/SURA907/pid-1.git`
2. `cd pid-1`
3. `docker build -t pid-1 .`
4. `docker run -d --name pid-1 pid-1:latest`
5. `docker exec -it pid-1 /bin/bash`
6. `mkdir repo && cd repo && git init --bare`
7. `ps -ef`
------
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:16 ? 00:00:00 tail -f /dev/null
root 7 0 0 07:16 pts/0 00:00:00 /bin/bash
root 13 0 0 07:16 pts/1 00:00:00 /bin/bash
root 29 7 0 07:17 pts/0 00:00:00 ps -ef
------

8. `git fetch https://github.com/git/git.git`
9. `ps -ef` (Run this command from a separate terminal session
connected to the container)
------
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:16 ? 00:00:00 tail -f /dev/null
root 7 0 0 07:16 pts/0 00:00:00 /bin/bash
root 13 0 0 07:16 pts/1 00:00:00 /bin/bash
root 30 13 1 07:17 pts/1 00:00:00 git fetch https://github.com/git/git.git
root 31 30 0 07:17 pts/1 00:00:00 /usr/local/libexec/git-core/git
remote-https https://github.com/git/git.git
https://github.com/git/git.git
root 32 31 2 07:17 pts/1 00:00:00
/usr/local/libexec/git-core/git-remote-https
https://github.com/git/git.git https://github.com/git/git.git
root 36 30 30 07:17 pts/1 00:00:00 /usr/local/libexec/git-core/git
index-pack --stdin -v --fix-thin --keep=fetch-pack 30 on sura-pc
--pack_header=2,399455
root 38 7 0 07:17 pts/0 00:00:00 ps -ef
------

10. ps -ef (after fetch ends)
------
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:16 ? 00:00:00 tail -f /dev/null
root 7 0 0 07:16 pts/0 00:00:00 /bin/bash
root 13 0 0 07:16 pts/1 00:00:00 /bin/bash
root 52 1 0 07:19 ? 00:00:00 [git] <defunct>
root 53 7 0 07:19 pts/0 00:00:00 ps -ef
------

A zombie process has appeared. It appears to originate from a `fetch`
subprocess that terminates very quickly; despite several attempts, I
have been unable to successfully capture it.

This issue was discovered within a legacy service. A few days after
upgrading to Git 2.53.0, the system's PID resources were exhausted by
zombie processes. This is likely the result of recent changes, as this
problem did not exist in earlier versions (2.4x).

To be honest, this is not an urgent matter; I have already deployed
`tini` as the init process (PID 1) to prevent the service from
becoming unavailable.

^ permalink raw reply

* Re: [PATCH v10 2/4] branch: add --prune-merged <branch>
From: Harald Nordgren @ 2026-05-22  7:59 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	Johannes Sixt, Phillip Wood
In-Reply-To: <xmqq1pf4w3x5.fsf@gitster.g>

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


Harald

^ permalink raw reply

* Re: [PATCH] compat/mingw: Allow SIGKILL to kill in mingw_kill.
From: Siddh Raman Pant @ 2026-05-22  8:03 UTC (permalink / raw)
  To: j6t@kdbg.org, gitster@pobox.com, Johannes.Schindelin@gmx.de
  Cc: git@vger.kernel.org, code@khaugsbakk.name, newren@gmail.com,
	ps@pks.im
In-Reply-To: <xmqqwlwwt0mj.fsf@gitster.g>

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

On Fri, May 22 2026 at 12:02:52 +0530, Junio C Hamano wrote:
> I do not do windows, so I'd like to ask those much more clueful than
> I am to see if they see any downsides.
> 
> The current code only handles TERM (to terminate) or 0 (to probe)
> and everything else results in EINVAL, so the updated behaviour is
> to pretend as if TERM is sent and do whatever PROCESS_TERMINATE
> does, instead of doing nothing and erroring with EINVAL.  Which does
> sound like an improvement over the status quo.
> 
> What I am wondering is if there are different kind of "kill" in the
> Windows land, just like there are distinction between TERM and KILL.
> For example, the program ought to be able to block TERM but not
> KILL.  There are other termination-inducing signals like SIGQUIT but
> until we start using them in our code, this emulation layer does not
> have to know about them, I think.

From what I can see from the docs, there is no SIGTERM on Windows
either. So I did this change since the SIGTERM handling just looks
like a compatibility change in our code.

The docs at [1] says:
	The SIGILL and SIGTERM signals aren't generated under Windows.
	They're included for ANSI compatibility. Therefore, you can set
	signal handlers for these signals by using signal, and you can
	also explicitly generate these signals by calling raise.

Our helper uses TerminateProcess(). The docs at [2] says:
	The TerminateProcess function is used to unconditionally cause
	a process to exit.
	[...]
	A process cannot prevent itself from being terminated.

which is like SIGKILL.

So currently SIGTERM on Windows is behaving like a SIGKILL.

Thanks,
Siddh

[1] https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/signal
[2] https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-terminateprocess

[-- Attachment #2: This is a digitally signed message part --]
[-- Type: application/pgp-signature, Size: 833 bytes --]

^ permalink raw reply

* Re: [PATCH v2 01/11] git-gui: guard set/unset of GIT_DIR and GIT_WORK_TREE
From: Johannes Sixt @ 2026-05-22  8:06 UTC (permalink / raw)
  To: Mark Levedahl, git; +Cc: egg_mushroomcow, bootaina702
In-Reply-To: <20260520202411.108764-2-mlevedahl@gmail.com>

Am 20.05.26 um 22:24 schrieb Mark Levedahl:
> git-gui unconditionally exports _gitdir as GIT_DIR, and _gitworktree as
> GIT_WORK_TREE, to the environment, and furthermore unconditionally
> unsets these environment variables in many

"many cases"?

> 
> git gui must have a repository, so _gitdir can never be empty and its
> export is always valid if repository discovery completes successfully.

_gitdir cannot be empty, so we should be able to drop the conditionals
around 'set env(GIT_DIR) $_gitdir'.

> 
> git gui might not find a worktree, so _gitworktree can be empty. While
> having no worktree is valid for blame/browser subcommands, exporting
> GIT_WORK_TREE=<empty> is not valid. Rather, an empty GIT_WORK_TREE
> raises errors in git builtins, for instance 'git branch --show-current'
> as used by git, and causes breakage. This is one cause of git blame /
> git browser not working without a worktree.
> 
> A user may set GIT_DIR and/or GIT_WORK_TREE to override git's normal
> discovery rules, including repository configuration of core.worktree
> and/or worktree specific gitdirs. It is always safe to export the
> absolute pathnames of the discovered values, even though they may not be
> needed. However, the gitdir might not be found from the worktree without
> GIT_DIR being set. Furthermore, the worktree defined by the discovered
> gitdir might be overridden by GIT_WORK_TREE set before git-gui started.
> So, it is also sometimes necessary that one or both of these variables
> is set.

While all you say here is true, the actual reason for the dance is more
like the simpler: provide a clean slate for the new process and return
to the old state after it has been started.

> 
> So, let's provide two procs, one to unset GIT_DIR / GIT_WORK_TREE if
> they are set, one to set GIT_DIR and, if not empty, GIT_WORK_TREE,  so
> all call sites do the same thing, and problems with _gitworktree == {}
> are avoided.

That being said, I propose the two patches below (pasted here for
review), after which we do not need these functions anymore IMHO
because the call sites are one-liners around GIT_DIR anyway.

The commits are available here:

git fetch https://github.com/j6t/git-gui.git js/unset-git-work-tree
https://github.com/j6t/git-gui/tree/js/unset-git-work-tree

------ 8< ------
Subject: [PATCH 1/2] git-gui: remove unnecessary 'cd $_gitworktree' from do_gitk

In the procedure that invokes Gitk, we have a 'cd $_gitworktree'. Such
a change of the current directory is not necessary, because

- if we have a working tree, then the startup routine has already
  changed the current directory to the root of the working tree, which
  *is* $_gitworktree; or

- if we are in a bare repository, then there is no point in changing
  the current directory anywhere. (And $_gitworktree is empty.)

Signed-off-by: Johannes Sixt <j6t@kdbg.org>
---
 git-gui.sh | 6 +-----
 1 file changed, 1 insertion(+), 5 deletions(-)

diff --git a/git-gui.sh b/git-gui.sh
index 23fe76e498bd..8d2b02b13fa0 100755
--- a/git-gui.sh
+++ b/git-gui.sh
@@ -2021,11 +2021,7 @@ proc do_gitk {revs {is_submodule false}} {
 
 		set pwd [pwd]
 
-		if {!$is_submodule} {
-			if {![is_bare]} {
-				cd $_gitworktree
-			}
-		} else {
+		if {$is_submodule} {
 			cd $current_diff_path
 			if {$revs eq {--}} {
 				set s $file_states($current_diff_path)
-- 
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.

Do keep the state of GIT_WORK_TREE if we are in a bare repository,
because Git GUI is not interested in the worktree at all, as no commit
mode is possible in a bare repository.

This avoids cases where an empty GIT_WORK_TREE was exported into the
environment, most notably by a call of `git gui blame HEAD file` in a
bare repository. (Although, this particular error is currently masked
by an earlier failure in `rev-parse --show-toplevel`, which requires a
working tree.)

Signed-off-by: Johannes Sixt <j6t@kdbg.org>
---
 git-gui.sh | 17 +++++++----------
 1 file changed, 7 insertions(+), 10 deletions(-)

diff --git a/git-gui.sh b/git-gui.sh
index 8d2b02b13fa0..3819f8be2211 100755
--- a/git-gui.sh
+++ b/git-gui.sh
@@ -1183,6 +1183,7 @@ if {$_prefix ne {}} {
 		exit 1
 	}
 	set _gitworktree [pwd]
+	catch {unset env(GIT_WORK_TREE)}
 	unset cdup
 } elseif {![is_enabled bare]} {
 	if {[is_bare]} {
@@ -1199,6 +1200,7 @@ if {$_prefix ne {}} {
 		exit 1
 	}
 	set _gitworktree [pwd]
+	catch {unset env(GIT_WORK_TREE)}
 }
 set _reponame [file split [file normalize $_gitdir]]
 if {[lindex $_reponame end] eq {.git}} {
@@ -1208,7 +1210,6 @@ if {[lindex $_reponame end] eq {.git}} {
 }
 
 set env(GIT_DIR) $_gitdir
-set env(GIT_WORK_TREE) $_gitworktree
 
 ######################################################################
 ##
@@ -2007,7 +2008,7 @@ proc incr_font_size {font {amt 1}} {
 
 proc do_gitk {revs {is_submodule false}} {
 	global current_diff_path file_states current_diff_side ui_index
-	global _gitdir _gitworktree
+	global _gitdir
 
 	# -- Always start gitk through whatever we were loaded with.  This
 	#    lets us bypass using shell process on Windows systems.
@@ -2041,18 +2042,16 @@ proc do_gitk {revs {is_submodule false}} {
 				}
 				set revs $old_sha1...$new_sha1
 			}
-			# GIT_DIR and GIT_WORK_TREE for the submodule are not the ones
-			# we've been using for the main repository, so unset them.
+			# GIT_DIR for the submodule is not the one we've been using for
+			# the main repository, so unset it. (GIT_WORK_TREE is already unset.)
 			# TODO we could make life easier (start up faster?) for gitk
 			# by setting these to the appropriate values to allow gitk
 			# to skip the heuristics to find their proper value
 			unset env(GIT_DIR)
-			unset env(GIT_WORK_TREE)
 		}
 		safe_exec_bg [concat $cmd $revs "--" "--"]
 
 		set env(GIT_DIR) $_gitdir
-		set env(GIT_WORK_TREE) $_gitworktree
 		cd $pwd
 
 		if {[info exists main_status]} {
@@ -2076,12 +2075,11 @@ proc do_git_gui {} {
 		error_popup [mc "Couldn't find git gui in PATH"]
 	} else {
 		global env
-		global _gitdir _gitworktree
+		global _gitdir
 
-		# see note in do_gitk about unsetting these vars when
+		# see note in do_gitk about unsetting this variable when
 		# running tools in a submodule
 		unset env(GIT_DIR)
-		unset env(GIT_WORK_TREE)
 
 		set pwd [pwd]
 		cd $current_diff_path
@@ -2089,7 +2087,6 @@ proc do_git_gui {} {
 		safe_exec_bg [concat $exe gui]
 
 		set env(GIT_DIR) $_gitdir
-		set env(GIT_WORK_TREE) $_gitworktree
 		cd $pwd
 
 		set status_operation [$::main_status \
-- 
2.54.0.215.g4fe990ec16


^ permalink raw reply related

* Re: [PATCH v2 02/11] git-gui: return status from choose_repository::pick
From: Johannes Sixt @ 2026-05-22  8:18 UTC (permalink / raw)
  To: Mark Levedahl; +Cc: egg_mushroomcow, bootaina702, git
In-Reply-To: <20260520202411.108764-3-mlevedahl@gmail.com>

Am 20.05.26 um 22:24 schrieb Mark Levedahl:
> The repository picker (choose_repository::pick) on success always
> returns with the current directory at the root of the selected worktree,
> and with the global variable _gitdir holding the name of the git
> repository, possibly as a relative path. On failure, _gitdir = {}. If
> the selection was from the "recent" list, no validation has occurred.
> 
> There are too many side effects in this interface. Note that the picker
> only supports worktrees with a .git entry in the worktree root, so git
> repository and worktree discovery will work starting in the current
> directory on return. So, let's change pick to return a 0/1 value, 1
> meaning a worktreee + repo was selected and the current directory is the
> worktree root, and leave validation and setting of _gitdir,
> _gitworktree, and _prefix to the caller.

While the removal of side-effects from the picker is very much desired,
the new return value sounds over-engineered at this point, in particular
due to this note:

> Note: pick actually does not
> return if something was not selected, rather it terminates git-gui.
> But, let's pretend at the call site that pick returns 0/false instead.

If we need the return value later, let's postpone that part of this
commit until then.

> diff --git a/git-gui.sh b/git-gui.sh
> index 4ba25da7b6..4a736190a9 100755
> --- a/git-gui.sh
> +++ b/git-gui.sh
> @@ -1151,10 +1151,16 @@ if {[catch {
>  	} err]} {
>  	load_config 1
>  	apply_config
> -	choose_repository::pick
> -	if {![file isdirectory $_gitdir]} {
> +	if {![choose_repository::pick]} {
>  		exit 1
>  	}
> +	if {[catch {
> +		set _gitdir [git rev-parse --git-dir]
> +	} err]} {
> +		catch {wm withdraw .}
> +		error_popup [strcat [mc "Unusable repo/worktree:"] " [pwd] "\n\n$err"]

There's something wrong with the quotes here, and an 'exit 1' is missing.

> +	}
> +	set _prefix {}
>  	set picked 1
>  }
-- Hannes


^ permalink raw reply

* Re: [PATCH v2 03/11] git-gui: use --absolute-git-dir
From: Johannes Sixt @ 2026-05-22  8:25 UTC (permalink / raw)
  To: Mark Levedahl, git; +Cc: egg_mushroomcow, bootaina702
In-Reply-To: <20260520202411.108764-4-mlevedahl@gmail.com>

Am 20.05.26 um 22:24 schrieb Mark Levedahl:
> git-gui uses git rev-parse --git-dir to get the pathname of the
> discovered git repository. The returned value can be relative, and is
> '.' if the current directory is the top of the repository directory
> itself.  git-gui has code to change '.' to [pwd] in this case so that
> subsequent logic runs.
> 
> But, git rev-parse supports --absolute-git-dir from fac60b8925
> ("rev-parse: add option for absolute or relative path formatting",
> 2020-12-13), and included in git 2.31. git-gui requires git >= 2.36, so
> this more useful form is always available. Use --absolute-git-dir to
> always get an absolute path, avoiding the need for other checks, and
> delete the now unneeded code to fix a relative _gitdir.

Very good!

> 
> Signed-off-by: Mark Levedahl <mlevedahl@gmail.com>
> ---
>  git-gui.sh | 11 ++---------
>  1 file changed, 2 insertions(+), 9 deletions(-)
> 
> diff --git a/git-gui.sh b/git-gui.sh
> index 4a736190a9..233c975786 100755
> --- a/git-gui.sh
> +++ b/git-gui.sh
> @@ -1146,7 +1146,7 @@ if {[catch {
>  	&& [catch {
>  		# beware that from the .git dir this sets _gitdir to .
>  		# and _prefix to the empty string

Note that the comment above needs some adjustment as well.

> -		set _gitdir [git rev-parse --git-dir]
> +		set _gitdir [git rev-parse --absolute-git-dir]
>  		set _prefix [git rev-parse --show-prefix]
>  	} err]} {
>  	load_config 1
> @@ -1155,7 +1155,7 @@ if {[catch {
>  		exit 1
>  	}
>  	if {[catch {
> -		set _gitdir [git rev-parse --git-dir]
> +		set _gitdir [git rev-parse --absolute-git-dir]
>  	} err]} {
>  		catch {wm withdraw .}
>  		error_popup [strcat [mc "Unusable repo/worktree:"] " [pwd] "\n\n$err"]
> @@ -1175,13 +1175,6 @@ if {$hashalgorithm eq "sha1"} {
>  	exit 1
>  }
>  
> -# we expand the _gitdir when it's just a single dot (i.e. when we're being
> -# run from the .git dir itself) lest the routines to find the worktree
> -# get confused
> -if {$_gitdir eq "."} {
> -	set _gitdir [pwd]
> -}
> -
>  if {![file isdirectory $_gitdir]} {
>  	catch {wm withdraw .}
>  	error_popup [strcat [mc "Git directory not found:"] "\n\n$_gitdir"]

-- Hannes


^ permalink raw reply

* Re: [PATCH v2 04/11] git-gui: use rev-parse exclusively to find a repository
From: Johannes Sixt @ 2026-05-22  8:46 UTC (permalink / raw)
  To: Mark Levedahl; +Cc: egg_mushroomcow, bootaina702, git
In-Reply-To: <20260520202411.108764-5-mlevedahl@gmail.com>

Am 20.05.26 um 22:24 schrieb Mark Levedahl:
> git-gui attempts to use env(GIT_DIR) directly as the git repository,
> accepting GIT_DIR if it is a directory. Only if that fails is git
> rev-parse used to discover the repository.  But, this avoids all of
> git-core's validity checking on a repository, thus possibly deferring an
> error to a later step, possibly unexpected. Repository validation should
> be part of initial setup so that later processing does not need error
> trapping for configuration errors.
> 
> Let's just invoke rev-parse so all error checking is done.
> 
> While here, let's cleanup the error handling.
> 
> Stop if an error occurs and the user set GIT_DIR or GIT_WORK_TREE.
> Use of either or both of those variables is supported by git, but their
> use also means the user has taken responsibility that they are correct,
> so a failure is something the user must address.

Very much so!

> 
> Otherwise on error, continue the existing behavior and show the
> repository picker. But, let's move the possible invocation of
> repository_chooser::pick to a separate code block. This permits adding
> separate conditions on using pick indepent of repository discovery, and
> will be exploited later in the series.  Note that the picker always
> returns with the current directory in the root of a worktree with the
> git repository is in the .git subdirectory.  The variable "picked" is
> used by git-gui to automatically execute the "Explore Working Copy" menu
> item after the repository picker is run.  This is controlled by config
> variable gui.autoexplore, and happens after all discovery is complete.
> 
> Remove a later check on whether _gitdir is a directory: that code
> cannot be reached without rev-parse already validating the repository.
> 
> _prefix should not be set before worktree discovery: the prefix is only
> known after the worktree is found, and at this point we have only
> discovered the repository.

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.

> This is true even when running the repository
> picker: that option provides a list of prior selections, and does no
> validation on the list beyond checking that the directories exist.  For
> now, just initialize _prefix along with other global variables.
> 
> Signed-off-by: Mark Levedahl <mlevedahl@gmail.com>
> ---
>  git-gui.sh | 48 +++++++++++++++++++++++++++++++++---------------
>  1 file changed, 33 insertions(+), 15 deletions(-)
> 
> diff --git a/git-gui.sh b/git-gui.sh
> index 233c975786..c61a6cbd8f 100755
> --- a/git-gui.sh
> +++ b/git-gui.sh
> @@ -374,6 +374,7 @@ set _gitdir {}
>  set _gitworktree {}
>  set _isbare {}
>  set _githtmldir {}
> +set _prefix {}
>  set _reponame {}
>  set _shellpath {@@SHELL_PATH@@}
>  
> @@ -1122,6 +1123,24 @@ unset argv0dir
>  ##
>  ## repository setup
>  
> +proc is_gitvars_error {err} {
> +	set havevars 0
> +	set GIT_DIR {}
> +	set GIT_WORK_TREE {}
> +	catch {set GIT_DIR $::env(GIT_DIR); set havevars 1}
> +	catch {set GIT_WORK_TREE $::env(GIT_WORK_TREE) ; set havevars 1}
> +
> +	if {$havevars} {
> +		catch {wm withdraw .}
> +		error_popup [strcat [mc "Invalid configuration:"] \
> +		   "\n" "GIT_DIR: " $GIT_DIR \
> +		   "\n" "GIT_WORK_TREE: " $GIT_WORK_TREE \
> +			"\n\n$err"]
> +		return 1
> +	}
> +	return 0
> +}
> +
>  proc set_gitdir_vars {} {
>  	global _gitdir _gitworktree env
>  	if {$_gitdir ne {}} {
> @@ -1138,17 +1157,22 @@ proc unset_gitdir_vars {} {
>  	catch {unset env(GIT_WORK_TREE)}
>  }
>  
> -set picked 0
> -if {[catch {
> -		set _gitdir $env(GIT_DIR)
> -		set _prefix {}
> -		}]
> -	&& [catch {
> -		# beware that from the .git dir this sets _gitdir to .
> -		# and _prefix to the empty string
> +# find repository.
> +set _gitdir {}
> +if {$_gitdir eq {}} {
> +	if {[catch {
>  		set _gitdir [git rev-parse --absolute-git-dir]
> -		set _prefix [git rev-parse --show-prefix]

You cannot leave the _prefix empty, because it breaks `git gui browser
master dir` when invoked from a subdirectory of the working tree.

This line must remain. I see that you add it back in later patch. There
may be some motivation to move prefix discovery, but there is no
motivation to remove it at this point.

>  	} err]} {
> +		if {[is_gitvars_error $err]} {
> +			exit 1
> +		}
> +		set _gitdir {}

BTW, this line would only be needed if the 'set _prefix' line above stays.

> +	}
> +}
> +
> +set picked 0
> +if {$_gitdir eq {}} {
> +	unset_gitdir_vars
>  	load_config 1
>  	apply_config
>  	if {![choose_repository::pick]} {
> @@ -1160,7 +1184,6 @@ if {[catch {
>  		catch {wm withdraw .}
>  		error_popup [strcat [mc "Unusable repo/worktree:"] " [pwd] "\n\n$err"]
>  	}
> -	set _prefix {}
>  	set picked 1
>  }
>  
> @@ -1175,11 +1198,6 @@ if {$hashalgorithm eq "sha1"} {
>  	exit 1
>  }
>  
> -if {![file isdirectory $_gitdir]} {
> -	catch {wm withdraw .}
> -	error_popup [strcat [mc "Git directory not found:"] "\n\n$_gitdir"]
> -	exit 1
> -}
>  # _gitdir exists, so try loading the config
>  load_config 0
>  apply_config

-- Hannes


^ permalink raw reply

* Re: [PATCH] doc: clarify push.default=simple in triangular workflows
From: Junio C Hamano @ 2026-05-22  8:49 UTC (permalink / raw)
  To: Ivan Baluta via GitGitGadget; +Cc: git, Ivan Baluta
In-Reply-To: <pull.2115.git.1779433093971.gitgitgadget@gmail.com>

"Ivan Baluta via GitGitGadget" <gitgitgadget@gmail.com> writes:

> From: ivanbaluta <ivanbaluta.dev@gmail.com>

Just noticing, but don't you want to spell your name just like you
spell it in your e-mails?  I.e., 

    From: Ivan Baluta <ivanbaluta.dev@gmail.com>

Use the same name for your sign-off below.

> The documentation for 'simple' push mode currently focuses on the
> centralized workflow. However, the implementation in builtin/push.c
> falls back to 'current' behavior when pushing to a remote different
> from the upstream (a triangular workflow).

It is not just implementation, but that is how it was designed to
do.

Whether centralized or triangular, "simple" works as a restricted
form as "current", with the same restriction.  That is, both
"current" and "simple" push out only the current branch to a single
destination that is configured, and "simple" insists that the
destination has the same name as the local branch.

So I am not sure if this three-line patch adds much value.

I agree that it _is_ confusing that the current text singles out the
centralized workflow when describing "simple".  But the remedy may
not be to add "what happens in triangular, then?", but it may be to
clarify that the need to configure the push destination whether your
push destination is the same as or different from your upstream, no?

Something along this line, perhaps?

    `simple`;;
    push the current branch with the same name on the remote.
    +
    This mode requires that the remote repository to be pushed to is
    known.  When pushing back to the same remote you pull from, the
    current branch must also have an upstream tracking branch with the
    same name.
    +
    This mode is the default since Git 2.0, and is the safest option
    suited for beginners.

That way, the description would be more self standing and the
readers hopefully do not have to refer to another mode (`current`)
to understand what happens, no?

^ permalink raw reply

* Re: [PATCH 1/8] t0001: plug test gaps for git-init(1) with GIT_OBJECT_DIRECTORY
From: Kristoffer Haugsbakk @ 2026-05-22  9:05 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git
In-Reply-To: <ag_yUsOEO6AjT4Ky@pks.im>

On Fri, May 22, 2026, at 08:06, Patrick Steinhardt wrote:
>>[snip]
>
> That reads a bit better.
>
>>>[snip]
>>
>> Isn’t it more that “the upcoming changes *would have* broken” them if
>> not for this change? This seems to refer to a an alternative commit
>> history where this change does not exist?
>
> Grammar is hard :) But yeah, this of course refers to an alternative
> commit history I had at one point in time that did break this.
>
> Fixed locally, will wait a bit before sending out the next version.

Thank you for considering my small input, as always.

Cheers.

^ permalink raw reply

* Re: [PATCH v9 3/5] branch: add --prune-merged <remote>
From: Phillip Wood @ 2026-05-22  9:47 UTC (permalink / raw)
  To: Harald Nordgren, phillip.wood
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	Johannes Sixt
In-Reply-To: <CAHwyqnVhhwT80Ao+7QLUAsTnUJaN5vE=ZiaxeqF3rYxxiD_Qww@mail.gmail.com>

Hi Harald

On 21/05/2026 20:16, Harald Nordgren wrote:
>> While we want to clean up topic branches, we want to avoid cleaning up
>> branches like "master" which follow an upstream branch and therefore
>> look like they've been merged straight after they've been pulled. So I
>> think as well as checking that the local branch is merged into its
>> upstream branch, we want to check that the local branch is not pushed to
>> the upstream branch i.e. that branch@{upstream} != branch@{push}.
> 
> This one I handle already by letting the default branch be guarded.

I used "master" as an example of a branch name above. There is no 
guarantee that a remote even defines a default branch, let alone that 
there is only one local branch where the its upstream and push 
destinations match. I don't see how you can avoid checking that the 
branch pushes to a different ref than its upstream and still be safe.

I'm going to be off the list from now until the week after next, I'll 
catch up with this thread when I'm back on line.

Thanks

Phillip


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


^ permalink raw reply

* Re: [PATCH v10 1/4] branch: add --forked <branch>
From: Harald Nordgren @ 2026-05-22 10:49 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Johannes Sixt, Kristoffer Haugsbakk, Phillip Wood, git,
	Harald Nordgren via GitGitGadget
In-Reply-To: <xmqqse7kt0ge.fsf@gitster.g>

> Johannes Sixt <j6t@kdbg.org> writes:
>
> > The icing on the cake would now be that
> >
> >     git branch --merged origin/main --forked origin/*
> >
> > provides the list of branches forked from origin that have already been
> > integrated.
>
> Yup, that is very nice.  Also with "--merged" replaced with
> "--not-merged", i.e., "our work building on top of origin's, and
> still need to be finished", would give us a good list to work on.

This is nice, but I think this would require an overhaul of other
infra as well, maybe better to do as a follow-up?


Harald

^ permalink raw reply

* Re: [PATCH v9 3/5] branch: add --prune-merged <remote>
From: Harald Nordgren @ 2026-05-22 10:51 UTC (permalink / raw)
  To: phillip.wood
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	Johannes Sixt
In-Reply-To: <6ae90274-3fbb-4d2a-b0f4-cd9260e4d6b3@gmail.com>

> >> While we want to clean up topic branches, we want to avoid cleaning up
> >> branches like "master" which follow an upstream branch and therefore
> >> look like they've been merged straight after they've been pulled. So I
> >> think as well as checking that the local branch is merged into its
> >> upstream branch, we want to check that the local branch is not pushed to
> >> the upstream branch i.e. that branch@{upstream} != branch@{push}.
> >
> > This one I handle already by letting the default branch be guarded.
>
> I used "master" as an example of a branch name above. There is no
> guarantee that a remote even defines a default branch, let alone that
> there is only one local branch where the its upstream and push
> destinations match. I don't see how you can avoid checking that the
> branch pushes to a different ref than its upstream and still be safe.

Ok, I'll give it a shot!

> I'm going to be off the list from now until the week after next, I'll
> catch up with this thread when I'm back on line.

Enjoy the time off!


Harald

^ permalink raw reply

* Re: [PATCH v10 1/4] branch: add --forked <branch>
From: Johannes Sixt @ 2026-05-22 11:25 UTC (permalink / raw)
  To: Harald Nordgren
  Cc: Junio C Hamano, Kristoffer Haugsbakk, Phillip Wood, git,
	Harald Nordgren via GitGitGadget
In-Reply-To: <CAHwyqnX=zvjpy3w8qn+H7L_Ncxs5+tK5Va-Lr4ZXX=XYLs2YZQ@mail.gmail.com>

Am 22.05.26 um 12:49 schrieb Harald Nordgren:
>> Johannes Sixt <j6t@kdbg.org> writes:
>>
>>> The icing on the cake would now be that
>>>
>>>     git branch --merged origin/main --forked origin/*
>>>
>>> provides the list of branches forked from origin that have already been
>>> integrated.
>>
>> Yup, that is very nice.  Also with "--merged" replaced with
>> "--not-merged", i.e., "our work building on top of origin's, and
>> still need to be finished", would give us a good list to work on.
> 
> This is nice, but I think this would require an overhaul of other
> infra as well, maybe better to do as a follow-up?
This can certainly be done as an extension in a follow-up patch. But the
UI must still be planned accordingly, i.e., --forked can only take a
single argument. For example, in

    git branch --forked foo bar

'bar' is the pattern of branches to show. The "list" is filtered
according to '--forked foo'. That is, if 'bar' was not forked from
'foo', the output is empty.

You would have to require

    git branch --forked foo --forked bar

to list all branches forked from 'foo' or 'bar'.

In the first implementation, you can restrict uses of other options with
--forked or even with a branch pattern. But you cannot be loose by
accepting multiple branch patterns with one --forked option, because
that would later clash with --list mode.

-- Hannes


^ 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

* [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


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