Git development
 help / color / mirror / Atom feed
* Re: [PATCH v3 3/5] name-rev: factor code for sharing with a new command
From: Kristoffer Haugsbakk @ 2026-05-05 19:21 UTC (permalink / raw)
  To: Phillip Wood; +Cc: D. Ben Knoble, git, Phillip Wood
In-Reply-To: <65e013cd-5bca-4340-8018-bcbb44371e4f@gmail.com>

On Sat, May 2, 2026, at 12:00, Phillip Wood wrote:
>>>[snip]
>>
>> Yeah, I didn’t want to repeat that bookkeeping but in some iteration it
>> looked necessary. But it’s good that it isn’t.
>
> It looks like the printing code is shared between the two case blocks in
> patch 5 as well so we should move that outside them as well and just set
> "name" inside the switch statement.

They’re not shared. Both commands work the same: either the object name
is consumed and replaced or it is written out as-is, in other words when
commit lookup fails. But somehow the name-rev path prints that *failure
to look up* case before continuing here (I don’t know how):

    if (!name)
        continue;

Because the printf(3) only prints when a symbolic name was found. Either
name-only:

    <symbolic name>

Or not name-only:

    <object name> (<symbolic name>)

On the other hand format-rev uses those two print statements to output
either the name lookup case or the lookup failure case.

>>>[snip]
>>
>> They looked the same to me. So I will need to think about this some
>> more. Just a lack of C experience on my part.
>>
>> Replacing the `continue` with a goto at the start of the loop was also
>> unnecessary. Of course the `continue` breaks out of the loop and not the
>> switch-block (unlike `break`).
>>
>> But I didn’t break `t6120-describe.sh`. So I’ll also take a look to see
>> if there are any holes.
>
> I think the difference only matters in pathological cases as "goto
> start" means we end up looking at the same character twice but the loop
> carries on as normal after that. We should just keep using "continue",
> I'm not sure we need a new test case.

Yeah, I have gone back to the sensible `continue`. Thanks.

(To be honest I tried to provoke a parsing bug here but I was unable
to. Somewhat annoying.)

^ permalink raw reply

* Re: [PATCH v2 01/11] index-pack, unpack-objects: use size_t for object size
From: Torsten Bögershausen @ 2026-05-05 19:11 UTC (permalink / raw)
  To: Johannes Schindelin via GitGitGadget
  Cc: git, Derrick Stolee, Jeff King, Johannes Schindelin
In-Reply-To: <dc660106ea8511e6adc44d2b70e9a4ae8b18090e.1777914508.git.gitgitgadget@gmail.com>

On Mon, May 04, 2026 at 05:08:18PM +0000, Johannes Schindelin via GitGitGadget wrote:
> From: Johannes Schindelin <johannes.schindelin@gmx.de>
> 
> When unpacking objects from a packfile, the object size is decoded
> from a variable-length encoding. On platforms where unsigned long is
> 32-bit (such as Windows, even in 64-bit builds), the shift operation
> overflows when decoding sizes larger than 4GB. The result is a
> truncated size value, causing the unpacked object to be corrupted or
> rejected.
> 
> Fix this by changing the size variable to size_t, which is 64-bit on
> 64-bit platforms, and ensuring the shift arithmetic occurs in 64-bit
> space.
> 
> This was originally authored by LordKiRon <https://github.com/LordKiRon>,
> who preferred not to reveal their real name and therefore agreed that I
> take over authorship.
> 
> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> ---
>  builtin/index-pack.c     | 9 +++++----
>  builtin/unpack-objects.c | 5 +++--
>  2 files changed, 8 insertions(+), 6 deletions(-)
> 
> diff --git a/builtin/index-pack.c b/builtin/index-pack.c
> index ca7784dc2c..cc660582e9 100644
> --- a/builtin/index-pack.c
> +++ b/builtin/index-pack.c
> @@ -37,7 +37,7 @@ static const char index_pack_usage[] =
>  
>  struct object_entry {
>  	struct pack_idx_entry idx;
> -	unsigned long size;
> +	size_t size;
>  	unsigned char hdr_size;
>  	signed char type;
>  	signed char real_type;
> @@ -469,7 +469,7 @@ static int is_delta_type(enum object_type type)
>  	return (type == OBJ_REF_DELTA || type == OBJ_OFS_DELTA);
>  }
>  
> -static void *unpack_entry_data(off_t offset, unsigned long size,
> +static void *unpack_entry_data(off_t offset, size_t size,
>  			       enum object_type type, struct object_id *oid)
>  {
>  	static char fixed_buf[8192];
> @@ -524,7 +524,8 @@ static void *unpack_raw_entry(struct object_entry *obj,
>  			      struct object_id *oid)
>  {
>  	unsigned char *p;
> -	unsigned long size, c;
> +	size_t size;
> +	unsigned long c;
Does this look a little bit strange ?
p points to an unsigned char (better would be *uint8_t)
then it is dereferenced into an "unsigned long".
Then it is masked with 0x7f
In short: should "c" be declared as uint8_t ?

>  	off_t base_offset;
>  	unsigned shift;
>  	void *data;
> @@ -542,7 +543,7 @@ static void *unpack_raw_entry(struct object_entry *obj,
>  		p = fill(1);
>  		c = *p;
>  		use(1);
> -		size += (c & 0x7f) << shift;
> +		size += ((size_t)c & 0x7f) << shift;
>  		shift += 7;
>  	}
>  	obj->size = size;
> diff --git a/builtin/unpack-objects.c b/builtin/unpack-objects.c
> index e01cf6e360..59a36c2481 100644
> --- a/builtin/unpack-objects.c
> +++ b/builtin/unpack-objects.c
> @@ -533,7 +533,8 @@ static void unpack_one(unsigned nr)
>  {
>  	unsigned shift;
>  	unsigned char *pack;
> -	unsigned long size, c;
> +	size_t size;
> +	unsigned long c;
>  	enum object_type type;
>  
>  	obj_list[nr].offset = consumed_bytes;
> @@ -548,7 +549,7 @@ static void unpack_one(unsigned nr)
>  		pack = fill(1);
>  		c = *pack;
>  		use(1);
> -		size += (c & 0x7f) << shift;
> +		size += ((size_t)c & 0x7f) << shift;
>  		shift += 7;
>  	}
>  
> -- 
> gitgitgadget
> 
> 

^ permalink raw reply

* Re: [PATCH] name-rev: fix an 'may be used uninitialized' error
From: Kristoffer Haugsbakk @ 2026-05-05 19:09 UTC (permalink / raw)
  To: Ramsay Jones, Junio C Hamano; +Cc: GIT Mailing-list
In-Reply-To: <aad833e9-d34e-4e57-a1e7-99dc0c6c7d24@ramsayjones.plus.com>

On Tue, May 5, 2026, at 02:41, Ramsay Jones wrote:
> On 04/05/2026 10:56 pm, Kristoffer Haugsbakk wrote:
>> On Mon, May 4, 2026, at 22:26, Ramsay Jones wrote:
>>>[snip]
>>
>> This is what I had when maybe-uninit. didn’t fail for me.
>>
>>     $ cat config.mak
>>     DEVELOPER=1
>>     DEBUG=1
>>     CC = ccache gcc
>>     CFLAGS+=-O0
>
> Ah, yes -O0 will disable the warning/error. Normally CFLAGS would be set to
> something like 'CFLAGS = -g -O2 -Wall'. (which still produces a binary you
> can reasonably use with gdb).

Thanks :)

>
>>     CFLAGS+=-ggdb3
>>     USE_ASCIIDOCTOR=true
>>[snip]

^ permalink raw reply

* Re: [PATCH] Makefile: link osxkeychain helper against Rust
From: Kristoffer Haugsbakk @ 2026-05-05 19:08 UTC (permalink / raw)
  To: git, gitgitgadget; +Cc: Shnatu
In-Reply-To: <pull.2288.git.git.1778001976709.gitgitgadget@gmail.com>

On Tue, May 5, 2026, at 19:26, Shardul Natu via GitGitGadget wrote:
> From: Shnatu <snatu@google.com>
>
> When Rust is enabled, ensure that the git-credential-osxkeychain
> helper is linked with the necessary Rust libraries.
>
> Introduce the RUST_LIBS variable inside ifndef NO_RUST block
> to hold the Rust library dependency, and use it in the helper's
> build target. This cleanly handles cases where Rust is disabled,
> making it a no-op and avoiding any build failures on systems
> without Cargo.
>
> This addresses reviewer feedback from internal CL 910223487
> by simplifying the variables and avoiding confusing "LINK"
> terminology.

This pararagraph is meaningless to those outside internal.

>
> Signed-off-by: Shnatu <snatu@google.com>
> ---
>[snip]

^ permalink raw reply

* Re: [PATCH v2 00/10] pack-objects: integrate --path-walk and some --filter options
From: Taylor Blau @ 2026-05-05 19:01 UTC (permalink / raw)
  To: Derrick Stolee
  Cc: Derrick Stolee via GitGitGadget, git, christian.couder, gitster,
	johannes.schindelin, johncai86, karthik.188, kristofferhaugsbakk,
	newren, peff, ps
In-Reply-To: <f5d8d4aa-2453-45ef-bc96-2b94bdf55c7e@gmail.com>

On Tue, May 05, 2026 at 12:18:28PM -0400, Derrick Stolee wrote:
> One thing I discovered when testing Taylor's series is that this series
> introduces new test failures when run with GIT_TEST_PACK_PATH_WALK=1.
> It's probably due to new cases that are fragile to the difference
> between delta compression algorithms, but are now exposed after the
> filters are no longer disabling --path-walk even with that test var.
>
> I'll make sure these are fixed in the next version.

Thanks for looking into it.

It looks like this bisects (at least in t5310) to "path-walk: support
blobless filter", which is 03/10 in this series. I suspect that there
are other failures that are indeed due to delta selection sensitivity as
you note, but in this case it looks like we are actually not sending the
right set of objects:

    + git clone --no-local --bare --filter=blob:none . partial-clone.git
    Cloning into bare repository 'partial-clone.git'...
    [...]
    fatal: bad object 782f60206c837dcd3d441e106549ad6f58de55b5
    fatal: remote did not send all necessary objects
    error: last command exited with $?=128
    not ok 26 - partial clone from bitmapped repository

I think this is a consequence of us not sending directly-referenced
blobs with `--filter=blob:none` when running the filters through
`--path-walk`. Something like:

--- 8< ---
diff --git a/path-walk.c b/path-walk.c
index a4dd197c37e..dbad01287e2 100644
--- a/path-walk.c
+++ b/path-walk.c
@@ -159,8 +159,8 @@ static int add_tree_entries(struct path_walk_context *ctx,
 		if (S_ISGITLINK(entry.mode))
 			continue;

-		/* If the caller doesn't want blobs, then don't bother. */
-		if (!ctx->info->blobs && type == OBJ_BLOB)
+		if ((!ctx->info->blobs || ctx->info->prune_tree_blobs) &&
+		    type == OBJ_BLOB)
 			continue;

 		if (type == OBJ_TREE) {
@@ -495,7 +495,7 @@ static int prepare_filters(struct path_walk_info *info,

 	case LOFC_BLOB_NONE:
 		if (info) {
-			info->blobs = 0;
+			info->prune_tree_blobs = 1;
 			list_objects_filter_release(options);
 		}
 		return 1;
--- >8 ---

fixes t5310 for me. I haven't looked into any of the other failures yet
since you mentioned that you're looking into them, but let me know if
you want to tag-team any of these.

(As a related side-note, I noticed that GIT_TEST_PACK_PATH_WALK=1 is not
currently in the TEST-vars CI build.  I'm not sure if there are
historical reasons for leaving it out, but if not I think it would be
worthwhile to add it.)

Thanks,
Taylor

^ permalink raw reply related

* [PATCH] Makefile: link osxkeychain helper against Rust
From: Shardul Natu via GitGitGadget @ 2026-05-05 17:26 UTC (permalink / raw)
  To: git; +Cc: Shnatu

From: Shnatu <snatu@google.com>

When Rust is enabled, ensure that the git-credential-osxkeychain
helper is linked with the necessary Rust libraries.

Introduce the RUST_LIBS variable inside ifndef NO_RUST block
to hold the Rust library dependency, and use it in the helper's
build target. This cleanly handles cases where Rust is disabled,
making it a no-op and avoiding any build failures on systems
without Cargo.

This addresses reviewer feedback from internal CL 910223487
by simplifying the variables and avoiding confusing "LINK"
terminology.

Signed-off-by: Shnatu <snatu@google.com>
---
    Makefile: link osxkeychain helper against Rust

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2288%2Fkiranani%2Fnext-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2288/kiranani/next-v1
Pull-Request: https://github.com/git/git/pull/2288

 Makefile | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/Makefile b/Makefile
index f86173f93a..a17dca22b1 100644
--- a/Makefile
+++ b/Makefile
@@ -1593,6 +1593,7 @@ ALL_LDFLAGS = $(LDFLAGS) $(LDFLAGS_APPEND)
 ifndef NO_RUST
 BASIC_CFLAGS += -DWITH_RUST
 GITLIBS += $(RUST_LIB)
+RUST_LIBS = $(RUST_LIB)
 ifeq ($(uname_S),Windows)
 EXTLIBS += -luserenv
 endif
@@ -4082,9 +4083,9 @@ $(LIBGIT_HIDDEN_EXPORT): $(LIBGIT_PARTIAL_EXPORT)
 contrib/libgit-sys/libgitpub.a: $(LIBGIT_HIDDEN_EXPORT)
 	$(AR) $(ARFLAGS) $@ $^
 
-contrib/credential/osxkeychain/git-credential-osxkeychain: contrib/credential/osxkeychain/git-credential-osxkeychain.o $(LIB_FILE) GIT-LDFLAGS
+contrib/credential/osxkeychain/git-credential-osxkeychain: contrib/credential/osxkeychain/git-credential-osxkeychain.o $(LIB_FILE) $(RUST_LIBS) GIT-LDFLAGS
 	$(QUIET_LINK)$(CC) $(ALL_CFLAGS) -o $@ $(ALL_LDFLAGS) \
-		$(filter %.o,$^) $(LIB_FILE) $(EXTLIBS) -framework Security -framework CoreFoundation
+		$(filter %.o,$^) $(LIB_FILE) $(RUST_LIBS) $(EXTLIBS) -framework Security -framework CoreFoundation
 
 contrib/credential/osxkeychain/git-credential-osxkeychain.o: contrib/credential/osxkeychain/git-credential-osxkeychain.c GIT-CFLAGS
 	$(QUIET_LINK)$(CC) -o $@ -c $(dep_args) $(compdb_args) $(ALL_CFLAGS) $(EXTRA_CPPFLAGS) $<

base-commit: 4f69b47b940100b02630f745a52f9d9850f122b2
-- 
gitgitgadget

^ permalink raw reply related

* Re: [PATCH v2 00/10] pack-objects: integrate --path-walk and some --filter options
From: Derrick Stolee @ 2026-05-05 16:18 UTC (permalink / raw)
  To: Derrick Stolee via GitGitGadget, git
  Cc: christian.couder, gitster, johannes.schindelin, johncai86,
	karthik.188, kristofferhaugsbakk, me, newren, peff, ps
In-Reply-To: <pull.2101.v2.git.1777926079.gitgitgadget@gmail.com>

On 5/4/2026 4:21 PM, Derrick Stolee via GitGitGadget wrote:
> NOTE: This series is based on en/backfill-fixes-and-edges.
> 
> The 'git pack-objects' command has a '--path-walk' option that uses the
> path-walk API instead of a typical revision walk to group objects into
> chunks by path name instead of relying solely on name-hashes to group
> similar files together. (It also does a second compression pass looking for
> better deltas after the first pass that is focused within chunks per path.)
> 
> The '--path-walk' feature was not previously integrated with the '--filter'
> feature, so a warning would appear and disable the path-walk API when a
> filter is given. This patch series integrates these together in the
> following ways:
> 
>  * --filter=blob:none updates the path-walk API options to skip blobs.
>  * --filter=blob:limit=<size> adds a scan to a list of blob objects to
>    remove objects that are too large.
>  * --filter=sparse:<oid> adds a scan to the chunks to validate that the
>    paths match the sparse-checkout patterns.

(I need to update this cover letter to include the new filters.)

One thing I discovered when testing Taylor's series is that this series
introduces new test failures when run with GIT_TEST_PACK_PATH_WALK=1.
It's probably due to new cases that are fragile to the difference
between delta compression algorithms, but are now exposed after the
filters are no longer disabling --path-walk even with that test var.

I'll make sure these are fixed in the next version.

Thanks,
-Stolee


^ permalink raw reply

* RE: Question on Clean/Smudge Infrastructure
From: rsbecker @ 2026-05-05 15:04 UTC (permalink / raw)
  To: 'Junio C Hamano'; +Cc: git
In-Reply-To: <xmqqzf2em2v8.fsf@gitster.g>

On May 5, 2026 8:56 AM, Junio C Hamano wrote:
> <rsbecker@nexbridge.com> writes:
> 
> > Hi Git,
> >
> > I have a edge use case that I would like to ask about.
> >
> > Given a directory with a large number, say 100, text files, and a few
> > scattered binary files - specified in .gitattributes as binary, what
> > does clean smudge do with the binary files if they match the filter
> > specification pattern? Are they ignored or processed. I am not sure
> > that passing binary via stdin is necessarily portable. However, I
> > would like to be able to explicitly ignore the binary files in my
> > clean/smudge filters - either by doing a copy stdin/stdout (as I said,
> > probably not portable), or sending a non-zero exit code, or some other
> > mechanism.
> > The root of the use case is that the directory is subject to
> > significant changes over time, and errors are sneaking in when people
> > forget to update .gitattributes or name the files incorrectly. I would
> > like to make their situation more stable to errors.
> >
> > Thanks,
> > Randall
> >
> > --
> > Brief whoami: NonStop&UNIX developer since approximately
> > UNIX(421664400)
> > NonStop(211288444200000000)
> > -- In real life, I talk too much.
> 
> 
> * Passing binary via stdin is perfetly normal.  Otherwise, it would
>   not work to set "exif" as the textconv filter on JPEG image files.
> 
> * The "filter" attribute is orthogonal to other attributes like
>   "text" or "diff".  If "filter" somehow paid attention to
>   binary-ness of the payload and refrained from working at all, then
>   it would make it impossible to filter binary contents.
> 
> * If you want to apply your "filter" attribute to a subset of the
>   files you have, you need to sift your files into two classes, ones
>   that your filter would be used, and the other the remainder.  And
>   they give your filter attribute only to the former.  Perhaps you
>   only want *.txt to go through clean/smudge, and then you would
>   have
> 
>     *.txt filter=mytextfilter
> 
>   in your .gitattributes, and in your .git/config, you would have
>   lines to speicify the executable you can use on each system.
> 
>    [filter "mytextfilter"]
> 	clean = ... your system specific command comes here ...
> 	smudge = ... your system specific command comes here ...

Thanks. I'm going to add a mechanism to auto-add file extensions
for this situation. That will allow me to select by pattern and
improve long-term manageability.

Regards,
Randall


^ permalink raw reply

* Re: Question on Clean/Smudge Infrastructure
From: Junio C Hamano @ 2026-05-05 12:56 UTC (permalink / raw)
  To: rsbecker; +Cc: git
In-Reply-To: <079201dcdbe7$162d5430$4287fc90$@nexbridge.com>

<rsbecker@nexbridge.com> writes:

> Hi Git,
>
> I have a edge use case that I would like to ask about.
>
> Given a directory with a large number, say 100, text files, and a few
> scattered
> binary files - specified in .gitattributes as binary, what does clean smudge
> do
> with the binary files if they match the filter specification pattern? Are
> they
> ignored or processed. I am not sure that passing binary via stdin is
> necessarily
> portable. However, I would like to be able to explicitly ignore the binary
> files
> in my clean/smudge filters - either by doing a copy stdin/stdout (as I said,
> probably
> not portable), or sending a non-zero exit code, or some other mechanism.
> The root of the use case is that the directory is subject to significant
> changes
> over time, and errors are sneaking in when people forget to update
> .gitattributes
> or name the files incorrectly. I would like to make their situation more
> stable
> to errors.
>
> Thanks,
> Randall
>
> --
> Brief whoami: NonStop&UNIX developer since approximately
> UNIX(421664400)
> NonStop(211288444200000000)
> -- In real life, I talk too much.


* Passing binary via stdin is perfetly normal.  Otherwise, it would
  not work to set "exif" as the textconv filter on JPEG image files.

* The "filter" attribute is orthogonal to other attributes like
  "text" or "diff".  If "filter" somehow paid attention to
  binary-ness of the payload and refrained from working at all, then
  it would make it impossible to filter binary contents.

* If you want to apply your "filter" attribute to a subset of the
  files you have, you need to sift your files into two classes, ones
  that your filter would be used, and the other the remainder.  And
  they give your filter attribute only to the former.  Perhaps you
  only want *.txt to go through clean/smudge, and then you would
  have 

    *.txt filter=mytextfilter

  in your .gitattributes, and in your .git/config, you would have
  lines to speicify the executable you can use on each system.

   [filter "mytextfilter"]
	clean = ... your system specific command comes here ...
	smudge = ... your system specific command comes here ...


^ permalink raw reply

* Re: [PATCH v2 11/11] ci: run expensive tests on push builds to integration branches
From: Junio C Hamano @ 2026-05-05 12:56 UTC (permalink / raw)
  To: Derrick Stolee
  Cc: Johannes Schindelin via GitGitGadget, git,
	Torsten Bögershausen, Jeff King, Johannes Schindelin
In-Reply-To: <42f96e54-7b94-4075-91b1-1c2447b93322@gmail.com>

Derrick Stolee <stolee@gmail.com> writes:

> On 5/4/2026 1:08 PM, Johannes Schindelin via GitGitGadget wrote:
>> From: Johannes Schindelin <johannes.schindelin@gmx.de>
>> 
>> Derrick Stolee suggested [1] that expensive tests should be run at a
>> regular cadence rather than on every PR iteration. Gate GIT_TEST_LONG
>> on push builds to the integration branches (next, master, main, maint)
>> so that the EXPENSIVE prereq is satisfied there but not during PR
>> validation, where the extra minutes of wall-clock time do not justify
>> themselves.
> I like that this will be run as part of regular updates to the
> important branches. The important bit after that is whether or
> not a human pays attention to the signal of these builds.
>
> Junio: Do you pay attention to CI breaks when you push to
> 'master'?

Well, it is way too late to notice breakage when the faulty update
hits 'master'.  CI failures should be noticed before breakage hits
'next'.

I often notice and complain when I see failures on 'seen', and
sometimes I help original submitter by bisecting, but I do not
necessarily have enough time and bandwidth to help everybody.

Quite honestly, the best place to give widest test coverage is much
closer to the source of the problems than in my tree and mixed with
other topics, i.e., at individual contributor's CI.  That way, I
presume that GitGitGadget can also help submitters avoid sending a
faulty series, reducing the load on the list and the maintainer.

Ideally the CI tests by the integrator should only be catching any
mismerges and unexpected inter-topic interactions, as they cannot be
caught by contributor's standalone tests, so I do not mind widening
coverage of CI tests when I push the integration results out.  But
so far, the majority of what I have seen and reported back to the
list have been something that the authors should be equipped to spot
in their topic without getting mixed with other topics into any
integration branches.

> One way to help this procedure could be to have GitHub CI
> failures trigger new issues, which could then be more easily
> viewed and noticed by the community watching the repo. This
> is of course out-of-scope for this patch series, but could be
> considered in the future.

I think a better way to help would be to arrange the workflow so
that we do not even have to trigger an issue, and stop before the
patches leave the original authors' hand.  They can of course ask
for help saying "here is my topic in my fork of the repository and
failing in this way for macOS that I do not have access to.  Could
anybody help me figuring out what macOS peculiarity my changes are
tickling?", or something like that.

It would be best to find problems early, and make it easier for
individual contributors to help each other by having a concrete CI
failure reports in their forks that they can point at when they ask
for help.  And CI run when I push 'seen' or 'master' out would not
help as much as CI run when they publish their forked branches would.

By the way, please expect slow responses as I am (officially) still
mostly offline for the rest of the week.

Thanks.


^ permalink raw reply

* [PATCH] build: tolerate use of _Generic from glibc 2.43 with Clang
From: Patrick Steinhardt @ 2026-05-05 12:26 UTC (permalink / raw)
  To: git

When building with `make DEVELOPER=1` we explicitly pass "-std=gnu99" to
the compiler so that we don't start leaning on features exposed by more
recent versions of the C standard. Unfortunately though, glibc 2.43
started to use type-generic expressions. This works alright with GCC,
but when compiling with Clang this leads to errors:

  $ make DEVELOPER=1 CC=clang
  CC daemon.o
  In file included from daemon.c:3:
  ./git-compat-util.h:344:11: error: '_Generic' is a C11 extension [-Werror,-Wc11-extensions]
    344 |         return !!strchr(path, '/');
        |                  ^
  /usr/include/string.h:265:3: note: expanded from macro 'strchr'
    265 |   __glibc_const_generic (S, const char *, strchr (S, C))
        |   ^
  /usr/include/x86_64-linux-gnu/sys/cdefs.h:838:3: note: expanded from macro '__glibc_const_generic'
    838 |   _Generic (0 ? (PTR) : (void *) 1,                     \
        |   ^

In theory, the `__glibc_const_generic` macro does have feature gating:

  #if !defined __cplusplus \
      && (__GNUC_PREREQ (4, 9) \
          || __glibc_has_extension (c_generic_selections) \
          || (!defined __GNUC__ && defined __STDC_VERSION__ \
              && __STDC_VERSION__ >= 201112L))
  # define __HAVE_GENERIC_SELECTION 1
  #else
  # define __HAVE_GENERIC_SELECTION 0
  #endif

But this feature gating isn't effective because `_has_extension()` will
always evaluate to true as C generics _are_ available as a language
extension to GNU C99 when using Clang. This would have been different if
`_has_feature()` was used instead, in which case it would have properly
evaluated to `false`.

Unfortunately, there is no easy way for us to work around the warning.
We cannot define `__HAVE_GENERIC_SELECTION` ourselves as that would lead
to a redefinition, and given that the conditions are or'd together we
cannot disable any of those, either.

Instead, work around the issue by not using -std=gnu99 with Clang when
using the Makefile and by disabling warnings about C11 extensions when
using Meson. This isn't ideal, but we at least retain the ability to
detect the (mis-)use of features from newer standards with GCC.

An alternative to this might be to simply bump the required C standard
to C11, which is 15 years old by now and should have support on most
platforms out there. But some more esoteric platforms may not have it.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
Hi,

this patch fixes CI failures that have started to occur due to the
upgrade to Ubuntu 26.04. Thanks!

Patrick
---
 config.mak.dev | 5 ++++-
 meson.build    | 6 ++++++
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/config.mak.dev b/config.mak.dev
index c8dcf78779..8830b78c1b 100644
--- a/config.mak.dev
+++ b/config.mak.dev
@@ -21,12 +21,15 @@ endif
 endif
 
 ifneq ($(uname_S),FreeBSD)
-ifneq ($(or $(filter gcc6,$(COMPILER_FEATURES)),$(filter clang7,$(COMPILER_FEATURES))),)
+ifneq ($(filter gcc6,$(COMPILER_FEATURES)),)
 DEVELOPER_CFLAGS += -std=gnu99
 endif
 else
 # FreeBSD cannot limit to C99 because its system headers unconditionally
 # rely on C11 features.
+#
+# Clang cannot limit to C99 when using glibc 2.43 because its system headers
+# depend on the _Generic C11 feature. This works with GCC though.
 endif
 
 DEVELOPER_CFLAGS += -Wdeclaration-after-statement
diff --git a/meson.build b/meson.build
index 11488623bf..2997d4f90f 100644
--- a/meson.build
+++ b/meson.build
@@ -866,6 +866,12 @@ if get_option('warning_level') in ['2','3', 'everything'] and compiler.get_argum
       libgit_c_args += cflag
     endif
   endforeach
+
+  # Clang generates warnings when compiling glibc 2.43 because of the use of
+  # _Generic.
+  if compiler.get_id() == 'clang'
+    libgit_c_args += '-Wno-c11-extensions'
+  endif
 endif
 
 if get_option('breaking_changes')

---
base-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0
change-id: 20260505-b4-pks-ci-tolerate-glibc-generic-0815aff39ff7


^ permalink raw reply related

* [PATCH] stash: test show --include-untracked includes untracked files
From: Pushkar Singh @ 2026-05-05 10:33 UTC (permalink / raw)
  To: git; +Cc: gitster, peff, ps, Pushkar Singh

Add a test to verify that 'git stash show --include-untracked'
includes untracked files that were saved in the stash.

This ensures coverage for the third parent of stash commits,
which represents untracked files.

Signed-off-by: Pushkar Singh <pushkarkumarsingh1970@gmail.com>
---
 t/t3903-stash.sh | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/t/t3903-stash.sh b/t/t3903-stash.sh
index 70879941c2..d4867536b9 100755
--- a/t/t3903-stash.sh
+++ b/t/t3903-stash.sh
@@ -1790,4 +1790,21 @@ test_expect_success 'stash.index=false overridden by --index' '
 	test_cmp expect file
 '
 
+test_expect_success 'stash show --include-untracked includes untracked files' '
+	git reset --hard &&
+
+	echo tracked >tracked &&
+	git add tracked &&
+	git commit -m "base" &&
+
+	echo change >>tracked &&
+	echo untracked >untracked &&
+
+	git stash push --include-untracked &&
+	test_path_is_missing untracked &&
+
+	git stash show --include-untracked >actual &&
+	test_grep "untracked" actual
+'
+
 test_done
-- 
2.53.0.582.gca1db8a0f7


^ permalink raw reply related

* Re: Git maintenance fails without meaningful error message if any remote is no longer available
From: Anselm Schüler @ 2026-05-05 10:05 UTC (permalink / raw)
  To: Phillip Wood, git
In-Reply-To: <5e3bcfdb-d3aa-4494-81d6-15b0dfd43af1@gmail.com>

[ This is a duplicate message because I forgot to hit Reply All the last 
time ]

Hi Phillip,

I think there may be a misunderstanding. My current systemd timers do 
use --keep-going. The issue is that on the individual repo, 
git-maintenance won’t fetch other remotes if one remote fails. 
--keep-going will ensure that the other repos get processed, but the 
repo with the failing remote won’t fetch any remotes after the failing one.

At least, that’s what appears to be happening from the output of the 
command.

On 05/05/2026 11:59, Phillip Wood wrote:
> Hi Anselm
>
> On 30/04/2026 00:13, Anselm Schüler wrote:
>> I have a repo with multiple remotes, one of which no longer exists. 
>> When git-maintenance runs on it, it fails during the prefetch stage 
>> because that remote doesn’t exist anymore, and gives a mostly 
>> unhelpful error message:
>>
>> $ git maintenance run --schedule=daily
>> ERROR: Repository not found.
>> fatal: Could not read from remote repository.
>>
>> Please make sure you have the correct access rights
>> and the repository exists.
>> error: failed to prefetch remotes
>> error: task 'prefetch' failed
>>
>> I think that
>> 1. git-maintenance should report which remote it’s encountering an 
>> error on
>> 2. git-maintenance should continue fetching other remotes even if one 
>> fails
>
> Since c75662bfc9 (maintenance: running maintenance should not stop on 
> errors, 2024-04-24) which is in git 2.45.3 the systemd timer files 
> installed by "git maintenance start" use "git for-each-repo 
> --keep-going --config=..." to avoid this problem. Unfortunately we 
> don't have a way to automatically upgrade the timer files for users 
> who ran "git maintenance start" before that. I think if you run
>
>     git maintenance stop
>     git maintenance start
>
> It will delete the old timer files and install the new ones. If that 
> does not work you'll need to manually edit the files and add 
> "--keep-going" to "git for-each-repo".
>
> Thanks
>
> Phillip
>
>> Now, on my system, the systemd timers for git-maintenance use 
>> git-for- each-repo. Not sure if that’s upstream behaviour or 
>> something Nix/home- manager does. But if it is upstream behaviour, it 
>> would also be great to report the repo the error comes from, since I 
>> basically had to guess right now which repo was erroring. Luckily I 
>> have only three repos under maintenance so that was fine.
>>
>> Let me know if you agree that this should be done. I would be open to 
>> writing a patch (no promises though)
>>
>> Anselm
>>
>>
>

^ permalink raw reply

* Re: Git maintenance fails without meaningful error message if any remote is no longer available
From: Phillip Wood @ 2026-05-05  9:59 UTC (permalink / raw)
  To: Anselm Schüler, git
In-Reply-To: <0f3ef394-d96a-42f2-825d-53cb475a2363@anselmschueler.com>

Hi Anselm

On 30/04/2026 00:13, Anselm Schüler wrote:
> I have a repo with multiple remotes, one of which no longer exists. When 
> git-maintenance runs on it, it fails during the prefetch stage because 
> that remote doesn’t exist anymore, and gives a mostly unhelpful error 
> message:
> 
> $ git maintenance run --schedule=daily
> ERROR: Repository not found.
> fatal: Could not read from remote repository.
> 
> Please make sure you have the correct access rights
> and the repository exists.
> error: failed to prefetch remotes
> error: task 'prefetch' failed
> 
> I think that
> 1. git-maintenance should report which remote it’s encountering an error on
> 2. git-maintenance should continue fetching other remotes even if one fails

Since c75662bfc9 (maintenance: running maintenance should not stop on 
errors, 2024-04-24) which is in git 2.45.3 the systemd timer files 
installed by "git maintenance start" use "git for-each-repo --keep-going 
--config=..." to avoid this problem. Unfortunately we don't have a way 
to automatically upgrade the timer files for users who ran "git 
maintenance start" before that. I think if you run

	git maintenance stop
	git maintenance start

It will delete the old timer files and install the new ones. If that 
does not work you'll need to manually edit the files and add 
"--keep-going" to "git for-each-repo".

Thanks

Phillip

> Now, on my system, the systemd timers for git-maintenance use git-for- 
> each-repo. Not sure if that’s upstream behaviour or something Nix/home- 
> manager does. But if it is upstream behaviour, it would also be great to 
> report the repo the error comes from, since I basically had to guess 
> right now which repo was erroring. Luckily I have only three repos under 
> maintenance so that was fine.
> 
> Let me know if you agree that this should be done. I would be open to 
> writing a patch (no promises though)
> 
> Anselm
> 
> 


^ permalink raw reply

* Re: Git trims the last character of content from remotes
From: Mikael Magnusson @ 2026-05-05  9:38 UTC (permalink / raw)
  To: Hugo Osvaldo Barrera; +Cc: git
In-Reply-To: <2d3f5504-f5dd-4171-96e8-b5633b6a1f5e@app.fastmail.com>

On Mon, May 4, 2026 at 7:02 PM Hugo Osvaldo Barrera <hugo@whynothugo.nl> wrote:
>
> Hi all,
>
> When I push content to GitLab, the remote server sends back some text which git
> then prints to stderr:
>
>   remote:
>   remote: To create a merge request for zk, visit:
>   remote:   https://gitlab.alpinelinux.org/WhyNotHugo/aports/-/merge_requests/new?merge_request%5Bsource_branch%5D=zk
>   remote:
>
> When the width of a whole line is the same as my terminal width, the last digit
> gets trimmed off. E.g.: if I resize my terminal for the above to fix exactly,
> and re-run the same command, git prints:
>
>   remote:   https://gitlab.alpinelinux.org/WhyNotHugo/aports/-/merge_requests/new?merge_request%5Bsource_branch%5D=z
>
> From what I can tell, sideband.c prints ANSI_SUFFIX = "\033[K", this escape
> sequence being "clear the line from the current position until the end of the
> line", and this is the root cause of the issue.
>
> When piping to cat or to a file, this sequence is not printed, so the output is
> fine.
>
> Is this a bug?

grep has the same bug with --color, if you have a line of text the
same width as your terminal, for the same reason. urxvt supports an
extension of \e[3K to clear to end but not erase the character under
the cursor if it's wrapped (but not yet moved to the next line). xterm
hasn't picked it up though, so it's not a general solution,
unfortunately. It's a little unclear to me why git prints this
sequence here at all, are we expecting that there is already other
text printed on the line? Or is it to clear cells that may have had
another background color when the current line was scrolled in? Maybe
that case is more unusual and less harmful than actually eating the
final character, that it's not worth clearing?

-- 
Mikael Magnusson

^ permalink raw reply

* [PATCH v2 1/1] http: reject unsupported proxy URL schemes
From: aminnimaj @ 2026-05-05  9:19 UTC (permalink / raw)
  To: git; +Cc: gitster, peff, ryan.hendrickson, Aliwoto
In-Reply-To: <20260505091941.1825-1-aminnimaj@gmail.com>

From: Aliwoto <aminnimaj@gmail.com>

An explicit proxy URL with an unrecognized scheme such as
htpp://127.0.0.1 is currently accepted.

Git parses the URL, extracts the host part, and then passes only that
host to libcurl. Because no proxy type is selected for the unknown
scheme, Git leaves libcurl at its default HTTP proxy type, so the typo
is silently treated as an HTTP proxy.

Reject proxy URLs with explicit unsupported schemes instead of silently
accepting them. Keep the existing host:port-without-scheme behavior
unchanged.

Implement the SOCKS proxy handling with a shared table-driven mapping.

Add a regression test to cover the unsupported-scheme case.

Signed-off-by: Aliwoto <aminnimaj@gmail.com>
---
 http.c                | 93 +++++++++++++++++++++++++++++++------------
 t/t5564-http-proxy.sh |  6 +++
 2 files changed, 74 insertions(+), 25 deletions(-)

diff --git a/http.c b/http.c
index 7815f144de..b945267c9c 100644
--- a/http.c
+++ b/http.c
@@ -722,6 +722,69 @@ static int has_proxy_cert_password(void)
 	return 1;
 }
 
+static const struct socks_proxy_type {
+	const char *name;
+	long curlsym;
+} socks_proxy_types[] = {
+	{ "socks", CURLPROXY_SOCKS4 },
+	{ "socks4", CURLPROXY_SOCKS4 },
+	{ "socks4a", CURLPROXY_SOCKS4A },
+	{ "socks5", CURLPROXY_SOCKS5 },
+	{ "socks5h", CURLPROXY_SOCKS5_HOSTNAME },
+};
+
+static const struct socks_proxy_type *find_socks_proxy_type(const char *protocol)
+{
+	int i;
+
+	if (!protocol)
+		return NULL;
+
+	for (i = 0; i < ARRAY_SIZE(socks_proxy_types); i++) {
+		if (!strcmp(socks_proxy_types[i].name, protocol))
+			return &socks_proxy_types[i];
+	}
+
+	return NULL;
+}
+
+static int is_socks_proxy_protocol(const char *protocol)
+{
+	return !!find_socks_proxy_type(protocol);
+}
+
+static int set_curl_proxy_type(CURL *result, const char *protocol)
+{
+	const struct socks_proxy_type *socks_proxy_type;
+
+	if (!protocol || !strcmp(protocol, "http"))
+		return 0;
+
+	socks_proxy_type = find_socks_proxy_type(protocol);
+	if (socks_proxy_type) {
+		curl_easy_setopt(result, CURLOPT_PROXYTYPE, socks_proxy_type->curlsym);
+		return 0;
+	}
+
+	if (!strcmp(protocol, "https")) {
+		curl_easy_setopt(result, CURLOPT_PROXYTYPE, (long)CURLPROXY_HTTPS);
+
+		if (http_proxy_ssl_cert)
+			curl_easy_setopt(result, CURLOPT_PROXY_SSLCERT,
+					 http_proxy_ssl_cert);
+
+		if (http_proxy_ssl_key)
+			curl_easy_setopt(result, CURLOPT_PROXY_SSLKEY,
+					 http_proxy_ssl_key);
+
+		if (has_proxy_cert_password())
+			curl_easy_setopt(result, CURLOPT_PROXY_KEYPASSWD,
+					 proxy_cert_auth.password);
+	}
+
+	return -1;
+}
+
 /* Return 1 if redactions have been made, 0 otherwise. */
 static int redact_sensitive_header(struct strbuf *header, size_t offset)
 {
@@ -1192,30 +1255,6 @@ static CURL *get_curl_handle(void)
 	} else if (curl_http_proxy) {
 		struct strbuf proxy = STRBUF_INIT;
 
-		if (starts_with(curl_http_proxy, "socks5h"))
-			curl_easy_setopt(result,
-				CURLOPT_PROXYTYPE, (long)CURLPROXY_SOCKS5_HOSTNAME);
-		else if (starts_with(curl_http_proxy, "socks5"))
-			curl_easy_setopt(result,
-				CURLOPT_PROXYTYPE, (long)CURLPROXY_SOCKS5);
-		else if (starts_with(curl_http_proxy, "socks4a"))
-			curl_easy_setopt(result,
-				CURLOPT_PROXYTYPE, (long)CURLPROXY_SOCKS4A);
-		else if (starts_with(curl_http_proxy, "socks"))
-			curl_easy_setopt(result,
-				CURLOPT_PROXYTYPE, (long)CURLPROXY_SOCKS4);
-		else if (starts_with(curl_http_proxy, "https")) {
-			curl_easy_setopt(result, CURLOPT_PROXYTYPE, (long)CURLPROXY_HTTPS);
-
-			if (http_proxy_ssl_cert)
-				curl_easy_setopt(result, CURLOPT_PROXY_SSLCERT, http_proxy_ssl_cert);
-
-			if (http_proxy_ssl_key)
-				curl_easy_setopt(result, CURLOPT_PROXY_SSLKEY, http_proxy_ssl_key);
-
-			if (has_proxy_cert_password())
-				curl_easy_setopt(result, CURLOPT_PROXY_KEYPASSWD, proxy_cert_auth.password);
-		}
 		if (strstr(curl_http_proxy, "://"))
 			credential_from_url(&proxy_auth, curl_http_proxy);
 		else {
@@ -1225,6 +1264,10 @@ static CURL *get_curl_handle(void)
 			strbuf_release(&url);
 		}
 
+		if (set_curl_proxy_type(result, proxy_auth.protocol) < 0)
+			die("Invalid proxy URL '%s': unsupported proxy scheme '%s'",
+			    curl_http_proxy, proxy_auth.protocol);
+
 		if (!proxy_auth.host)
 			die("Invalid proxy URL '%s'", curl_http_proxy);
 
@@ -1235,7 +1278,7 @@ static CURL *get_curl_handle(void)
 			if (ver->version_num < 0x075400)
 				die("libcurl 7.84 or later is required to support paths in proxy URLs");
 
-			if (!starts_with(proxy_auth.protocol, "socks"))
+			if (!is_socks_proxy_protocol(proxy_auth.protocol))
 				die("Invalid proxy URL '%s': only SOCKS proxies support paths",
 				    curl_http_proxy);
 
diff --git a/t/t5564-http-proxy.sh b/t/t5564-http-proxy.sh
index 3bcbdef409..5669ce37d8 100755
--- a/t/t5564-http-proxy.sh
+++ b/t/t5564-http-proxy.sh
@@ -95,4 +95,10 @@ test_expect_success 'Unix socket requires localhost' - <<\EOT
 	}
 EOT
 
+test_expect_success 'unknown proxy scheme is rejected' '
+	test_must_fail git clone -c http.proxy=htpp://127.0.0.1 \
+		https://example.com/repo.git 2>err &&
+	test_grep "unsupported proxy scheme '\''htpp'\''" err
+'
+
 test_done
-- 
2.49.0.windows.1


^ permalink raw reply related

* [PATCH v2 0/1] http: reject unsupported proxy URL schemes
From: aminnimaj @ 2026-05-05  9:19 UTC (permalink / raw)
  To: git; +Cc: gitster, peff, ryan.hendrickson, Aliwoto
In-Reply-To: <20260501190401.1580-1-aminnimaj@gmail.com>

From: Aliwoto <aminnimaj@gmail.com>

An explicit proxy URL with an unsupported scheme such as
htpp://127.0.0.1 is currently accepted and treated as an HTTP proxy.

This happens because Git parses the URL, extracts the host part, and
passes only that host to libcurl without rejecting the unsupported
scheme. As a result, the typo is silently accepted.

This patch rejects explicit unsupported proxy schemes while keeping the
existing host:port-without-scheme behavior unchanged, and adds a
regression test for the unsupported-scheme case.

---
Changes in v2:
- make SOCKS proxy type handling table-driven
- use test_must_fail in the regression test
- use test_grep on the essential error text

Aliwoto (1):
  http: reject unsupported proxy URL schemes

 http.c                | 93 +++++++++++++++++++++++++++++++------------
 t/t5564-http-proxy.sh |  6 +++
 2 files changed, 74 insertions(+), 25 deletions(-)


base-commit: 67ad42147a7acc2af6074753ebd03d904476118f
-- 
2.49.0.windows.1

^ permalink raw reply

* Re: [PATCH v4 6/9] update-ref: handle rejections while adding updates
From: Karthik Nayak @ 2026-05-05  8:23 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git, toon
In-Reply-To: <afmFmGo_Sg33Rv6V@pks.im>

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

Patrick Steinhardt <ps@pks.im> writes:

> On Mon, May 04, 2026 at 07:44:10PM +0200, Karthik Nayak wrote:
>> diff --git a/builtin/update-ref.c b/builtin/update-ref.c
>> index 5259cc7226..6355c3dd3e 100644
>> --- a/builtin/update-ref.c
>> +++ b/builtin/update-ref.c
>> @@ -257,6 +266,31 @@ static void print_rejected_refs(const char *refname,
>>  	strbuf_release(&sb);
>>  }
>>
>> +/*
>> + * Handle transaction errors. If we're using batches updates, we want to only
>> + * die for generic errors and print the remaining to the user.
>> + */
>> +static void handle_ref_transaction_error(const char *refname,
>> +					 struct object_id *new_oid,
>> +					 struct object_id *old_oid,
>> +					 const char *new_target,
>> +					 const char *old_target,
>> +					 enum ref_transaction_error tx_err,
>> +					 struct strbuf *err,
>> +					 struct command_options *opts)
>> +{
>> +	if (!tx_err)
>> +		return;
>> +
>> +	if (tx_err != REF_TRANSACTION_ERROR_GENERIC && opts->allow_update_failures) {
>> +		print_rejected_refs(refname, old_oid, new_oid, old_target,
>> +				    new_target, tx_err, err->buf, NULL);
>> +		return;
>> +	}
>> +
>> +	die("%s", err->buf);
>> +}
>
> It's a bit weird that we pass in the error message as a strbuf given
> that we really only care about the actual message.
>

That's fair. I will add this locally but I think it is also not worth a
re-roll.

>> @@ -644,6 +699,10 @@ static void update_refs_stdin(unsigned int flags)
>>  	struct ref_transaction *transaction;
>>  	int i, j;
>>
>> +	struct command_options opts = {
>> +		.allow_update_failures = flags & REF_TRANSACTION_ALLOW_FAILURE,
>> +	};
>> +
>
> Nit: stray empty line between the variable declarations.
>

Same here.

> Other than that this patch looks good to me, thanks!
>
> Patrick
>

I'll hold off on a re-roll unless needed.

Thanks for the review again!

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

^ permalink raw reply

* [PATCH] log: let --follow follow renames in merge commits
From: Miklos Vajna @ 2026-05-05  7:42 UTC (permalink / raw)
  To: git; +Cc: Patrick Steinhardt, Junio C Hamano, brian m. carlson

Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',
the output only contains history in the outer repo, not commits that
were merged via a subtree merge.

This is inconsistent, since doing a 'git blame prefix/test.c' does find
the original commits. This works because find_rename() in blame.c is
invoked for each parent, and there diff_tree_oid() is used, which uses
try_to_follow_renames(). This means that in case a rename happens as
part of a merge commit, git blame can follow that rename.

Fix the problem in a similar way for the 'git log --follow' case: in
case log_tree_diff() finds a merge commit and it would return early,
then do some extra work in the follow_renames case first. Check each
parent, use diff_tree_oid() and if found_follow is set, then work with
that parent instead of returning.

This means that users examining the history of a repo with subtree
merges can see all commits to a file with a single 'git log --follow'
invocation, instead of one invocation for the outer repo and one for the
history before the subtree merge.

Signed-off-by: Miklos Vajna <vmiklos@collabora.com>
---

Hi,

I recently ran into a case where a subtree merge was used to do a
one-off import of one repo into an other one, into a subdirectory. Turns
out git blame finds original commits nicely, but git log --follow is
less great for this case. This is an improvement to also follow renames
when the rename happens as part of a merge commit.

Let me know if I managed to get some formality wrong, I haven't
contributed since 2022.

Could you please review this?

Thanks,

Miklos

 log-tree.c                          | 20 ++++++++++++++++-
 t/meson.build                       |  1 +
 t/t4218-log-follow-subtree-merge.sh | 34 +++++++++++++++++++++++++++++
 3 files changed, 54 insertions(+), 1 deletion(-)
 create mode 100755 t/t4218-log-follow-subtree-merge.sh

diff --git a/log-tree.c b/log-tree.c
index 7e048701d0..bce09c7dac 100644
--- a/log-tree.c
+++ b/log-tree.c
@@ -1142,8 +1142,26 @@ static int log_tree_diff(struct rev_info *opt, struct commit *commit, struct log
 				/* Show parent info for multiple diffs */
 				log->parent = parents->item;
 			}
-		} else
+		} else {
+			if (opt->diffopt.flags.follow_renames) {
+				/*
+				 * Detect a rename across one of the parents.
+				 * Check each parent till we find a follow.
+				 */
+				struct commit_list *p;
+				for (p = parents; p; p = p->next) {
+					parse_commit_or_die(p->item);
+					diff_tree_oid(get_commit_tree_oid(p->item),
+						      oid, "", &opt->diffopt);
+					diff_queue_clear(&diff_queued_diff);
+					if (opt->diffopt.found_follow) {
+						opt->diffopt.found_follow = 0;
+						break;
+					}
+				}
+			}
 			return 0;
+		}
 	}
 
 	showed_log = 0;
diff --git a/t/meson.build b/t/meson.build
index 7528e5cda5..b4ae8d76d8 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -574,6 +574,7 @@ integration_tests = [
   't4215-log-skewed-merges.sh',
   't4216-log-bloom.sh',
   't4217-log-limit.sh',
+  't4218-log-follow-subtree-merge.sh',
   't4252-am-options.sh',
   't4253-am-keep-cr-dos.sh',
   't4254-am-corrupt.sh',
diff --git a/t/t4218-log-follow-subtree-merge.sh b/t/t4218-log-follow-subtree-merge.sh
new file mode 100755
index 0000000000..7ca607cbb8
--- /dev/null
+++ b/t/t4218-log-follow-subtree-merge.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+test_description='Test --follow follows renames across subtree merges'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'setup subtree-merged repository' '
+	git init inner &&
+	echo inner >inner/inner.txt &&
+	git -C inner add inner.txt &&
+	git -C inner commit -m "inner init" &&
+
+	git init outer &&
+	echo outer >outer/outer.txt &&
+	git -C outer add outer.txt &&
+	git -C outer commit -m "outer init" &&
+
+	git -C outer fetch ../inner master &&
+	git -C outer merge -s ours --no-commit --allow-unrelated-histories \
+		FETCH_HEAD &&
+	git -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&
+	git -C outer commit -m "Merge inner repo into inner/ subdirectory"
+'
+
+test_expect_success '--follow finds the pre-merge commit through a subtree merge' '
+	git -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&
+	echo "inner init" >expect &&
+	test_cmp expect actual
+'
+
+test_done
-- 
2.51.0


^ permalink raw reply related

* [PATCH v3 6/6] branch: add --all-remotes flag
From: Harald Nordgren via GitGitGadget @ 2026-05-05  7:22 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Combined with --forked or --prune-merged, --all-remotes acts on
every configured remote, in addition to any explicit <remote>
arguments. Used alone, it errors out.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  9 ++++++--
 builtin/branch.c              | 40 ++++++++++++++++++++++++-----------
 t/t3200-branch.sh             | 40 +++++++++++++++++++++++++++++++++++
 3 files changed, 75 insertions(+), 14 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 9d4944d17e..5c5b91d9b6 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>
 git branch (-c|-C) [<old-branch>] <new-branch>
 git branch (-d|-D) [-r] <branch-name>...
 git branch --edit-description [<branch-name>]
-git branch --forked <remote>...
-git branch [-f] --prune-merged <remote>...
+git branch --forked (<remote>... | --all-remotes)
+git branch [-f] --prune-merged (<remote>... | --all-remotes)
 
 DESCRIPTION
 -----------
@@ -226,6 +226,11 @@ With `--force` (or `-f`), delete them regardless. The currently
 checked-out branch in any worktree is always preserved, as is
 any branch with `branch.<name>.pruneMerged` set to `false`.
 
+`--all-remotes`::
+	With `--forked` or `--prune-merged`, act on every
+	configured remote in addition to any explicit _<remote>_
+	arguments.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index c2094ca34d..78272daa10 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -685,6 +685,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
 	free_worktrees(worktrees);
 }
 
+static int collect_remote_name(struct remote *remote, void *cb_data)
+{
+	struct string_list *remote_names = cb_data;
+	string_list_insert(remote_names, remote->name);
+	return 0;
+}
+
 static void parse_forked_args(int argc, const char **argv,
 			      struct string_list *remote_names,
 			      struct string_list *tracking_refs)
@@ -754,7 +761,7 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
 	return 0;
 }
 
-static void collect_forked_set(int argc, const char **argv,
+static void collect_forked_set(int argc, const char **argv, int all_remotes,
 			       struct string_list *out)
 {
 	struct string_list remote_names = STRING_LIST_INIT_NODUP;
@@ -766,6 +773,8 @@ static void collect_forked_set(int argc, const char **argv,
 	};
 
 	parse_forked_args(argc, argv, &remote_names, &tracking_refs);
+	if (all_remotes)
+		for_each_remote(collect_remote_name, &remote_names);
 
 	refs_for_each_branch_ref(get_main_ref_store(the_repository),
 				 collect_forked_branch, &cb);
@@ -776,15 +785,15 @@ static void collect_forked_set(int argc, const char **argv,
 	string_list_clear(&tracking_refs, 0);
 }
 
-static int list_forked_branches(int argc, const char **argv)
+static int list_forked_branches(int argc, const char **argv, int all_remotes)
 {
 	struct string_list out = STRING_LIST_INIT_DUP;
 	struct string_list_item *item;
 
-	if (!argc)
-		die(_("--forked requires at least one <remote>"));
+	if (!argc && !all_remotes)
+		die(_("--forked requires at least one <remote> or --all-remotes"));
 
-	collect_forked_set(argc, argv, &out);
+	collect_forked_set(argc, argv, all_remotes, &out);
 	for_each_string_list_item(item, &out)
 		puts(item->string);
 
@@ -792,8 +801,8 @@ static int list_forked_branches(int argc, const char **argv)
 	return 0;
 }
 
-static int prune_merged_branches(int argc, const char **argv, int force,
-				 int quiet)
+static int prune_merged_branches(int argc, const char **argv,
+				 int all_remotes, int force, int quiet)
 {
 	struct string_list candidates = STRING_LIST_INIT_DUP;
 	struct strvec deletable = STRVEC_INIT;
@@ -801,10 +810,10 @@ static int prune_merged_branches(int argc, const char **argv, int force,
 	int n_not_merged = 0;
 	int ret = 0;
 
-	if (!argc)
-		die(_("--prune-merged requires at least one <remote>"));
+	if (!argc && !all_remotes)
+		die(_("--prune-merged requires at least one <remote> or --all-remotes"));
 
-	collect_forked_set(argc, argv, &candidates);
+	collect_forked_set(argc, argv, all_remotes, &candidates);
 
 	for_each_string_list_item(item, &candidates) {
 		const char *short_name = item->string;
@@ -911,6 +920,7 @@ int cmd_branch(int argc,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	int forked = 0;
 	int prune_merged = 0;
+	int all_remotes = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -968,6 +978,9 @@ int cmd_branch(int argc,
 			N_("list local branches forked from the given <remote>s")),
 		OPT_BOOL(0, "prune-merged", &prune_merged,
 			N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
+		OPT_BOOL_F(0, "all-remotes", &all_remotes,
+			N_("with --forked or --prune-merged, act on every configured remote"),
+			PARSE_OPT_NONEG),
 		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")),
@@ -1011,6 +1024,9 @@ int cmd_branch(int argc,
 	argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
 			     0);
 
+	if (all_remotes && !forked && !prune_merged)
+		die(_("--all-remotes requires --forked or --prune-merged"));
+
 	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
 	    !show_current && !unset_upstream && !forked && !prune_merged &&
 	    argc == 0)
@@ -1064,10 +1080,10 @@ int cmd_branch(int argc,
 				      quiet, 0, NULL);
 		goto out;
 	} else if (forked) {
-		ret = list_forked_branches(argc, argv);
+		ret = list_forked_branches(argc, argv, all_remotes);
 		goto out;
 	} else if (prune_merged) {
-		ret = prune_merged_branches(argc, argv, force, quiet);
+		ret = prune_merged_branches(argc, argv, all_remotes, force, quiet);
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 9af7de690e..f401d8db19 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '
 	test_grep "at least one <remote>" err
 '
 
+test_expect_success '--forked --all-remotes covers every configured remote' '
+	git -C forked branch --forked --all-remotes >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	local-two
+	main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked --all-remotes still validates explicit <remote>' '
+	test_must_fail git -C forked branch --forked nope --all-remotes 2>err &&
+	test_grep "neither a configured remote nor a remote-tracking branch" err
+'
+
+test_expect_success '--all-remotes alone is rejected' '
+	test_must_fail git -C forked branch --all-remotes 2>err &&
+	test_grep "requires --forked or --prune-merged" err
+'
+
 test_expect_success '--prune-merged: setup' '
 	test_create_repo pm-upstream &&
 	test_commit -C pm-upstream base &&
@@ -1892,4 +1913,23 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
 	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
 '
 
+test_expect_success '--prune-merged --all-remotes covers every configured remote' '
+	test_when_finished "rm -rf pm-allremotes" &&
+	git clone pm-upstream pm-allremotes &&
+	test_create_repo pm-other &&
+	test_commit -C pm-other other-base &&
+	git -C pm-other branch foreign other-base &&
+	git -C pm-allremotes remote add other ../pm-other &&
+	git -C pm-allremotes fetch other &&
+	git -C pm-allremotes branch one --track origin/one &&
+	git -C pm-allremotes branch foreign --track other/foreign &&
+
+	git -C pm-allremotes update-ref -d refs/remotes/origin/one &&
+	git -C pm-allremotes update-ref -d refs/remotes/other/foreign &&
+	git -C pm-allremotes branch --force --prune-merged --all-remotes &&
+
+	test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign
+'
+
 test_done
-- 
gitgitgadget

^ permalink raw reply related

* [PATCH v3 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-05-05  7:22 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Setting branch.<name>.pruneMerged=false exempts that branch from
--prune-merged (and from fetch --prune-merged), even with --force.
Useful for keeping a topic branch around between rounds.

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

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/config/branch.adoc |  7 ++++++
 Documentation/git-branch.adoc    | 17 +++++++-------
 builtin/branch.c                 | 23 ++++++++++++++++--
 t/t3200-branch.sh                | 40 ++++++++++++++++++++++++++++++++
 4 files changed, 76 insertions(+), 11 deletions(-)

diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..60dba38e27 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -102,3 +102,10 @@ for details).
 	`git branch --edit-description`. Branch description is
 	automatically added to the `format-patch` cover letter or
 	`request-pull` summary.
+
+`branch.<name>.pruneMerged`::
+	If set to `false`, branch _<name>_ is exempt from
+	`git branch --prune-merged` (and `git fetch --prune-merged`).
+	Useful for topic branches you intend to develop further after
+	an initial round has been merged upstream. Defaults to true.
+	Explicit deletion via `git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 80b20a55eb..9d4944d17e 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -216,16 +216,15 @@ Each _<remote>_ may be either the name of a configured remote
 	Delete the local branches that `--forked` would list for
 	the same _<remote>_ arguments, but only when the branch's
 	push destination remote-tracking branch (the branch `git push`
-	would update; see `branch_get_push` semantics) no longer
-	resolves locally. In other words: the branch was pushed
-	under some name on _<remote>_, and that name has since
-	been pruned upstream.
+	would update) no longer resolves locally. In other words:
+	the branch was pushed under some name on _<remote>_, and
+	that name has since been pruned upstream.
 +
-By default, the local tip must also be reachable from the
-upstream remote-tracking branch (see `--no-merged`); branches with
-unpushed commits are refused. With `--force` (or `-f`), delete
-them regardless. The currently checked-out branch in any worktree
-is always preserved.
+The local tip must also be reachable from the upstream
+remote-tracking branch; branches with unpushed commits are refused.
+With `--force` (or `-f`), delete them regardless. The currently
+checked-out branch in any worktree is always preserved, as is
+any branch with `branch.<name>.pruneMerged` set to `false`.
 
 `-v`::
 `-vv`::
diff --git a/builtin/branch.c b/builtin/branch.c
index d3ca320d4d..c2094ca34d 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -809,23 +809,42 @@ static int prune_merged_branches(int argc, const char **argv, int force,
 	for_each_string_list_item(item, &candidates) {
 		const char *short_name = item->string;
 		struct strbuf full = STRBUF_INIT;
+		struct strbuf key = STRBUF_INIT;
 		struct branch *branch;
 		const char *push_ref;
+		int opt_out = 0;
 
 		strbuf_addf(&full, "refs/heads/%s", short_name);
 		if (branch_checked_out(full.buf)) {
 			strbuf_release(&full);
+			strbuf_release(&key);
 			continue;
 		}
 		strbuf_release(&full);
 
 		branch = branch_get(short_name);
 		push_ref = branch ? branch_get_push(branch, NULL) : NULL;
-		if (!push_ref)
+		if (!push_ref) {
+			strbuf_release(&key);
 			continue;
+		}
 		if (refs_ref_exists(get_main_ref_store(the_repository),
-				    push_ref))
+				    push_ref)) {
+			strbuf_release(&key);
+			continue;
+		}
+
+		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+		    !opt_out) {
+			if (!quiet)
+				fprintf(stderr, _("Skipping '%s' "
+						  "(branch.%s.pruneMerged is false)\n"),
+					short_name, short_name);
+			strbuf_release(&key);
 			continue;
+		}
+		strbuf_release(&key);
 
 		strvec_push(&deletable, short_name);
 	}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e6e6eab482..9af7de690e 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1852,4 +1852,44 @@ test_expect_success '--prune-merged deletes when push ref differs from upstream'
 	test_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a
 '
 
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+	test_when_finished "rm -rf pm-optout" &&
+	git clone pm-upstream pm-optout &&
+	git -C pm-optout branch one --track origin/one &&
+	git -C pm-optout branch two --track origin/two &&
+	git -C pm-optout config branch.one.pruneMerged false &&
+
+	git -C pm-optout update-ref -d refs/remotes/origin/one &&
+	git -C pm-optout update-ref -d refs/remotes/origin/two &&
+	git -C pm-optout branch --prune-merged origin 2>err &&
+
+	git -C pm-optout rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+	test_grep "Skipping .one." err
+'
+
+test_expect_success '--prune-merged --force still honours pruneMerged=false' '
+	test_when_finished "rm -rf pm-optout-force" &&
+	git clone pm-upstream pm-optout-force &&
+	git -C pm-optout-force checkout -b one --track origin/one &&
+	test_commit -C pm-optout-force unpushed &&
+	git -C pm-optout-force checkout - &&
+	git -C pm-optout-force config branch.one.pruneMerged false &&
+
+	git -C pm-optout-force update-ref -d refs/remotes/origin/one &&
+	git -C pm-optout-force branch --force --prune-merged origin &&
+
+	git -C pm-optout-force rev-parse --verify refs/heads/one
+'
+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+	test_when_finished "rm -rf pm-optout-d" &&
+	git clone pm-upstream pm-optout-d &&
+	git -C pm-optout-d branch one --track origin/one &&
+	git -C pm-optout-d config branch.one.pruneMerged false &&
+
+	git -C pm-optout-d branch -d one &&
+	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v3 4/6] fetch: add --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-05-05  7:22 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

After a successful fetch from a configured remote, run
'git branch --prune-merged <remote>' to delete local branches
whose push destination ref has just been pruned.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/fetch-options.adoc |  8 ++++++++
 builtin/fetch.c                  | 20 ++++++++++++++++++++
 t/t5510-fetch.sh                 | 31 +++++++++++++++++++++++++++++++
 3 files changed, 59 insertions(+)

diff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc
index 81a9d7f9bb..afbd1f60b8 100644
--- a/Documentation/fetch-options.adoc
+++ b/Documentation/fetch-options.adoc
@@ -185,6 +185,14 @@ See the PRUNING section below for more details.
 +
 See the PRUNING section below for more details.
 
+`--prune-merged`::
+	After a successful fetch, run `git branch --prune-merged
+	<remote>` for the fetched remote, deleting local branches
+	that fork from this remote and whose tip is reachable from
+	their upstream remote-tracking branch. See linkgit:git-branch[1]
+	for the exact selection rules. The currently checked-out
+	branch is always preserved.
+
 endif::git-pull[]
 
 ifndef::git-pull[]
diff --git a/builtin/fetch.c b/builtin/fetch.c
index a22c319467..5451bf3b5b 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -82,6 +82,8 @@ static int prune = -1; /* unspecified */
 static int prune_tags = -1; /* unspecified */
 #define PRUNE_TAGS_BY_DEFAULT 0 /* do we prune tags by default? */
 
+static int prune_merged;
+
 static int append, dry_run, force, keep, update_head_ok;
 static int write_fetch_head = 1;
 static int verbosity, deepen_relative, set_upstream, refetch;
@@ -2189,6 +2191,8 @@ static void add_options_to_argv(struct strvec *argv,
 		strvec_push(argv, prune ? "--prune" : "--no-prune");
 	if (prune_tags != -1)
 		strvec_push(argv, prune_tags ? "--prune-tags" : "--no-prune-tags");
+	if (prune_merged)
+		strvec_push(argv, "--prune-merged");
 	if (update_head_ok)
 		strvec_push(argv, "--update-head-ok");
 	if (force)
@@ -2382,6 +2386,15 @@ static inline void fetch_one_setup_partial(struct remote *remote,
 	return;
 }
 
+static int prune_merged_for_remote(const struct remote *remote)
+{
+	struct child_process cmd = CHILD_PROCESS_INIT;
+
+	cmd.git_cmd = 1;
+	strvec_pushl(&cmd.args, "branch", "--prune-merged", remote->name, NULL);
+	return run_command(&cmd);
+}
+
 static int fetch_one(struct remote *remote, int argc, const char **argv,
 		     int prune_tags_ok, int use_stdin_refspecs,
 		     const struct fetch_config *config,
@@ -2457,6 +2470,11 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,
 	refspec_clear(&rs);
 	transport_disconnect(gtransport);
 	gtransport = NULL;
+
+	if (!exit_code && prune_merged && remote_via_config &&
+	    prune_merged_for_remote(remote))
+		exit_code = 1;
+
 	return exit_code;
 }
 
@@ -2520,6 +2538,8 @@ int cmd_fetch(int argc,
 			 N_("prune remote-tracking branches no longer on remote")),
 		OPT_BOOL('P', "prune-tags", &prune_tags,
 			 N_("prune local tags no longer on remote and clobber changed tags")),
+		OPT_BOOL(0, "prune-merged", &prune_merged,
+			 N_("after pruning, also delete local branches forked from this remote whose tips are reachable from their upstream")),
 		OPT_CALLBACK_F(0, "recurse-submodules", &recurse_submodules_cli, N_("on-demand"),
 			    N_("control recursive fetching of submodules"),
 			    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),
diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
index 6fe21e2b3a..b94fd2bda0 100755
--- a/t/t5510-fetch.sh
+++ b/t/t5510-fetch.sh
@@ -386,6 +386,37 @@ test_expect_success REFFILES 'fetch --prune fails to delete branches' '
 	)
 '
 
+test_expect_success 'fetch --prune-merged: setup' '
+	git init -b main fetch-pm-parent &&
+	test_commit -C fetch-pm-parent base
+'
+
+test_expect_success 'fetch --prune-merged deletes merged local branches' '
+	test_when_finished "rm -rf fetch-pm-clone" &&
+	git -C fetch-pm-parent branch one base &&
+	git clone fetch-pm-parent fetch-pm-clone &&
+	git -C fetch-pm-clone branch one --track origin/one &&
+	git -C fetch-pm-parent branch -D one &&
+
+	git -C fetch-pm-clone fetch --prune --prune-merged origin &&
+
+	test_must_fail git -C fetch-pm-clone rev-parse --verify refs/heads/one
+'
+
+test_expect_success 'fetch --prune-merged skips unmerged local branches' '
+	test_when_finished "rm -rf fetch-pm-unmerged" &&
+	git -C fetch-pm-parent branch two base &&
+	git clone fetch-pm-parent fetch-pm-unmerged &&
+	git -C fetch-pm-unmerged checkout -b two --track origin/two &&
+	test_commit -C fetch-pm-unmerged unpushed &&
+	git -C fetch-pm-unmerged checkout - &&
+	git -C fetch-pm-parent branch -D two &&
+
+	git -C fetch-pm-unmerged fetch --prune --prune-merged origin 2>err &&
+	test_grep "not fully merged" err &&
+	git -C fetch-pm-unmerged rev-parse --verify refs/heads/two
+'
+
 test_expect_success 'fetch --atomic works with a single branch' '
 	test_when_finished "rm -rf atomic" &&
 
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v3 3/6] branch: add --prune-merged <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-05  7:22 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Delete the local branches that --forked <remote> would list,
refusing any whose tip is not reachable from its upstream
remote-tracking branch. With --force, delete unconditionally.
The currently checked-out branch in any worktree is always
preserved.

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

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5773104cd3..80b20a55eb 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
 git branch (-d|-D) [-r] <branch-name>...
 git branch --edit-description [<branch-name>]
 git branch --forked <remote>...
+git branch [-f] --prune-merged <remote>...
 
 DESCRIPTION
 -----------
@@ -211,6 +212,21 @@ Each _<remote>_ may be either the name of a configured remote
 `refs/remotes/origin/*` ref) or a specific remote-tracking branch
 (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
 
+`--prune-merged`::
+	Delete the local branches that `--forked` would list for
+	the same _<remote>_ arguments, but only when the branch's
+	push destination remote-tracking branch (the branch `git push`
+	would update; see `branch_get_push` semantics) no longer
+	resolves locally. In other words: the branch was pushed
+	under some name on _<remote>_, and that name has since
+	been pruned upstream.
++
+By default, the local tip must also be reachable from the
+upstream remote-tracking branch (see `--no-merged`); branches with
+unpushed commits are refused. With `--force` (or `-f`), delete
+them regardless. The currently checked-out branch in any worktree
+is always preserved.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1941f8a9ad..d3ca320d4d 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -21,6 +21,7 @@
 #include "branch.h"
 #include "path.h"
 #include "string-list.h"
+#include "strvec.h"
 #include "column.h"
 #include "utf8.h"
 #include "ref-filter.h"
@@ -753,36 +754,101 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
 	return 0;
 }
 
-static int list_forked_branches(int argc, const char **argv)
+static void collect_forked_set(int argc, const char **argv,
+			       struct string_list *out)
 {
 	struct string_list remote_names = STRING_LIST_INIT_NODUP;
 	struct string_list tracking_refs = STRING_LIST_INIT_DUP;
-	struct string_list out = STRING_LIST_INIT_DUP;
-	struct string_list_item *item;
 	struct forked_cb cb = {
 		.remote_names = &remote_names,
 		.tracking_refs = &tracking_refs,
-		.out = &out,
+		.out = out,
 	};
 
-	if (!argc)
-		die(_("--forked requires at least one <remote>"));
-
 	parse_forked_args(argc, argv, &remote_names, &tracking_refs);
 
 	refs_for_each_branch_ref(get_main_ref_store(the_repository),
 				 collect_forked_branch, &cb);
 
-	string_list_sort(&out);
-	for_each_string_list_item(item, &out)
-		puts(item->string);
+	string_list_sort(out);
 
 	string_list_clear(&remote_names, 0);
 	string_list_clear(&tracking_refs, 0);
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+	struct string_list out = STRING_LIST_INIT_DUP;
+	struct string_list_item *item;
+
+	if (!argc)
+		die(_("--forked requires at least one <remote>"));
+
+	collect_forked_set(argc, argv, &out);
+	for_each_string_list_item(item, &out)
+		puts(item->string);
+
 	string_list_clear(&out, 0);
 	return 0;
 }
 
+static int prune_merged_branches(int argc, const char **argv, int force,
+				 int quiet)
+{
+	struct string_list candidates = STRING_LIST_INIT_DUP;
+	struct strvec deletable = STRVEC_INIT;
+	struct string_list_item *item;
+	int n_not_merged = 0;
+	int ret = 0;
+
+	if (!argc)
+		die(_("--prune-merged requires at least one <remote>"));
+
+	collect_forked_set(argc, argv, &candidates);
+
+	for_each_string_list_item(item, &candidates) {
+		const char *short_name = item->string;
+		struct strbuf full = STRBUF_INIT;
+		struct branch *branch;
+		const char *push_ref;
+
+		strbuf_addf(&full, "refs/heads/%s", short_name);
+		if (branch_checked_out(full.buf)) {
+			strbuf_release(&full);
+			continue;
+		}
+		strbuf_release(&full);
+
+		branch = branch_get(short_name);
+		push_ref = branch ? branch_get_push(branch, NULL) : NULL;
+		if (!push_ref)
+			continue;
+		if (refs_ref_exists(get_main_ref_store(the_repository),
+				    push_ref))
+			continue;
+
+		strvec_push(&deletable, short_name);
+	}
+
+	if (deletable.nr)
+		ret = delete_branches(deletable.nr, deletable.v, force,
+				      FILTER_REFS_BRANCHES, quiet,
+				      1, &n_not_merged);
+
+	if (n_not_merged && !quiet)
+		fprintf(stderr,
+			Q_("Skipped %d branch that is not fully merged; "
+			   "re-run with --force to delete it anyway.\n",
+			   "Skipped %d branches that are not fully merged; "
+			   "re-run with --force to delete them anyway.\n",
+			   n_not_merged),
+			n_not_merged);
+
+	strvec_clear(&deletable);
+	string_list_clear(&candidates, 0);
+	return ret;
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -825,6 +891,7 @@ int cmd_branch(int argc,
 	int delete = 0, rename = 0, copy = 0, list = 0,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	int forked = 0;
+	int prune_merged = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -880,6 +947,8 @@ int cmd_branch(int argc,
 			 N_("edit the description for the branch")),
 		OPT_BOOL(0, "forked", &forked,
 			N_("list local branches forked from the given <remote>s")),
+		OPT_BOOL(0, "prune-merged", &prune_merged,
+			N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
 		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")),
@@ -924,7 +993,8 @@ int cmd_branch(int argc,
 			     0);
 
 	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
-	    !show_current && !unset_upstream && !forked && argc == 0)
+	    !show_current && !unset_upstream && !forked && !prune_merged &&
+	    argc == 0)
 		list = 1;
 
 	if (filter.with_commit || filter.no_commit ||
@@ -933,7 +1003,7 @@ int cmd_branch(int argc,
 
 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
 			    !!show_current + !!list + !!edit_description +
-			    !!unset_upstream + !!forked;
+			    !!unset_upstream + !!forked + !!prune_merged;
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
@@ -977,6 +1047,9 @@ int cmd_branch(int argc,
 	} else if (forked) {
 		ret = list_forked_branches(argc, argv);
 		goto out;
+	} else if (prune_merged) {
+		ret = prune_merged_branches(argc, argv, force, quiet);
+		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
 		ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 24a3ec44ee..e6e6eab482 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1771,4 +1771,85 @@ test_expect_success '--forked requires at least one <remote>' '
 	test_grep "at least one <remote>" err
 '
 
+test_expect_success '--prune-merged: setup' '
+	test_create_repo pm-upstream &&
+	test_commit -C pm-upstream base &&
+	git -C pm-upstream branch one base &&
+	git -C pm-upstream branch two base
+'
+
+test_expect_success '--prune-merged deletes branches whose push ref is gone' '
+	test_when_finished "rm -rf pm-clean" &&
+	git clone pm-upstream pm-clean &&
+	git -C pm-clean branch one --track origin/one &&
+	git -C pm-clean branch two --track origin/two &&
+
+	git -C pm-clean update-ref -d refs/remotes/origin/one &&
+	git -C pm-clean branch --prune-merged origin &&
+
+	test_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&
+	git -C pm-clean rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '
+	test_when_finished "rm -rf pm-inflight" &&
+	git clone pm-upstream pm-inflight &&
+	git -C pm-inflight branch one --track origin/one &&
+
+	git -C pm-inflight branch --prune-merged origin &&
+
+	git -C pm-inflight rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged skips branches with unpushed commits' '
+	test_when_finished "rm -rf pm-unmerged" &&
+	git clone pm-upstream pm-unmerged &&
+	git -C pm-unmerged checkout -b one --track origin/one &&
+	test_commit -C pm-unmerged unpushed &&
+	git -C pm-unmerged checkout - &&
+
+	git -C pm-unmerged update-ref -d refs/remotes/origin/one &&
+	git -C pm-unmerged branch --prune-merged origin 2>err &&
+	test_grep "not fully merged" err &&
+	test_grep "Skipped 1 branch" err &&
+	test_grep "re-run with --force" err &&
+	test_grep ! "If you are sure you want to delete it" err &&
+	git -C pm-unmerged rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '
+	test_when_finished "rm -rf pm-force" &&
+	git clone pm-upstream pm-force &&
+	git -C pm-force checkout -b one --track origin/one &&
+	test_commit -C pm-force unpushed &&
+	git -C pm-force checkout - &&
+
+	git -C pm-force update-ref -d refs/remotes/origin/one &&
+	git -C pm-force branch --force --prune-merged origin &&
+
+	test_must_fail git -C pm-force rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+	test_when_finished "rm -rf pm-head" &&
+	git clone pm-upstream pm-head &&
+	git -C pm-head checkout -b one --track origin/one &&
+
+	git -C pm-head update-ref -d refs/remotes/origin/one &&
+	git -C pm-head branch --force --prune-merged origin &&
+
+	git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged deletes when push ref differs from upstream' '
+	test_when_finished "rm -rf pm-pushdiff" &&
+	git clone pm-upstream pm-pushdiff &&
+	git -C pm-pushdiff config push.default current &&
+	git -C pm-pushdiff branch --track topic-a origin/main &&
+
+	git -C pm-pushdiff branch --force --prune-merged origin &&
+
+	test_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v3 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-05-05  7:22 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Add two new parameters to delete_branches() and the helper
check_branch_commit():

* warn_only switches the per-branch refusal from a hard error
  ("error: the branch 'X' is not fully merged" plus a four-line
  hint about 'git branch -D X') to a one-line warning, and
  causes the function to skip those branches without setting its
  exit code. Each refused branch is still skipped from deletion.
* n_not_merged, when non-NULL, is incremented for each branch
  refused on the not-merged path, so a bulk caller can summarize
  rather than print per-branch advice.

All existing call sites pass 0 / NULL and so are unaffected. Both
parameters are wired up so a bulk-deletion caller can suppress
the noise normally appropriate for a one-shot 'git branch -d'.

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

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


^ permalink raw reply related

* [PATCH v3 1/6] branch: add --forked <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-05  7:22 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

List local branches whose configured upstream falls within any of
the given <remote> arguments. <remote> may be either a configured
remote name (matching all of its remote-tracking branches) or a
single remote-tracking branch. Multiple <remote> arguments are
unioned.

This is the building block for --prune-merged, which deletes the
listed branches.

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

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..5773104cd3 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 <remote>...
 
 DESCRIPTION
 -----------
@@ -199,6 +200,17 @@ 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 that fork from any of the given _<remote>_
+	arguments, that is, those whose configured upstream
+	(`branch.<name>.merge`) is one of those remotes' remote-tracking
+	branches.
++
+Each _<remote>_ may be either the name of a configured remote
+(e.g. `origin`, meaning any branch tracking a
+`refs/remotes/origin/*` ref) or a specific remote-tracking branch
+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..b3289a8875 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,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 <remote>..."),
 	NULL
 };
 
@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
 	free_worktrees(worktrees);
 }
 
+static void parse_forked_args(int argc, const char **argv,
+			      struct string_list *remote_names,
+			      struct string_list *tracking_refs)
+{
+	int i;
+
+	for (i = 0; i < argc; i++) {
+		const char *arg = argv[i];
+		struct remote *remote;
+		struct object_id oid;
+		char *full_ref = NULL;
+
+		remote = remote_get(arg);
+		if (remote && remote_is_configured(remote, 0)) {
+			string_list_insert(remote_names, remote->name);
+			continue;
+		}
+
+		if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+				  &full_ref, 0) == 1 &&
+		    starts_with(full_ref, "refs/remotes/")) {
+			string_list_insert(tracking_refs, full_ref);
+			free(full_ref);
+			continue;
+		}
+		free(full_ref);
+
+		die(_("'%s' is neither a configured remote nor a "
+		      "remote-tracking branch"), arg);
+	}
+}
+
+static int branch_is_forked(const char *short_name,
+			    const struct string_list *remote_names,
+			    const struct string_list *tracking_refs)
+{
+	struct branch *branch = branch_get(short_name);
+	const char *upstream;
+
+	if (!branch || !branch->remote_name)
+		return 0;
+
+	if (string_list_has_string(remote_names, branch->remote_name))
+		return 1;
+
+	upstream = branch_get_upstream(branch, NULL);
+	if (upstream && string_list_has_string(tracking_refs, upstream))
+		return 1;
+
+	return 0;
+}
+
+struct forked_cb {
+	const struct string_list *remote_names;
+	const struct string_list *tracking_refs;
+	struct string_list *out;
+};
+
+static int collect_forked_branch(const struct reference *ref, void *cb_data)
+{
+	struct forked_cb *cb = cb_data;
+
+	if (ref->flags & REF_ISSYMREF)
+		return 0;
+	if (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))
+		string_list_append(cb->out, ref->name);
+	return 0;
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+	struct string_list remote_names = STRING_LIST_INIT_NODUP;
+	struct string_list tracking_refs = STRING_LIST_INIT_DUP;
+	struct string_list out = STRING_LIST_INIT_DUP;
+	struct string_list_item *item;
+	struct forked_cb cb = {
+		.remote_names = &remote_names,
+		.tracking_refs = &tracking_refs,
+		.out = &out,
+	};
+
+	if (!argc)
+		die(_("--forked requires at least one <remote>"));
+
+	parse_forked_args(argc, argv, &remote_names, &tracking_refs);
+
+	refs_for_each_branch_ref(get_main_ref_store(the_repository),
+				 collect_forked_branch, &cb);
+
+	string_list_sort(&out);
+	for_each_string_list_item(item, &out)
+		puts(item->string);
+
+	string_list_clear(&remote_names, 0);
+	string_list_clear(&tracking_refs, 0);
+	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 +814,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 +868,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 forked from the given <remote>s")),
 		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 +914,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 +923,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 +963,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..24a3ec44ee 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '
 	test_cmp expect actual
 '
 
+test_expect_success '--forked: setup' '
+	test_create_repo forked-upstream &&
+	test_commit -C forked-upstream base &&
+	git -C forked-upstream branch one base &&
+	git -C forked-upstream branch two base &&
+
+	test_create_repo forked-other &&
+	test_commit -C forked-other other-base &&
+	git -C forked-other branch foreign other-base &&
+
+	git clone forked-upstream forked &&
+	git -C forked remote add other ../forked-other &&
+	git -C forked fetch other &&
+	git -C forked branch --track local-one origin/one &&
+	git -C forked branch --track local-two origin/two &&
+	git -C forked branch --track local-foreign other/foreign &&
+	git -C forked branch detached
+'
+
+test_expect_success '--forked <remote-name> lists branches tracking that remote' '
+	git -C forked branch --forked origin >actual &&
+	cat >expect <<-\EOF &&
+	local-one
+	local-two
+	main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '
+	git -C forked branch --forked origin/one >actual &&
+	echo local-one >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked unions multiple <remote> arguments' '
+	git -C forked branch --forked origin/one other >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown remote/ref' '
+	test_must_fail git -C forked branch --forked nope 2>err &&
+	test_grep "neither a configured remote nor a remote-tracking branch" err
+'
+
+test_expect_success '--forked requires at least one <remote>' '
+	test_must_fail git -C forked branch --forked 2>err &&
+	test_grep "at least one <remote>" err
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related


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