Git development
 help / color / mirror / Atom feed
* Re: [PATCH 3/6] hash algorithms: use size_t for section lengths
From: Johannes Schindelin @ 2026-06-16 14:48 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: Philip Oakley via GitGitGadget, git, Philip Oakley
In-Reply-To: <ai-5VmawU2MRiAHQ@pks.im>

Hi Patrick,

On Tue, 16 Jun 2026, Patrick Steinhardt wrote:

> On Thu, Jun 04, 2026 at 05:15:09PM +0000, Philip Oakley via GitGitGadget wrote:
> > diff --git a/object-file.c b/object-file.c
> > index 1f5f9daf24..c648cecd80 100644
> > --- a/object-file.c
> > +++ b/object-file.c
> > @@ -581,7 +581,7 @@ static void write_object_file_prepare(const struct git_hash_algo *algo,
> >  	/* Generate the header */
> >  	*hdrlen = format_object_header(hdr, *hdrlen, type, len);
> >  
> > -	/* Sha1.. */
> > +	/* Hash (function pointers) computation */
> >  	hash_object_body(algo, &c, buf, len, oid, hdr, hdrlen);
> >  }
> >  
> 
> Thanks for updating this comment while at it :)

It wasn't my idea, it was Claude Opus'. I would have left it as-is, but
then decided that it's actually a good change and not worth splitting out
into a separate commit.

> > diff --git a/t/t1007-hash-object.sh b/t/t1007-hash-object.sh
> > index 7867fd1dbf..10382a815e 100755
> > --- a/t/t1007-hash-object.sh
> > +++ b/t/t1007-hash-object.sh
> > @@ -261,7 +261,7 @@ test_expect_success '--stdin outside of repository (uses default hash)' '
> >  	test_cmp expect actual
> >  '
> >  
> > -test_expect_failure EXPENSIVE,SIZE_T_IS_64BIT,!LONG_IS_64BIT \
> > +test_expect_success EXPENSIVE,SIZE_T_IS_64BIT,!LONG_IS_64BIT \
> >  		'files over 4GB hash literally' '
> >  	test-tool genzeros $((5*1024*1024*1024)) >big &&
> >  	test_oid large5GB >expect &&
> 
> Previously we required `!LONG_IS_64BIT`, because the test would have
> succeeded on platforms where it is 64 bit wide. But now that this test
> works on all platforms I rather wonder whether we should completely drop
> that prerequisite here, as we expect it to pass regardless of whether or
> not long is 64 bit now.

Good point!

Thank you for the review,
Johannes

^ permalink raw reply

* Re: [PATCH 5/6] hash-object: add another >4GB/LLP64 test case
From: Johannes Schindelin @ 2026-06-16 14:48 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: Philip Oakley via GitGitGadget, git, Philip Oakley
In-Reply-To: <ai-5ZvDgc4smGfGc@pks.im>

Hi Patrick,

On Tue, 16 Jun 2026, Patrick Steinhardt wrote:

> On Thu, Jun 04, 2026 at 05:15:11PM +0000, Philip Oakley via GitGitGadget wrote:
> > diff --git a/t/t1007-hash-object.sh b/t/t1007-hash-object.sh
> > index 59efee3aff..f2722380ee 100755
> > --- a/t/t1007-hash-object.sh
> > +++ b/t/t1007-hash-object.sh
> > @@ -277,4 +277,12 @@ test_expect_success EXPENSIVE,SIZE_T_IS_64BIT,!LONG_IS_64BIT \
> >  	test_cmp expect actual
> >  '
> >  
> > +test_expect_success EXPENSIVE,SIZE_T_IS_64BIT,!LONG_IS_64BIT \
> > +		'files over 4GB hash correctly' '
> > +	{ test -f big || test-tool genzeros $((5*1024*1024*1024)) >big; } &&
> > +	test_oid large5GB >expect &&
> > +	git hash-object -- big >actual &&
> > +	test_cmp expect actual
> > +'
> 
> Same comment here.

[Comment was the suggestion to drop the !LONG_IS_64BIT prerequisite]

Same comment here. [My reply: Good point!]

> Nit: I feel like we could've easily introduced all of these tests in the
> first commit.

Sure, but I actually liked the structuring by Philip when I accepted the
patches into Git for Windows, and I still do: The commits all have
slightly different concerns, and I love that the cognitive load is
lightened somewhat by keeping those concerns in separate patches with
separate commit messages.

Ciao,
Johannes

^ permalink raw reply

* Re: [PATCH 2/6] object-file.c: use size_t for header lengths
From: Johannes Schindelin @ 2026-06-16 14:48 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: Philip Oakley via GitGitGadget, git, Philip Oakley
In-Reply-To: <ai-5XO9gsc_HdMFX@pks.im>

Hi Patrick,

On Mon, 15 Jun 2026, Patrick Steinhardt wrote:

> On Thu, Jun 04, 2026 at 05:15:08PM +0000, Philip Oakley via GitGitGadget wrote:
> > From: Philip Oakley <philipoakley@iee.email>
> > 
> > Continue walking the code path for the >4GB `hash-object --literally`
> > test. The `hash_object_file_literally()` function internally uses both
> > `hash_object_file()` and `write_object_file_prepare()`. Both function
> > signatures use `unsigned long` rather than `size_t` for the mem buffer
> > sizes. Use `size_t` instead, for LLP64 compatibility.
> > 
> > While at it, convert those function's object's header buffer length to
> > `size_t` for consistency. The value is already upcast to `uintmax_t` for
> > print format compatibility.
> 
> One thing I was wondering is whether we should rather migrate to a size
> that is consistent across different platforms. We could e.g. `typedef
> uint64_t objsize_t` and then use that going forward.

No, the point of `size_t` is to represent what the current platform can
handle in-memory. That cannot (and should not) be consolidated.

> I guess the question though is whether that'd buy us anything. In other
> words, are there any platforms that we care about where `size_t` is only
> 32 bit wide? And would such platforms even be able to handle such large
> objects?

There are ways to handle objects larger than 4GB on 32-bit platforms, via
streaming. In those cases, what you need is `off_t`, not `size_t`.

Obviously, there is a large class of problems with such setups. For
example, you can forget about efficiently reconstructing a large Git
object from a delta chain. If you cannot do that in-memory, trying to work
around that limitation merely opens up the user for a world of pain.

Ciao,
Johannes

^ permalink raw reply

* Re: [PATCH v3] read_gitfile(): simplify NOT_A_REPO error message
From: Jeff King @ 2026-06-16 14:45 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: Patrick Steinhardt, git, Tian Yuchen
In-Reply-To: <xmqq7bnya7gh.fsf@gitster.g>

On Tue, Jun 16, 2026 at 07:25:02AM -0700, Junio C Hamano wrote:

> >     +@@ t/t7450-bad-git-dotfiles.sh: test_expect_success 'git dirs of sibling submodules must not be nested' '
> >     + test_expect_success 'submodule git dir nesting detection must work with parallel cloning' '
> >     + 	test_must_fail git clone --recurse-submodules --jobs=2 nested clone_parallel 2>err &&
> >     + 	cat err &&
> >     +-	grep -E "(already exists|is inside git dir|not a git repository)" err &&
> >     ++	grep -E "(already exists|is inside git dir|does not point to a valid repository)" err &&
> 
> A few things.
> 
>  * Will we be happy to see only one of these possibilities, or do we
>    expect to see these once for each kind?

I imagine it is only one. This all comes from 9cf8547320 (clone: prevent
clashing git dirs when cloning submodule in parallel, 2024-01-28), and
it is expecting the nested path to cause a failure. Which failure I
guess depends on the racy ordering. If we create the inner one first,
then we probably get "already exists", and if the outer one, then "is
inside git dir". I don't know exactly what sequence yields the
NOT_A_REPO message.

But none of that is changing in this patch, just what the user-visible
text is for the NOT_A_REPO case.

I did briefly wonder if we might see "not a git repository" from a
_different_ code path, and need to catch it along with the new message.
But running successfully with --stress implies that we never see the old
one anymore.

>  * a recently started in-flight topic tries to catch bare "grep" and
>    fails until you write test_grep X-<.

Yeah. This will create a merge conflict for you, but hopefully the
resolution should be obvious. I don't think it makes sense to fix here,
as it's orthogonal to the purpose of the patch.

-Peff

^ permalink raw reply

* Re: [PATCH v3] read_gitfile(): simplify NOT_A_REPO error message
From: Junio C Hamano @ 2026-06-16 14:25 UTC (permalink / raw)
  To: Jeff King; +Cc: Patrick Steinhardt, git, Tian Yuchen
In-Reply-To: <20260616123516.GA2301231@coredump.intra.peff.net>

Jeff King <peff@peff.net> writes:

> On Tue, Jun 16, 2026 at 07:19:20AM -0400, Jeff King wrote:
>
>> Here it is.

Thanks.

>     +@@ t/t7450-bad-git-dotfiles.sh: test_expect_success 'git dirs of sibling submodules must not be nested' '
>     + test_expect_success 'submodule git dir nesting detection must work with parallel cloning' '
>     + 	test_must_fail git clone --recurse-submodules --jobs=2 nested clone_parallel 2>err &&
>     + 	cat err &&
>     +-	grep -E "(already exists|is inside git dir|not a git repository)" err &&
>     ++	grep -E "(already exists|is inside git dir|does not point to a valid repository)" err &&

A few things.

 * Will we be happy to see only one of these possibilities, or do we
   expect to see these once for each kind?

 * a recently started in-flight topic tries to catch bare "grep" and
   fails until you write test_grep X-<.

> We also racily trigger this in t7450. During parallel cloning we might
> see one of several errors, including this one. And so we must update
> that message, too (you can otherwise find the failure pretty quickly by
> running t7450 with --stress).


^ permalink raw reply

* Re: [PATCH] Add a test about broken notes handling on rebase
From: Phillip Wood @ 2026-06-16 13:12 UTC (permalink / raw)
  To: Uwe Kleine-König, git
In-Reply-To: <20260612143952.3281115-2-u.kleine-koenig@baylibre.com>

On 12/06/2026 15:39, Uwe Kleine-König wrote:
> When a commit disappears during rebase because the patch content is
> already there (but not by the same patch in which case the commit would
> be skipped) the notes of that disappearing commit still survives and is
> added to the (rebased) parent of the disappearing commit.
> 
> So with the commit graph
> 
>   A -- B -- C
>    `
>     `-BD
> 
> where BD includes the changes done in B, when rebasing C on top of BD,
> the note for B should disappear and not be added to BD.
> 
> Signed-off-by: Uwe Kleine-König <u.kleine-koenig@baylibre.com>
> ---
> Hello,
> 
> this is a behaviour of git that really bothers me when working on big
> patch series. I use notes to track the Message-Id of the patches when I
> send them out. Then when rebasing to a newer upstream version, the
> tracking gets confused because the Message-Id notes end up on commits
> that were not sent out yet (or I got two Message-Ids in them).
> 
> I reported that already back in 2023[1],

That thread includes a suggestion on how to fix it if anyone reading 
this is interesting in working on it.

> but obviously not in a way that
> resulted in a fix. So I'm trying again with a patch that adds a failing
> test.

I'm not sure carrying this test makes it any more likely that it will be 
fixed, though your mail might get someone interested in fixing it. Don't 
we already have some relevant tests t3400 rather than adding a whole new 
file for a single test?

Thanks

Phillip

> Best regards
> Uwe
> 
> [1] https://lore.kernel.org/git/20230530092155.3zbb5uxa7eisdzxb@pengutronix.de/
> 
>   t/meson.build           |  1 +
>   t/t3322-notes-rebase.sh | 35 +++++++++++++++++++++++++++++++++++
>   2 files changed, 36 insertions(+)
>   create mode 100644 t/t3322-notes-rebase.sh
> 
> diff --git a/t/meson.build b/t/meson.build
> index c5832fee0535..6927bd9c794f 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -358,6 +358,7 @@ integration_tests = [
>     't3311-notes-merge-fanout.sh',
>     't3320-notes-merge-worktrees.sh',
>     't3321-notes-stripspace.sh',
> +  't3322-notes-rebase.sh',
>     't3400-rebase.sh',
>     't3401-rebase-and-am-rename.sh',
>     't3402-rebase-merge.sh',
> diff --git a/t/t3322-notes-rebase.sh b/t/t3322-notes-rebase.sh
> new file mode 100644
> index 000000000000..64c40a523b50
> --- /dev/null
> +++ b/t/t3322-notes-rebase.sh
> @@ -0,0 +1,35 @@
> +#!/bin/sh
> +
> +test_description='Test notes on rebase'
> +
> +. ./test-lib.sh
> +
> +test_expect_success setup '
> +	git init &&
> +	echo A > A &&
> +	git add A &&
> +	git commit -m A &&
> +	git branch branch &&
> +	echo B > B &&
> +	git add B &&
> +	git commit -m B &&
> +	git notes add -m "This is B" @ &&
> +	echo C > C &&
> +	git add C &&
> +	git commit -m C &&
> +	git checkout branch &&
> +	echo B > B &&
> +	echo D > D &&
> +	git add B D &&
> +	git commit -m BD
> +'
> +
> +test_expect_success 'rebase B + C on top of BD' '
> +	git rebase @ master
> +'
> +
> +test_expect_failure 'assert there is no note on BD' '
> +	git notes show branch
> +'
> +
> +test_done
> 
> base-commit: 3e65291872de10c3f0bf05ea8c24187e7a71ebf0


^ permalink raw reply

* Re: [GSoC Patch v5 2/4] rev-parse: use append_formatted_path() for path formatting
From: Phillip Wood @ 2026-06-16 13:08 UTC (permalink / raw)
  To: K Jayatheerth, git
  Cc: jltobler, lucasseikioshiro, gitster, phillip.wood, sandals,
	kumarayushjha123, a3205153416
In-Reply-To: <20260616044953.184806-3-jayatheerthkulkarni2005@gmail.com>

On 16/06/2026 05:49, K Jayatheerth wrote:
> -static void print_path(const char *path, const char *prefix, enum format_type format, enum default_type def)
> +static void print_path(const char *path, const char *prefix,
> +		       enum path_format arg_path_format, enum path_format def_format)
>   {
> -	char *cwd = NULL;
> -	/*
> -	 * We don't ever produce a relative path if prefix is NULL, so set the
> -	 * prefix to the current directory so that we can produce a relative
> -	 * path whenever possible.  If we're using RELATIVE_IF_SHARED mode, then
> -	 * we want an absolute path unless the two share a common prefix, so don't
> -	 * set it in that case, since doing so causes a relative path to always
> -	 * be produced if possible.
> -	 */
> -	if (!prefix && (format != FORMAT_DEFAULT || def != DEFAULT_RELATIVE_IF_SHARED))
> -		prefix = cwd = xgetcwd();
> -	if (format == FORMAT_DEFAULT && def == DEFAULT_UNMODIFIED) {
> -		puts(path);
> -	} else if (format == FORMAT_RELATIVE ||
> -		  (format == FORMAT_DEFAULT && def == DEFAULT_RELATIVE)) {
> -		/*
> -		 * In order for relative_path to work as expected, we need to
> -		 * make sure that both paths are absolute paths.  If we don't,
> -		 * we can end up with an unexpected absolute path that the user
> -		 * didn't want.
> -		 */
> -		struct strbuf buf = STRBUF_INIT, realbuf = STRBUF_INIT, prefixbuf = STRBUF_INIT;
> -		if (!is_absolute_path(path)) {
> -			strbuf_realpath_forgiving(&realbuf, path,  1);
> -			path = realbuf.buf;
> -		}
> -		if (!is_absolute_path(prefix)) {
> -			strbuf_realpath_forgiving(&prefixbuf, prefix, 1);
> -			prefix = prefixbuf.buf;
> -		}
> -		puts(relative_path(path, prefix, &buf));
> -		strbuf_release(&buf);
> -		strbuf_release(&realbuf);
> -		strbuf_release(&prefixbuf);
> -	} else if (format == FORMAT_DEFAULT && def == DEFAULT_RELATIVE_IF_SHARED) {
> -		struct strbuf buf = STRBUF_INIT;
> -		puts(relative_path(path, prefix, &buf));
> -		strbuf_release(&buf);
> -	} else {
> -		struct strbuf buf = STRBUF_INIT;
> -		strbuf_realpath_forgiving(&buf, path, 1);
> -		puts(buf.buf);
> -		strbuf_release(&buf);
> -	}
> -	free(cwd);
> +	struct strbuf sb = STRBUF_INIT;
> +	/* If the user didn't explicitly specify a format, fallback to the path-specific default. */
> +	enum path_format fmt = (arg_path_format != PATH_FORMAT_DEFAULT) ? arg_path_format : def_format;
> +
> +	append_formatted_path(&sb, path, prefix, fmt);
> +	puts(sb.buf);
> +
> +	strbuf_release(&sb);
>   }
>   
>   int cmd_rev_parse(int argc,
> @@ -717,7 +661,7 @@ int cmd_rev_parse(int argc,
>   	const char *name = NULL;
>   	struct strbuf buf = STRBUF_INIT;
>   	int seen_end_of_options = 0;
> -	enum format_type format = FORMAT_DEFAULT;
> +	enum path_format arg_path_format = PATH_FORMAT_DEFAULT;

This is the source of the api wart I referred to in the previous patch. 
Could we keep the existing enums and convert them into the appropriate 
PATH_FORMAT_* flag in print_path() above? I think we already have the 
logic to do that in the existing code. That would mean that other users 
of append_formatted_path() don't have to worry about the extra flag.

Thanks

Phillip

>   
>   	show_usage_if_asked(argc, argv, builtin_rev_parse_usage);
>   
> @@ -797,8 +741,8 @@ int cmd_rev_parse(int argc,
>   					die(_("--git-path requires an argument"));
>   				print_path(repo_git_path_replace(the_repository, &buf,
>   								 "%s", argv[i + 1]), prefix,
> -						format,
> -						DEFAULT_RELATIVE_IF_SHARED);
> +						arg_path_format,
> +						PATH_FORMAT_RELATIVE_IF_SHARED);
>   				i++;
>   				continue;
>   			}
> @@ -820,9 +764,9 @@ int cmd_rev_parse(int argc,
>   				if (!arg)
>   					die(_("--path-format requires an argument"));
>   				if (!strcmp(arg, "absolute")) {
> -					format = FORMAT_CANONICAL;
> +					arg_path_format = PATH_FORMAT_CANONICAL;
>   				} else if (!strcmp(arg, "relative")) {
> -					format = FORMAT_RELATIVE;
> +					arg_path_format = PATH_FORMAT_RELATIVE;
>   				} else {
>   					die(_("unknown argument to --path-format: %s"), arg);
>   				}
> @@ -985,7 +929,7 @@ int cmd_rev_parse(int argc,
>   			if (!strcmp(arg, "--show-toplevel")) {
>   				const char *work_tree = repo_get_work_tree(the_repository);
>   				if (work_tree)
> -					print_path(work_tree, prefix, format, DEFAULT_UNMODIFIED);
> +					print_path(work_tree, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
>   				else
>   					die(_("this operation must be run in a work tree"));
>   				continue;
> @@ -993,7 +937,7 @@ int cmd_rev_parse(int argc,
>   			if (!strcmp(arg, "--show-superproject-working-tree")) {
>   				struct strbuf superproject = STRBUF_INIT;
>   				if (get_superproject_working_tree(&superproject))
> -					print_path(superproject.buf, prefix, format, DEFAULT_UNMODIFIED);
> +					print_path(superproject.buf, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
>   				strbuf_release(&superproject);
>   				continue;
>   			}
> @@ -1028,18 +972,18 @@ int cmd_rev_parse(int argc,
>   				const char *gitdir = getenv(GIT_DIR_ENVIRONMENT);
>   				char *cwd;
>   				int len;
> -				enum format_type wanted = format;
> +				enum path_format wanted = arg_path_format;
>   				if (arg[2] == 'g') {	/* --git-dir */
>   					if (gitdir) {
> -						print_path(gitdir, prefix, format, DEFAULT_UNMODIFIED);
> +						print_path(gitdir, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
>   						continue;
>   					}
>   					if (!prefix) {
> -						print_path(".git", prefix, format, DEFAULT_UNMODIFIED);
> +						print_path(".git", prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
>   						continue;
>   					}
>   				} else {		/* --absolute-git-dir */
> -					wanted = FORMAT_CANONICAL;
> +					wanted = PATH_FORMAT_CANONICAL;
>   					if (!gitdir && !prefix)
>   						gitdir = ".git";
>   					if (gitdir) {
> @@ -1055,11 +999,11 @@ int cmd_rev_parse(int argc,
>   				strbuf_reset(&buf);
>   				strbuf_addf(&buf, "%s%s.git", cwd, len && cwd[len-1] != '/' ? "/" : "");
>   				free(cwd);
> -				print_path(buf.buf, prefix, wanted, DEFAULT_CANONICAL);
> +				print_path(buf.buf, prefix, wanted, PATH_FORMAT_CANONICAL);
>   				continue;
>   			}
>   			if (!strcmp(arg, "--git-common-dir")) {
> -				print_path(repo_get_common_dir(the_repository), prefix, format, DEFAULT_RELATIVE_IF_SHARED);
> +				print_path(repo_get_common_dir(the_repository), prefix, arg_path_format, PATH_FORMAT_RELATIVE_IF_SHARED);
>   				continue;
>   			}
>   			if (!strcmp(arg, "--is-inside-git-dir")) {
> @@ -1089,7 +1033,7 @@ int cmd_rev_parse(int argc,
>   				if (the_repository->index->split_index) {
>   					const struct object_id *oid = &the_repository->index->split_index->base_oid;
>   					const char *path = repo_git_path_replace(the_repository, &buf, "sharedindex.%s", oid_to_hex(oid));
> -					print_path(path, prefix, format, DEFAULT_RELATIVE);
> +					print_path(path, prefix, arg_path_format, PATH_FORMAT_RELATIVE);
>   				}
>   				continue;
>   			}


^ permalink raw reply

* Re: [GSoC Patch v5 1/4] path: introduce append_formatted_path() for shared path formatting
From: Phillip Wood @ 2026-06-16 13:08 UTC (permalink / raw)
  To: K Jayatheerth, git
  Cc: jltobler, lucasseikioshiro, gitster, phillip.wood, sandals,
	kumarayushjha123, a3205153416
In-Reply-To: <20260616044953.184806-2-jayatheerthkulkarni2005@gmail.com>

On 16/06/2026 05:49, K Jayatheerth wrote:
> The path-formatting logic in builtin/rev-parse.c is tightly coupled
> to that command and writes directly to stdout, making it impossible
> for other builtins to reuse.
> 
> Extract the core algorithm into append_formatted_path() in path.c
> and expose a path_format enum in path.h so that any builtin can
> format paths consistently without duplicating logic.

Sorry I haven't had time to look at this series recently, it is looking 
much nicer now that we have a single enum. It would be helpful to 
explain why we need PATH_FORMAT_DEFAULT that acts exactly like 
PATH_FORMAT_UNMODIFIED. Looking at the next patch it seems this is still 
a wart in the api due to rev-parse wanting needing to distinguish the 
unmodified case from the default case.

Thanks

Phillip

> Mentored-by: Justin Tobler <jltobler@gmail.com>
> Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
> Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
> ---
>   path.c | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
>   path.h | 36 ++++++++++++++++++++++++++++++
>   2 files changed, 106 insertions(+)
> 
> diff --git a/path.c b/path.c
> index d7e17bf174..5e83e3e4f6 100644
> --- a/path.c
> +++ b/path.c
> @@ -1579,6 +1579,76 @@ char *xdg_cache_home(const char *filename)
>   	return NULL;
>   }
>   
> +void append_formatted_path(struct strbuf *dest, const char *path,
> +			   const char *prefix, enum path_format format)
> +{
> +	switch (format) {
> +	case PATH_FORMAT_DEFAULT:
> +	case PATH_FORMAT_UNMODIFIED:
> +		strbuf_addstr(dest, path);
> +		break;
> +
> +	case PATH_FORMAT_RELATIVE: {
> +		struct strbuf relative_buf = STRBUF_INIT;
> +		struct strbuf real_path = STRBUF_INIT;
> +		struct strbuf real_prefix = STRBUF_INIT;
> +		char *cwd = NULL;
> +
> +		/*
> +		 * We don't ever produce a relative path if prefix is NULL,
> +		 * so set the prefix to the current directory so that we can
> +		 * produce a relative path whenever possible.
> +		 */
> +		if (!prefix)
> +			prefix = cwd = xgetcwd();
> +
> +		if (!is_absolute_path(path)) {
> +			strbuf_realpath_forgiving(&real_path, path, 1);
> +			path = real_path.buf;
> +		}
> +		if (!is_absolute_path(prefix)) {
> +			strbuf_realpath_forgiving(&real_prefix, prefix, 1);
> +			prefix = real_prefix.buf;
> +		}
> +
> +		strbuf_addstr(dest, relative_path(path, prefix, &relative_buf));
> +
> +		strbuf_release(&relative_buf);
> +		strbuf_release(&real_path);
> +		strbuf_release(&real_prefix);
> +		free(cwd);
> +		break;
> +	}
> +
> +	case PATH_FORMAT_RELATIVE_IF_SHARED: {
> +		struct strbuf relative_buf = STRBUF_INIT;
> +
> +		/*
> +		 * If we're using RELATIVE_IF_SHARED mode, then we want an
> +		 * absolute path unless the two share a common prefix, so don't
> +		 * default the prefix to the current working directory. Doing so
> +		 * would cause a relative path to always be produced if possible.
> +		 */
> +		strbuf_addstr(dest, relative_path(path, prefix, &relative_buf));
> +		strbuf_release(&relative_buf);
> +		break;
> +	}
> +
> +	case PATH_FORMAT_CANONICAL: {
> +		struct strbuf canonical_buf = STRBUF_INIT;
> +
> +		strbuf_realpath_forgiving(&canonical_buf, path, 1);
> +		strbuf_addbuf(dest, &canonical_buf);
> +
> +		strbuf_release(&canonical_buf);
> +		break;
> +	}
> +
> +	default:
> +		BUG("unknown path_format value %d", format);
> +	}
> +}
> +
>   REPO_GIT_PATH_FUNC(squash_msg, "SQUASH_MSG")
>   REPO_GIT_PATH_FUNC(merge_msg, "MERGE_MSG")
>   REPO_GIT_PATH_FUNC(merge_rr, "MERGE_RR")
> diff --git a/path.h b/path.h
> index 0434ba5e07..6aca53b100 100644
> --- a/path.h
> +++ b/path.h
> @@ -262,6 +262,42 @@ enum scld_error safe_create_leading_directories_no_share(char *path);
>   int safe_create_file_with_leading_directories(struct repository *repo,
>   					      const char *path);
>   
> +/**
> + * The formatting strategy to apply when writing a path into a buffer.
> + */
> +enum path_format {
> +	/*
> +	 * Represents the default formatting behavior. Treated as
> +	 * PATH_FORMAT_UNMODIFIED by append_formatted_path().
> +	 */
> +	PATH_FORMAT_DEFAULT,
> +
> +	/* Output the path exactly as-is without any modifications. */
> +	PATH_FORMAT_UNMODIFIED,
> +
> +	/* Output a path relative to the provided directory prefix. */
> +	PATH_FORMAT_RELATIVE,
> +
> +	/* Output a relative path only if the path shares a root with the prefix. */
> +	PATH_FORMAT_RELATIVE_IF_SHARED,
> +
> +	/* Output a fully resolved, absolute canonical path. */
> +	PATH_FORMAT_CANONICAL
> +};
> +
> +/**
> + * Format a path according to the specified formatting strategy and append
> + * the result to the given strbuf.
> + *
> + * `dest`   : The string buffer to append the formatted path to.
> + * `path`   : The path string that needs to be formatted.
> + * `prefix` : The directory prefix to calculate relative offsets against.
> + * Pass NULL to default to the current working directory where applicable.
> + * `format` : The formatting behavior rule to execute.
> + */
> +void append_formatted_path(struct strbuf *dest, const char *path,
> +			   const char *prefix, enum path_format format);
> +
>   # ifdef USE_THE_REPOSITORY_VARIABLE
>   #  include "strbuf.h"
>   #  include "repository.h"


^ permalink raw reply

* Re: [PATCH v5 2/2] graph: indent visual root in graph
From: Pablo Sabater @ 2026-06-16 13:06 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Taylor Blau, git, ayu.chandekar, chandrapratap3519,
	christian.couder, jltobler, karthik.188, peff, phillip.wood,
	siddharthasthana31
In-Reply-To: <xmqqzf0vbyj8.fsf@gitster.g>

El lun, 15 jun 2026 a las 17:42, Junio C Hamano (<gitster@pobox.com>) escribió:
>
> Pablo Sabater <pabloosabaterr@gmail.com> writes:
>
> > It does not make it unpredictable but it makes it not output what I
> > wanted to test, what I wanted to test is having an active column at
> > the same time that visual roots in different cases were being rendered
> > on another column.
>
> Oh, use of commit-graph changes the traversal order, which would
> affect how the graph is drawn, and there is no way to ensure that we
> traverse in the same way with or without commit-graph?  That's
> inconvenient.  But even without commit-graph, do we guarantee the
> same traversal order forever?  I doubt it.  So I suspect that it is
> a brittle workaround to disable commit-graph in the longer term.

Hi!

About the traversal order, aren't all the graph tests dependent on the
traversal order?  If it changed they would all need to be updated
because the tests are hardcoded expects of the graph.
I guess it might be more brittle than other graph tests specially
because it also depends on removing files, I tried using "git config
core.commitGraph false" or "--date-order" but I still get different
results and removing the files fixed it. If someone knows a better way
of doing it I'm happy to change it.

>
> As long as the graph engine shows correct graph no matter what order
> the commits come out of the revision traversal engine, we won't hurt
> end-users, but we need our tests to be reproducible, so that is a
> bit unfortunate.
>
> Anyway, stepping back a bit,
>
> > However having GIT_TEST_COMMIT_GRAPH in the last
> > text for example changes from:
> >
> > * 41_octopus
> > | * 43_B
> > |  \
> > |   * 43_A
> > | * 42_B
> > | * 42_A
> > * 41_B
> > * 41_A
>
> Does the "vertically aligned * on 2nd and later columns do not mean
> any parent-child relationship" rule no longer apply in this version?
> IOW, does the above graph show that
>
>  - 41_A is a parent of 41_B, which is a parent of 41_octopus
>  - 42_A is a parent of 42_B, and
>  - 43_A is a parent of 43_B but is not related to 42_B

Yes, this means that all commits vertically adjacent are related,
those who are not related and can cause that ambiguity get indented
(43_A).

>
> ?  Who are the parents of 41_octopus?  It has no relationship with
> 42_B and 43_B, and unlike what its name suggests, it has only 41_b
> as its parent (probably with history simplification that makes only
> these commits shown)?

On this test we are using "--first-parent" which excludes all the
parents but the first one, but later we force its excluded parents to
be shown.
We exclude 42_* and 43_* branches and then force them to appear as
unrelated branches.

>
> > to:
> >
> > * 41_octopus
> > * 41_B
> >  \
> >   * 41_A
> > * 43_B
> >  \
> >   * 43_A
> > * 42_B
> > * 42_A
>
> And this graph shows the same inter-commit relationship.  So both
> are correctly showing what we want to express, but they show the
> same information differently, making test_cmp unhappy?

Yes they show the same information.  On the second graph every commit
is on the first column (or second if they get indented) but on the
first graph we have:

*
| * <- visual root on second column
^
`----- first column remains active

If you tested v3 with this case you would see that it assumes that
visual roots only happen to be rendered on the first column, therefore
failing to correctly indent those visual roots on the second column,
which this test proves that they can appear on other columns.

Back to the test:

  * 41_octopus
  | * 43_B
  |  \
  |   * 43_A
  | * 42_B
  | * 42_A
  * 41_B
  * 41_A

43_A is rendered on the second column (first column is active by the
41_* branch) and gets indented to the third one. With commit-graph it
would be on the first and get indented to the second, making it the
same as more general tests above in "t4218", it is an edge case but
shows that indentation works correctly independently where the visual
root is.

>
> Thanks.

Thanks,

Pablo

^ permalink raw reply

* [PATCH v3] read_gitfile(): simplify NOT_A_REPO error message
From: Jeff King @ 2026-06-16 12:35 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: Patrick Steinhardt, git, Tian Yuchen
In-Reply-To: <20260616111919.GC687438@coredump.intra.peff.net>

On Tue, Jun 16, 2026 at 07:19:20AM -0400, Jeff King wrote:

> Here it is.
> 
> -- >8 --
> Subject: read_gitfile(): simplify NOT_A_REPO error message

<sigh> This triggered a failure in CI after passing tests locally.
Turned out to be a race in t7450.

Here's an update, with range-diff.

1:  daf7f99511 ! 1:  67d42141e9 read_gitfile(): simplify NOT_A_REPO error message
    @@ Commit message
         We'll tweak the test to match the new error, but there's no need to beef
         it up further, since we're not showing the pointed-to path at all.
     
    +    We also racily trigger this in t7450. During parallel cloning we might
    +    see one of several errors, including this one. And so we must update
    +    that message, too (you can otherwise find the failure pretty quickly by
    +    running t7450 with --stress).
    +
         Signed-off-by: Jeff King <peff@peff.net>
     
      ## setup.c ##
    @@ t/t0002-gitfile.sh: test_expect_success 'bad setup: invalid .git file format' '
      '
      
      test_expect_success 'final setup + check rev-parse --git-dir' '
    +
    + ## t/t7450-bad-git-dotfiles.sh ##
    +@@ t/t7450-bad-git-dotfiles.sh: test_expect_success 'git dirs of sibling submodules must not be nested' '
    + test_expect_success 'submodule git dir nesting detection must work with parallel cloning' '
    + 	test_must_fail git clone --recurse-submodules --jobs=2 nested clone_parallel 2>err &&
    + 	cat err &&
    +-	grep -E "(already exists|is inside git dir|not a git repository)" err &&
    ++	grep -E "(already exists|is inside git dir|does not point to a valid repository)" err &&
    + 	{
    + 		test_path_is_missing .git/modules/hippo/HEAD ||
    + 		test_path_is_missing .git/modules/hippo/hooks/HEAD

-- >8 --
Subject: [PATCH] read_gitfile(): simplify NOT_A_REPO error message

If a .git file is well-formed but points to a directory that is not
itself a valid repository, then we say:

  fatal: not a git repository: <pointed-to-repo>

without mentioning the .git file that pointed us there in the first
place. Doing so could better help the user understand the source of the
problem.

In theory the most helpful thing we could do is mention both paths,
like:

  gitfile '<gitfile>' points to invalid repository: <pointed-to-repo>

But there's another catch: when we generate the error, we don't always
know the pointed-to repository! This leads to a potential segfault.

The message comes from read_gitfile_error_die(). Originally we only
called that function from inside read_gitfile_gently(), passing in both
the gitfile path and the pointed-to path. But that changed in 1dd27bfbfd
(setup: improve error diagnosis for invalid .git files, 2026-03-04).
Since then, the caller in setup_git_directory_gently(), even if it wants
to die on error, always passes in the "return_error_code" flag, asking
the function to instead return a numeric error code. And then it calls
read_gitfile_error_die() itself, passing NULL for the pointed-to path.

If we get the READ_GITFILE_ERR_NOT_A_REPO code, we form a message using
that NULL pointer, and either segfault or get garbage like "not a git
repository: (null)", depending on the platform.

We could fix this by having the function pass out both the numeric error
code and the pointed-to path. But that creates a new headache: we have
to allocate that string on the heap and pass ownership back to the
caller. So now every caller has to be aware of it (and either free the
result, or signal that they are not interested by using an extra
parameter).

Instead, let's just drop the pointed-to path from the error message
entirely, and mention only the gitfile. This fixes the NULL dereference
without introducing any more complexity. The user-facing error message
is not as detailed as it could be, but is better than the original.
Since it mentions the gitfile, a user investigating the situation can
look there to find the pointed-to path (whereas you could not go the
other way from the original message).

There's an existing test in t0002 which triggers this case, but we
didn't notice the problem because it checks only that we said "not a
repository", and not the full string. So if we print "(null)" it is
happy. It will probably crash on some non-glibc platforms, but nobody
seems to have reported it yet (the breakage is recent-ish as of v2.54).
I'm also somewhat surprised that building with ASan/UBSan doesn't catch
this, but it doesn't seem to (and I found an open issue with somebody
asking for NULL printf checks to be implemented in the sanitizers).

We'll tweak the test to match the new error, but there's no need to beef
it up further, since we're not showing the pointed-to path at all.

We also racily trigger this in t7450. During parallel cloning we might
see one of several errors, including this one. And so we must update
that message, too (you can otherwise find the failure pretty quickly by
running t7450 with --stress).

Signed-off-by: Jeff King <peff@peff.net>
---
 setup.c                     | 9 +++++----
 setup.h                     | 2 +-
 submodule.c                 | 2 +-
 t/t0002-gitfile.sh          | 2 +-
 t/t7450-bad-git-dotfiles.sh | 2 +-
 5 files changed, 9 insertions(+), 8 deletions(-)

diff --git a/setup.c b/setup.c
index b4652651df..b1d9249d91 100644
--- a/setup.c
+++ b/setup.c
@@ -917,7 +917,7 @@ int verify_repository_format(const struct repository_format *format,
 	return 0;
 }
 
-void read_gitfile_error_die(int error_code, const char *path, const char *dir)
+void read_gitfile_error_die(int error_code, const char *path)
 {
 	switch (error_code) {
 	case READ_GITFILE_ERR_NOT_A_FILE:
@@ -937,7 +937,8 @@ void read_gitfile_error_die(int error_code, const char *path, const char *dir)
 	case READ_GITFILE_ERR_NO_PATH:
 		die(_("no path in gitfile: %s"), path);
 	case READ_GITFILE_ERR_NOT_A_REPO:
-		die(_("not a git repository: %s"), dir);
+		die(_("gitfile does not point to a valid repository: %s"),
+		    path);
 	default:
 		BUG("unknown error code");
 	}
@@ -1028,7 +1029,7 @@ const char *read_gitfile_gently(const char *path, int *return_error_code)
 	if (return_error_code)
 		*return_error_code = error_code;
 	else if (error_code)
-		read_gitfile_error_die(error_code, path, dir);
+		read_gitfile_error_die(error_code, path);
 
 	free(buf);
 	return error_code ? NULL : path;
@@ -1629,7 +1630,7 @@ static enum discovery_result setup_git_directory_gently_1(struct strbuf *dir,
 					return GIT_DIR_INVALID_GITFILE;
 			default:
 				if (die_on_error)
-					read_gitfile_error_die(error_code, dir->buf, NULL);
+					read_gitfile_error_die(error_code, dir->buf);
 				else
 					return GIT_DIR_INVALID_GITFILE;
 			}
diff --git a/setup.h b/setup.h
index 705d1d6ff7..df8c93687a 100644
--- a/setup.h
+++ b/setup.h
@@ -38,7 +38,7 @@ int is_nonbare_repository_dir(struct strbuf *path);
 #define READ_GITFILE_ERR_TOO_LARGE 8
 #define READ_GITFILE_ERR_MISSING 9
 #define READ_GITFILE_ERR_IS_A_DIR 10
-void read_gitfile_error_die(int error_code, const char *path, const char *dir);
+void read_gitfile_error_die(int error_code, const char *path);
 const char *read_gitfile_gently(const char *path, int *return_error_code);
 #define read_gitfile(path) read_gitfile_gently((path), NULL)
 const char *resolve_gitdir_gently(const char *suspect, int *return_error_code);
diff --git a/submodule.c b/submodule.c
index fd91201a92..93d0361072 100644
--- a/submodule.c
+++ b/submodule.c
@@ -2579,7 +2579,7 @@ void absorb_git_dir_into_superproject(const char *path,
 
 		if (err_code != READ_GITFILE_ERR_NOT_A_REPO)
 			/* We don't know what broke here. */
-			read_gitfile_error_die(err_code, path, NULL);
+			read_gitfile_error_die(err_code, path);
 
 		/*
 		* Maybe populated, but no git directory was found?
diff --git a/t/t0002-gitfile.sh b/t/t0002-gitfile.sh
index dfbcdddbcc..6356e9ec72 100755
--- a/t/t0002-gitfile.sh
+++ b/t/t0002-gitfile.sh
@@ -27,7 +27,7 @@ test_expect_success 'bad setup: invalid .git file format' '
 test_expect_success 'bad setup: invalid .git file path' '
 	echo "gitdir: $REAL.not" >.git &&
 	test_must_fail git rev-parse 2>.err &&
-	test_grep "not a git repository" .err
+	test_grep "gitfile does not point to a valid repository" .err
 '
 
 test_expect_success 'final setup + check rev-parse --git-dir' '
diff --git a/t/t7450-bad-git-dotfiles.sh b/t/t7450-bad-git-dotfiles.sh
index 8cc86522b2..69a17a9d13 100755
--- a/t/t7450-bad-git-dotfiles.sh
+++ b/t/t7450-bad-git-dotfiles.sh
@@ -350,7 +350,7 @@ test_expect_success 'git dirs of sibling submodules must not be nested' '
 test_expect_success 'submodule git dir nesting detection must work with parallel cloning' '
 	test_must_fail git clone --recurse-submodules --jobs=2 nested clone_parallel 2>err &&
 	cat err &&
-	grep -E "(already exists|is inside git dir|not a git repository)" err &&
+	grep -E "(already exists|is inside git dir|does not point to a valid repository)" err &&
 	{
 		test_path_is_missing .git/modules/hippo/HEAD ||
 		test_path_is_missing .git/modules/hippo/hooks/HEAD
-- 
2.55.0.rc0.346.g4c7eff6ddc


^ permalink raw reply related

* [PATCH v2] read_gitfile(): simplify NOT_A_REPO error message
From: Jeff King @ 2026-06-16 11:19 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: Patrick Steinhardt, git, Tian Yuchen
In-Reply-To: <xmqqeciezh0w.fsf@gitster.g>

On Wed, Jun 10, 2026 at 06:01:03AM -0700, Junio C Hamano wrote:

> > I have to agree that the patch is somewhat gross, and I myself don't
> > really see much of an issue to move to an error message like the above
> > if it ends up simplifying the logic.
> 
> So we are in agreement among three of us that simplifying the code
> to lose error message with dubious value would be a good way
> forward.
> 
> Peff, can we have a formal [v2] then?

Here it is.

-- >8 --
Subject: read_gitfile(): simplify NOT_A_REPO error message

If a .git file is well-formed but points to a directory that is not
itself a valid repository, then we say:

  fatal: not a git repository: <pointed-to-repo>

without mentioning the .git file that pointed us there in the first
place. Doing so could better help the user understand the source of the
problem.

In theory the most helpful thing we could do is mention both paths,
like:

  gitfile '<gitfile>' points to invalid repository: <pointed-to-repo>

But there's another catch: when we generate the error, we don't always
know the pointed-to repository! This leads to a potential segfault.

The message comes from read_gitfile_error_die(). Originally we only
called that function from inside read_gitfile_gently(), passing in both
the gitfile path and the pointed-to path. But that changed in 1dd27bfbfd
(setup: improve error diagnosis for invalid .git files, 2026-03-04).
Since then, the caller in setup_git_directory_gently(), even if it wants
to die on error, always passes in the "return_error_code" flag, asking
the function to instead return a numeric error code. And then it calls
read_gitfile_error_die() itself, passing NULL for the pointed-to path.

If we get the READ_GITFILE_ERR_NOT_A_REPO code, we form a message using
that NULL pointer, and either segfault or get garbage like "not a git
repository: (null)", depending on the platform.

We could fix this by having the function pass out both the numeric error
code and the pointed-to path. But that creates a new headache: we have
to allocate that string on the heap and pass ownership back to the
caller. So now every caller has to be aware of it (and either free the
result, or signal that they are not interested by using an extra
parameter).

Instead, let's just drop the pointed-to path from the error message
entirely, and mention only the gitfile. This fixes the NULL dereference
without introducing any more complexity. The user-facing error message
is not as detailed as it could be, but is better than the original.
Since it mentions the gitfile, a user investigating the situation can
look there to find the pointed-to path (whereas you could not go the
other way from the original message).

There's an existing test in t0002 which triggers this case, but we
didn't notice the problem because it checks only that we said "not a
repository", and not the full string. So if we print "(null)" it is
happy. It will probably crash on some non-glibc platforms, but nobody
seems to have reported it yet (the breakage is recent-ish as of v2.54).
I'm also somewhat surprised that building with ASan/UBSan doesn't catch
this, but it doesn't seem to (and I found an open issue with somebody
asking for NULL printf checks to be implemented in the sanitizers).

We'll tweak the test to match the new error, but there's no need to beef
it up further, since we're not showing the pointed-to path at all.

Signed-off-by: Jeff King <peff@peff.net>
---
 setup.c            | 9 +++++----
 setup.h            | 2 +-
 submodule.c        | 2 +-
 t/t0002-gitfile.sh | 2 +-
 4 files changed, 8 insertions(+), 7 deletions(-)

diff --git a/setup.c b/setup.c
index b4652651df..b1d9249d91 100644
--- a/setup.c
+++ b/setup.c
@@ -917,7 +917,7 @@ int verify_repository_format(const struct repository_format *format,
 	return 0;
 }
 
-void read_gitfile_error_die(int error_code, const char *path, const char *dir)
+void read_gitfile_error_die(int error_code, const char *path)
 {
 	switch (error_code) {
 	case READ_GITFILE_ERR_NOT_A_FILE:
@@ -937,7 +937,8 @@ void read_gitfile_error_die(int error_code, const char *path, const char *dir)
 	case READ_GITFILE_ERR_NO_PATH:
 		die(_("no path in gitfile: %s"), path);
 	case READ_GITFILE_ERR_NOT_A_REPO:
-		die(_("not a git repository: %s"), dir);
+		die(_("gitfile does not point to a valid repository: %s"),
+		    path);
 	default:
 		BUG("unknown error code");
 	}
@@ -1028,7 +1029,7 @@ const char *read_gitfile_gently(const char *path, int *return_error_code)
 	if (return_error_code)
 		*return_error_code = error_code;
 	else if (error_code)
-		read_gitfile_error_die(error_code, path, dir);
+		read_gitfile_error_die(error_code, path);
 
 	free(buf);
 	return error_code ? NULL : path;
@@ -1629,7 +1630,7 @@ static enum discovery_result setup_git_directory_gently_1(struct strbuf *dir,
 					return GIT_DIR_INVALID_GITFILE;
 			default:
 				if (die_on_error)
-					read_gitfile_error_die(error_code, dir->buf, NULL);
+					read_gitfile_error_die(error_code, dir->buf);
 				else
 					return GIT_DIR_INVALID_GITFILE;
 			}
diff --git a/setup.h b/setup.h
index 705d1d6ff7..df8c93687a 100644
--- a/setup.h
+++ b/setup.h
@@ -38,7 +38,7 @@ int is_nonbare_repository_dir(struct strbuf *path);
 #define READ_GITFILE_ERR_TOO_LARGE 8
 #define READ_GITFILE_ERR_MISSING 9
 #define READ_GITFILE_ERR_IS_A_DIR 10
-void read_gitfile_error_die(int error_code, const char *path, const char *dir);
+void read_gitfile_error_die(int error_code, const char *path);
 const char *read_gitfile_gently(const char *path, int *return_error_code);
 #define read_gitfile(path) read_gitfile_gently((path), NULL)
 const char *resolve_gitdir_gently(const char *suspect, int *return_error_code);
diff --git a/submodule.c b/submodule.c
index fd91201a92..93d0361072 100644
--- a/submodule.c
+++ b/submodule.c
@@ -2579,7 +2579,7 @@ void absorb_git_dir_into_superproject(const char *path,
 
 		if (err_code != READ_GITFILE_ERR_NOT_A_REPO)
 			/* We don't know what broke here. */
-			read_gitfile_error_die(err_code, path, NULL);
+			read_gitfile_error_die(err_code, path);
 
 		/*
 		* Maybe populated, but no git directory was found?
diff --git a/t/t0002-gitfile.sh b/t/t0002-gitfile.sh
index dfbcdddbcc..6356e9ec72 100755
--- a/t/t0002-gitfile.sh
+++ b/t/t0002-gitfile.sh
@@ -27,7 +27,7 @@ test_expect_success 'bad setup: invalid .git file format' '
 test_expect_success 'bad setup: invalid .git file path' '
 	echo "gitdir: $REAL.not" >.git &&
 	test_must_fail git rev-parse 2>.err &&
-	test_grep "not a git repository" .err
+	test_grep "gitfile does not point to a valid repository" .err
 '
 
 test_expect_success 'final setup + check rev-parse --git-dir' '
-- 
2.55.0.rc0.342.g45e27e83e2


^ permalink raw reply related

* Re: [PATCH 3/4] builtin/refs: add "update" subcommand
From: Junio C Hamano @ 2026-06-16 11:17 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git
In-Reply-To: <20260616-pks-refs-writing-subcommands-v1-3-9f5219b6109d@pks.im>

Patrick Steinhardt <ps@pks.im> writes:

> Add a new "update" subcommand which mirrors `git update-ref <refname>
> <oldoid> <newoid>`. This follows the same reasoning as the preceding
> commit.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-refs.adoc |   7 ++
>  builtin/refs.c              |  50 +++++++++++++
>  t/meson.build               |   1 +
>  t/t1465-refs-update.sh      | 179 ++++++++++++++++++++++++++++++++++++++++++++
>  4 files changed, 237 insertions(+)

I do not offhand know (and I am still away by 2 hours from the time
I wake up and start functioning) if update-ref shares the same
issue, but with "delete, update, rename" combo, lack of "create"
feels a bit annoying.  Wouldn't we want to offer an option to users
who want to ensure that the refs they create are truly new and they
are not overwriting a ref somebody has created?  Either (1) drop
"delete" and take a special value (e.g. "") as <newvalue> to signal
deletion and make the same special value used as <oldvalue> signals
creation, or (2) add "create" and insist that "update" takes only an
existing ref, would make the annoyance go away, I guess.

> +test_expect_success 'update creates a new reference' '
> +	test_when_finished "rm -rf repo" &&
> +	setup_repo repo &&
> +	(
> +		cd repo &&
> +		A=$(git rev-parse A) &&
> +		git refs update refs/heads/foo $A &&
> +		test_ref_matches refs/heads/foo "$A"
> +	)
> +'

Here we cannot test (and I strongly suspect that "git refs update"
and "git update-ref" lack ability to do so) a case where a creation
is attempted on an existing ref and fails.

^ permalink raw reply

* Re: [PATCH] cat-file: speed up default format
From: Jeff King @ 2026-06-16 11:15 UTC (permalink / raw)
  To: René Scharfe; +Cc: Git List
In-Reply-To: <df933ffa-1be2-4401-a4ac-9d72c9c4cdcc@web.de>

On Mon, Jun 15, 2026 at 11:53:10PM +0200, René Scharfe wrote:

> >  	} else if (is_atom("rest", atom, len)) {
> > -		if (data->mark_query)
> > -			data->split_on_whitespace = 1;
> > -		else if (data->rest)
> 
> This removes support for rest being NULL, breaking t1006.381.

Yup. I did say "only lightly tested". ;)

The fix is obviously just:

diff --git a/builtin/cat-file.c b/builtin/cat-file.c
index 9cc7ec7a6f..370ca6d771 100644
--- a/builtin/cat-file.c
+++ b/builtin/cat-file.c
@@ -363,7 +363,8 @@ static void objectsize_disk_add(struct format_item *item UNUSED,
 static void rest_add(struct format_item *item UNUSED,
 		     struct strbuf *sb, struct expand_data *data)
 {
-	strbuf_addstr(sb, data->rest);
+	if (data->rest)
+		strbuf_addstr(sb, data->rest);
 }
 
 static void deltabase_add(struct format_item *item UNUSED,

I think perhaps this error shows that the mark_query thing in the
existing code obfuscates the logic a bit.

-Peff

^ permalink raw reply related

* Re: [PATCH] cat-file: speed up default format
From: Jeff King @ 2026-06-16 11:12 UTC (permalink / raw)
  To: René Scharfe; +Cc: Git List
In-Reply-To: <10a33614-837f-4166-aa30-6de28b052692@web.de>

On Mon, Jun 15, 2026 at 11:53:07PM +0200, René Scharfe wrote:

> > IMHO that is probably not worth it for a custom parsing system just for
> > cat-file.  But if we were to finally unify ref-filter and cat-file (and
> > even --pretty=format) then it would probably worth doing this kind of
> > pre-parsing.
> It could be worth it for cat-file alone if we find the right balance, as
> it already does do a separate parsing step, but that is awkward with its
> mark_query checks all over the place and remembers only object property
> requirements and no other format string details.

Yeah, getting rid of the mark_query pass was a nice side effect of
having a true parse step.

> Making the opcodes small should be beneficial.  We need only a handful
> of them, so a byte each should suffice.  We can use a strbuf for that.
> 
> We can also store literal characters in there.  An opcode plus with a
> payload char incurs an overhead of 50%, which sounds high, but at least
> the default format only has two of them and it's much better than
> storing pointer plus size for an overhead of more than 90% in case of a
> single char.

True, and it's a size win if the literal portions tend to be small
(fewer than 15 bytes). You do lose out on the ability to strbuf_add()
them in one go, though. So lots more strbuf_grow() checks, etc. If you
really wanted to get fancy, you could follow the opcode with a length
represented as a variable-sized integer, followed by the literal bytes.

I'm not sure that Git's formatting code needs to squeeze out quite that
much performance, though.

> That gets us closer to native speed, at least on an Apple M1:
> 
> Benchmark 1: ./git_fp cat-file --batch-all-objects --batch-check='%(objectname)-%(objecttype)-%(objectsize)'
>   Time (mean ± σ):     992.7 ms ±   3.2 ms    [User: 967.5 ms, System: 23.8 ms]
>   Range (min … max):   990.1 ms … 1000.7 ms    10 runs
> 
> Benchmark 2: ./git_switch cat-file --batch-all-objects --batch-check='%(objectname)-%(objecttype)-%(objectsize)'
>   Time (mean ± σ):     991.8 ms ±   1.6 ms    [User: 967.0 ms, System: 23.3 ms]
>   Range (min … max):   989.3 ms … 994.4 ms    10 runs
> 
> Benchmark 3: ./git cat-file --batch-all-objects --batch-check='%(objectname)-%(objecttype)-%(objectsize)'
>   Time (mean ± σ):     985.8 ms ±   2.9 ms    [User: 960.5 ms, System: 23.6 ms]
>   Range (min … max):   982.9 ms … 993.0 ms    10 runs
> 
> Benchmark 4: ./git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype) %(objectsize)'
>   Time (mean ± σ):     982.1 ms ±   3.2 ms    [User: 956.7 ms, System: 23.6 ms]
>   Range (min … max):   979.2 ms … 989.2 ms    10 runs

OK, so we managed another 1%. But I'm skeptical that this linear opcode
technique is where we want to go in the long run, if we're ever going to
unify formatters.

One, for more advanced features like %(if) we'd want to support some
notion of hierarchy and recursion. We have to speculatively format the
inner part and see if it is empty.

Though I guess that is possible with a linearized set of opcodes. If you
turn "%(if)%(HEAD)%(then)*%(end)" into:

  FMT_IF
  FMT_HEAD
  FMT_THEN
  FMT_LITERAL
  *
  FMT_END

then I guess you just start a sub-execution of the opcodes after FMT_IF
and tell it to stop when it sees FMT_THEN. It does mean that the opcodes
themselves need to control the program counter, rather than the executor
blindly walking along the opcodes and asking them to put stuff in the
output. Whereas I think if the parser builds a tree of structs then this
falls out pretty naturally.

The second thing is that many of the ref-filter atoms have options, and
those options have to be represented in the opcodes. That works
naturally if each opcode gets its own struct (either with a big union,
or true polymorphism). But representing "%(describe:match=versions/v*)"
in opcodes sounds gross. Now you need opcodes to represent the options
(and maybe "no more options"), and some way of encoding arbitrary input
for those option arguments.

-Peff

^ permalink raw reply

* Re: [PATCH v3 0/4] Add support for an external command for fetching notes
From: Siddh Raman Pant @ 2026-06-16 10:59 UTC (permalink / raw)
  To: git@vger.kernel.org
  Cc: oswald.buddenhagen@gmx.de, gitster@pobox.com,
	code@khaugsbakk.name, j6t@kdbg.org, peff@peff.net, ps@pks.im,
	sandals@crustytoothpaste.net, newren@gmail.com
In-Reply-To: <cover.1779532562.git.siddh.raman.pant@oracle.com>

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

Ping...

Thread link:
https://lore.kernel.org/git/cover.1779532562.git.siddh.raman.pant@oracle.com/

Thanks,
Siddh

On Sat, May 23 2026 at 16:08:08 +0530, Siddh Raman Pant wrote:
> v2: https://lore.kernel.org/git/cover.1779464886.git.siddh.raman.pant@oracle.com/
> v1: https://lore.kernel.org/git/cover.1779207350.git.siddh.raman.pant@oracle.com/
> 
> <...insert text from v1 cover here...>
> 
> Changes since v2:
> - Removed stale help text talking about force-killing helper process.
> 
> Changes since v1:
> - Removed Documentation commit and sent as a standalone patch.
> - Removed finish_command_with_timeout addition (and thus sleep_nanosec).
> - Squashed the external notes command code, doc, and test commits.
> - Removed horizontal separators from note-external.c.
> - Removed global variables from translation unit and instead store config in
>   a dedicated new struct member in struct display_notes_opt.
> - Reworded the main commit to have better explanation of the motivation.
> 
> Siddh Raman Pant (4):
>   notes: convert raw arg in format_display_notes() to bool
>   wrapper: add support for timeout and deadline in read helpers
>   t3301: cover generic displayed notes behavior
>   notes: support an external command to display notes
> 
>  Documentation/config/notes.adoc             |  59 +++
>  Documentation/git-format-patch.adoc         |  11 +-
>  Documentation/git-range-diff.adoc           |   6 +
>  Documentation/pretty-options.adoc           |   9 +
>  Makefile                                    |   2 +
>  builtin/log.c                               |  17 +-
>  builtin/name-rev.c                          |   9 +-
>  builtin/range-diff.c                        |   2 +
>  contrib/completion/git-completion.bash      |   4 +-
>  log-tree.c                                  |  10 +-
>  meson.build                                 |   1 +
>  notes-external.c                            | 414 ++++++++++++++++++
>  notes-external.h                            |  53 +++
>  notes.c                                     | 266 +++++++++---
>  notes.h                                     |  33 +-
>  revision.c                                  |  36 +-
>  strbuf.c                                    |  26 +-
>  strbuf.h                                    |   4 +
>  t/helper/meson.build                        |   1 +
>  t/helper/test-external-notes                |  64 +++
>  t/helper/test-notes-external-config-reset.c |  24 ++
>  t/helper/test-tool.c                        |   1 +
>  t/helper/test-tool.h                        |   1 +
>  t/lib-notes.sh                              |  19 +
>  t/t3206-range-diff.sh                       |  68 +++
>  t/t3301-notes.sh                            | 448 ++++++++++++++++++++
>  t/t6120-describe.sh                         |  17 +
>  wrapper.c                                   | 139 +++++-
>  wrapper.h                                   |  23 +
>  29 files changed, 1691 insertions(+), 76 deletions(-)
>  create mode 100644 notes-external.c
>  create mode 100644 notes-external.h
>  create mode 100755 t/helper/test-external-notes
>  create mode 100644 t/helper/test-notes-external-config-reset.c
>  create mode 100644 t/lib-notes.sh

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

^ permalink raw reply

* Re: [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit
From: Phillip Wood @ 2026-06-16 10:10 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget, git
  Cc: Harald Nordgren, D. Ben Knoble, Patrick Steinhardt,
	Junio C Hamano
In-Reply-To: <pull.2337.v2.git.git.1781512625.gitgitgadget@gmail.com>

Hi Harald

On 15/06/2026 09:37, Harald Nordgren via GitGitGadget wrote:
> Rename to rebase --squash.

Please include the original cover letter as well so people who have not 
read the previous version know what the series is about.

I agree that git-history might be a better place for this but I'm not 
opposed to adding a --squash option to git-rebase. However I do think we 
need to think about the implementation - picking consecutive commits 
when we want to squash them together is inefficient and if we're 
changing the base risks the user having to stop to resolve conflicts 
multiple times. Regardless of whether this ends up in git-rebase or 
git-history I think the implementation should cherry-pick the whole 
commit range by doing the equivalent of

   git merge-tree --merge-base $(git merge-base HEAD @{upstream}) \
                  @{upstream} HEAD

We should also let the user edit the commit message to reflect the 
changes that are being squashed in. We should think about what support 
we want for "amend!" commits that replace the commit message when rebasing.

Thanks

Phillip

> Harald Nordgren (2):
>    t3415: remove prepare-commit-msg hook after use
>    rebase: add --squash to fold a range
> 
>   Documentation/git-rebase.adoc |  11 ++++
>   builtin/rebase.c              |  16 ++++-
>   sequencer.c                   |  24 ++++++-
>   sequencer.h                   |   2 +-
>   t/t3415-rebase-autosquash.sh  | 118 ++++++++++++++++++++++++++++++++++
>   5 files changed, 166 insertions(+), 5 deletions(-)
> 
> 
> base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v2
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v2
> Pull-Request: https://github.com/git/git/pull/2337
> 
> Range-diff vs v1:
> 
>   1:  c55b9cd6f7 = 1:  c55b9cd6f7 t3415: remove prepare-commit-msg hook after use
>   2:  bd1bc62aa8 ! 2:  22d4276ff5 rebase: add --fixup-all to fold a range
>       @@ Metadata
>        Author: Harald Nordgren <haraldnordgren@gmail.com>
>        
>         ## Commit message ##
>       -    rebase: add --fixup-all to fold a range
>       +    rebase: add --squash to fold a range
>        
>            Folding a series of commits into one required either an interactive
>            rebase where each commit after the first was hand-edited to "fixup", or
>            a "git reset --soft" to the merge base followed by "git commit --amend".
>        
>       -    Add "git rebase --autosquash --fixup-all [<upstream>]" to do this
>       -    directly. It keeps the first commit in the range as a "pick" and turns
>       -    every later commit into a "fixup", so the whole range collapses into a
>       -    single commit that reuses the first commit's message. With no <upstream>
>       -    argument the range is "@{upstream}..HEAD", folding all unpushed commits
>       -    into one.
>       +    Add "git rebase --squash [<upstream>]" to do this directly. It keeps
>       +    the first commit in the range as a "pick" and turns every later commit
>       +    into a "fixup", so the whole range collapses into a single commit that
>       +    reuses the first commit's message. With no <upstream> argument the range
>       +    is "@{upstream}..HEAD", folding all unpushed commits into one.
>        
>       -    Fold the commits in their original order, so that any fixup!/squash!
>       -    commits already present in the range are folded in as well. Allow the
>       -    flag only together with --autosquash, and reject --rebase-merges since a
>       -    merge commit cannot be folded into another commit.
>       +    The option implies the merge backend, so it works on its own without
>       +    --autosquash. Fold the commits in their original order, so that any
>       +    fixup!/squash! commits already present in the range are folded in as
>       +    well. Reject --rebase-merges since a merge commit cannot be folded into
>       +    another commit.
>        
>       +    Inspired-by: Sergey Chernov <serega.morph@gmail.com>
>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>        
>         ## Documentation/git-rebase.adoc ##
>       @@ Documentation/git-rebase.adoc: option can be used to override that setting.
>         +
>         See also INCOMPATIBLE OPTIONS below.
>         
>       -+--fixup-all::
>       -+	Valid only when used with `--autosquash`.  Keep the first commit in
>       -+	the range as a `pick` and change every later commit to a `fixup`, so
>       -+	the whole range is folded into a single commit that reuses the first
>       -+	commit's message.  With no `<upstream>` argument this folds all commits
>       -+	since `@{upstream}` into one.  The commits are folded in their original
>       -+	order, so any `fixup!`/`squash!` commits already in the range are folded
>       -+	in as well.  Cannot be combined with `--rebase-merges`, as a merge
>       -+	commit cannot be folded into another commit.
>       ++--squash::
>       ++	Keep the first commit in the range as a `pick` and change every later
>       ++	commit to a `fixup`, so the whole range is folded into a single commit
>       ++	that reuses the first commit's message.  With no `<upstream>` argument
>       ++	this folds all commits since `@{upstream}` into one.  The commits are
>       ++	folded in their original order, so any `fixup!`/`squash!` commits
>       ++	already in the range are folded in as well.  Cannot be combined with
>       ++	`--rebase-merges`, as a merge commit cannot be folded into another
>       ++	commit.
>        +
>         --autostash::
>         --no-autostash::
>       @@ Documentation/git-rebase.adoc: are incompatible with the following options:
>          * --strategy
>          * --strategy-option
>          * --autosquash
>       -+ * --fixup-all
>       ++ * --squash
>          * --rebase-merges
>          * --interactive
>          * --exec
>       @@ builtin/rebase.c: struct rebase_options {
>         	int allow_rerere_autoupdate;
>         	int keep_empty;
>         	int autosquash;
>       -+	int fixup_all;
>       ++	int squash;
>         	char *gpg_sign_opt;
>         	int autostash;
>         	int committer_date_is_author_date;
>       @@ builtin/rebase.c: static int do_interactive_rebase(struct rebase_options *opts,
>         		shortrevisions, opts->onto_name, opts->onto,
>         		&opts->orig_head->object.oid, &opts->exec,
>        -		opts->autosquash, opts->update_refs, &todo_list);
>       -+		opts->autosquash, opts->fixup_all, opts->update_refs,
>       ++		opts->autosquash, opts->squash, opts->update_refs,
>        +		&todo_list);
>         
>         cleanup:
>       @@ builtin/rebase.c: int cmd_rebase(int argc,
>         		OPT_BOOL(0, "autosquash", &options.autosquash,
>         			 N_("move commits that begin with "
>         			    "squash!/fixup! under -i")),
>       -+		OPT_BOOL(0, "fixup-all", &options.fixup_all,
>       ++		OPT_BOOL(0, "squash", &options.squash,
>        +			 N_("fold all commits in the range into the first one")),
>         		OPT_BOOL(0, "update-refs", &options.update_refs,
>         			 N_("update branches that point to commits "
>         			    "that are being rebased")),
>       +@@ builtin/rebase.c: int cmd_rebase(int argc,
>       + 	if ((options.flags & REBASE_INTERACTIVE_EXPLICIT) ||
>       + 	    (options.action != ACTION_NONE) ||
>       + 	    (options.exec.nr > 0) ||
>       +-	    options.autosquash == 1) {
>       ++	    options.autosquash == 1 ||
>       ++	    options.squash) {
>       + 		allow_preemptive_ff = 0;
>       + 	}
>       + 	if (options.committer_date_is_author_date || options.ignore_date)
>        @@ builtin/rebase.c: int cmd_rebase(int argc,
>         	options.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :
>         				((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);
>         
>       -+	if (options.fixup_all && options.autosquash != 1)
>       -+		die(_("--fixup-all requires --autosquash"));
>       -+
>       -+	if (options.fixup_all && options.rebase_merges)
>       ++	if (options.squash && options.rebase_merges)
>        +		die(_("options '%s' and '%s' cannot be used together"),
>       -+		    "--fixup-all", "--rebase-merges");
>       ++		    "--squash", "--rebase-merges");
>       ++
>       ++	if (options.squash)
>       ++		imply_merge(&options, "--squash");
>        +
>         	if (options.autosquash == 1) {
>         		imply_merge(&options, "--autosquash");
>       @@ sequencer.c: static int todo_list_add_update_ref_commands(struct todo_list *todo
>         		    struct commit *onto, const struct object_id *orig_head,
>         		    struct string_list *commands, unsigned autosquash,
>        -		    unsigned update_refs,
>       -+		    unsigned fixup_all, unsigned update_refs,
>       ++		    unsigned squash, unsigned update_refs,
>         		    struct todo_list *todo_list)
>         {
>         	char shortonto[GIT_MAX_HEXSZ + 1];
>       @@ sequencer.c: int complete_action(struct repository *r, struct replay_opts *opts,
>         		return -1;
>         
>        -	if (autosquash && todo_list_rearrange_squash(todo_list))
>       -+	if (fixup_all)
>       ++	if (squash)
>        +		todo_list_fixup_all_but_first(todo_list);
>        +	else if (autosquash && todo_list_rearrange_squash(todo_list))
>         		return -1;
>       @@ sequencer.h: int complete_action(struct repository *r, struct replay_opts *opts,
>         		    struct commit *onto, const struct object_id *orig_head,
>         		    struct string_list *commands, unsigned autosquash,
>        -		    unsigned update_refs,
>       -+		    unsigned fixup_all, unsigned update_refs,
>       ++		    unsigned squash, unsigned update_refs,
>         		    struct todo_list *todo_list);
>         int todo_list_rearrange_squash(struct todo_list *todo_list);
>         
>       @@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit
>         	test_commit_message HEAD -m "something"
>         '
>         
>       -+test_expect_success '--fixup-all folds the range into the first commit' '
>       ++test_expect_success '--squash folds the range into the first commit' '
>        +	git reset --hard base &&
>        +	test_commit --no-tag fold1 file_fold a &&
>        +	test_commit --no-tag fold2 file_fold b &&
>        +	test_commit --no-tag fold3 file_fold c &&
>       -+	git rebase --autosquash --fixup-all HEAD~3 &&
>       ++	git rebase --squash HEAD~3 &&
>        +	test_cmp_rev base HEAD~1 &&
>        +	test_commit_message HEAD -m "fold1" &&
>        +	echo c >expect &&
>        +	test_cmp expect file_fold
>        +'
>        +
>       -+test_expect_success '--fixup-all folds smoothly when a fixup! commit is in the series' '
>       ++test_expect_success '--squash folds smoothly when a fixup! commit is in the series' '
>        +	git reset --hard base &&
>        +	test_commit --no-tag foldA file_fold a &&
>        +	test_commit --no-tag foldB file_fold b &&
>        +	git commit --allow-empty --fixup HEAD~1 &&
>       -+	git rebase --autosquash --fixup-all HEAD~3 &&
>       ++	git rebase --squash HEAD~3 &&
>        +	test_cmp_rev base HEAD~1 &&
>        +	test_commit_message HEAD -m "foldA" &&
>        +	echo b >expect &&
>        +	test_cmp expect file_fold
>        +'
>        +
>       -+test_expect_success '--fixup-all picks the first commit even if it is a fixup!' '
>       ++test_expect_success '--squash picks the first commit even if it is a fixup!' '
>        +	git reset --hard base &&
>        +	test_commit --no-tag fixupbase file_fix a &&
>        +	git commit --allow-empty --fixup HEAD &&
>        +	test_commit --no-tag fixuptail file_fix b &&
>       -+	git rebase --autosquash --fixup-all HEAD~3 &&
>       ++	git rebase --squash HEAD~3 &&
>        +	test_cmp_rev base HEAD~1 &&
>        +	echo b >expect &&
>        +	test_cmp expect file_fix
>        +'
>        +
>       -+test_expect_success '--fixup-all with a single commit in range is a no-op' '
>       ++test_expect_success '--squash with a single commit in range is a no-op' '
>        +	git reset --hard base &&
>        +	test_commit --no-tag solo file_solo a &&
>        +	git rev-parse HEAD >expect &&
>       -+	git rebase --autosquash --fixup-all HEAD~1 &&
>       ++	git rebase --squash HEAD~1 &&
>        +	git rev-parse HEAD >actual &&
>        +	test_cmp expect actual
>        +'
>        +
>       -+test_expect_success '--fixup-all with an empty range succeeds' '
>       ++test_expect_success '--squash with an empty range succeeds' '
>        +	git reset --hard base &&
>       -+	git rebase --autosquash --fixup-all HEAD &&
>       ++	git rebase --squash HEAD &&
>        +	test_cmp_rev base HEAD
>        +'
>        +
>       -+test_expect_success '--fixup-all skips a dropped commit in the range' '
>       ++test_expect_success '--squash skips a dropped commit in the range' '
>        +	git reset --hard base &&
>        +	test_commit --no-tag fixdrop1 file_drop a &&
>        +	git commit --allow-empty -m "empty in the middle" &&
>        +	test_commit --no-tag fixdrop3 file_drop b &&
>       -+	git rebase --autosquash --empty=drop --fixup-all HEAD~3 &&
>       ++	git rebase --squash --empty=drop HEAD~3 &&
>        +	test_cmp_rev base HEAD~1 &&
>        +	test_commit_message HEAD -m "fixdrop1" &&
>        +	echo b >expect &&
>        +	test_cmp expect file_drop
>        +'
>        +
>       -+test_expect_success '--fixup-all folds a merge commit in the middle of the range' '
>       ++test_expect_success '--squash folds a merge commit in the middle of the range' '
>        +	git reset --hard base &&
>        +	test_commit --no-tag mid-first &&
>        +	git checkout -b mid-side &&
>       @@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit
>        +	git checkout - &&
>        +	git merge --no-ff -m "merge mid-side" mid-side &&
>        +	test_commit --no-tag mid-last &&
>       -+	git rebase --autosquash --fixup-all base &&
>       ++	git rebase --squash base &&
>        +	test_cmp_rev base HEAD~1 &&
>        +	test_commit_message HEAD -m "mid-first" &&
>        +	test_path_is_file mid-merged.t
>        +'
>        +
>       -+test_expect_success '--fixup-all keeps the first flattened commit when a merge sorts first' '
>       ++test_expect_success '--squash keeps the first flattened commit when a merge sorts first' '
>        +	git reset --hard base &&
>        +	git checkout -b head-side &&
>        +	test_commit --no-tag head-merged &&
>        +	git checkout - &&
>        +	git merge --no-ff -m "merge head-side" head-side &&
>        +	test_commit --no-tag head-last &&
>       -+	git rebase --autosquash --fixup-all base &&
>       ++	git rebase --squash base &&
>        +	test_cmp_rev base HEAD~1 &&
>        +	test_commit_message HEAD -m "head-merged" &&
>        +	test_path_is_file head-merged.t
>        +'
>        +
>       -+test_expect_success '--fixup-all requires --autosquash' '
>       ++test_expect_success '--squash takes precedence over --autosquash' '
>        +	git reset --hard base &&
>       -+	test_must_fail git rebase --fixup-all HEAD~1 2>err &&
>       -+	test_grep "fixup-all requires --autosquash" err &&
>       -+	test_must_fail git rebase --no-autosquash --fixup-all HEAD~1 2>err &&
>       -+	test_grep "fixup-all requires --autosquash" err
>       ++	test_commit --no-tag combo-first &&
>       ++	test_commit --no-tag combo-mid &&
>       ++	git commit --allow-empty --fixup HEAD~1 &&
>       ++	test_commit --no-tag combo-last &&
>       ++	git rebase --autosquash --squash base &&
>       ++	test_cmp_rev base HEAD~1 &&
>       ++	test_commit_message HEAD -m "combo-first"
>       ++'
>       ++
>       ++test_expect_success '--squash folds the range with rebase.autosquash set' '
>       ++	test_config rebase.autosquash true &&
>       ++	git reset --hard base &&
>       ++	test_commit --no-tag cfg-first &&
>       ++	test_commit --no-tag cfg-last &&
>       ++	git rebase --squash base &&
>       ++	test_cmp_rev base HEAD~1 &&
>       ++	test_commit_message HEAD -m "cfg-first"
>        +'
>        +
>       -+test_expect_success '--fixup-all and --rebase-merges cannot be combined' '
>       ++test_expect_success '--squash and --rebase-merges cannot be combined' '
>        +	git reset --hard base &&
>       -+	test_must_fail git rebase --autosquash --rebase-merges \
>       -+		--fixup-all HEAD~1 2>err &&
>       ++	test_must_fail git rebase --rebase-merges --squash HEAD~1 2>err &&
>        +	test_grep "cannot be used together" err &&
>        +	test_path_is_missing .git/rebase-merge
>        +'
> 


^ permalink raw reply

* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
From: Phillip Wood @ 2026-06-16  9:59 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget, git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren
In-Reply-To: <9924373da0a0598cabe4f08f3bc4200833679171.1780999917.git.gitgitgadget@gmail.com>

Hi Harald

On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:

Carrying on where I left off yesterday ...

> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 4e7deddc04..27ea1319bb 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
>   	test_grep "requires a value" err
>   '
>   
> +test_expect_success '--prune-merged: setup' '
> +	test_create_repo pm-upstream &&

The rest of this test would be easier to read if we did

	(
		cd pm-upstream &&
		...
	)

rather than prefixing every command with "-C pm-upstream"

> +	test_commit -C pm-upstream base &&
> +	git -C pm-upstream checkout -b next &&
> +	test_commit -C pm-upstream one-commit &&
> +	test_commit -C pm-upstream two-commit &&
> +	git -C pm-upstream branch one HEAD~ &&
> +	git -C pm-upstream branch two HEAD &&
> +	git -C pm-upstream branch wip main &&
> +	git -C pm-upstream checkout main &&
> +	test_create_repo pm-fork
> +'
> +
> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> +	test_when_finished "rm -rf pm-merged" &&
> +	git clone pm-upstream pm-merged &&
> +	git -C pm-merged remote add fork ../pm-fork &&
> +	test_config -C pm-merged remote.pushDefault fork &&
> +	test_config -C pm-merged push.default current &&

So we clone upstream and add fork as the default push remote. I find the 
pm- prefixes rather distracting. It would be clearer to me if we just 
called the repositories "upstream", "fork" and "repo"

> +	git -C pm-merged branch one one-commit &&
> +	git -C pm-merged branch --set-upstream-to=origin/next one &&
> +	git -C pm-merged branch two two-commit &&
> +	git -C pm-merged branch --set-upstream-to=origin/next two &&

Now we set up a couple of local branches with no local commits that 
track origin/next which seems a bit odd. Why don't we create local 
branches based one origin/next (and origin/main if we're using a 
wildcard below) with some local commits like a user would?

> +	git -C pm-merged branch --prune-merged "origin/*" &&

Here we delete all the branches whose upstream is on origin - which is 
all the branches that we've created so we're not really testing the 
safety features.

> +	test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-merged rev-parse --verify refs/heads/two

This verifies that the branches are deleted. It would be a good idea to 
check the output of command above to check --prune-merged prints what we 
expect it to and that it leaves branches with other upstreams.

> +test_expect_success '--prune-merged accepts a literal upstream' '
> +	test_when_finished "rm -rf pm-literal" &&
> +	git clone pm-upstream pm-literal &&

let's not litter the test directory with a hundred repositories - just 
call it "repo" and add remove it with test_when_finished in each test, 
or reuse it so we don't waste time cloning and setting up the config 
each time (that would mean not using test_config).

> +	git -C pm-literal remote add fork ../pm-fork &&
> +	test_config -C pm-literal remote.pushDefault fork &&
> +	test_config -C pm-literal push.default current &&
> +	git -C pm-literal branch one one-commit &&
> +	git -C pm-literal branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-literal branch --prune-merged origin/next &&
> +
> +	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one

Again we're not testing that nothing else is deleted.

> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> +	test_when_finished "rm -rf pm-union" &&
> +	git clone pm-upstream pm-union &&
> +	git -C pm-union remote add fork ../pm-fork &&
> +	test_config -C pm-union remote.pushDefault fork &&
> +	test_config -C pm-union push.default current &&
> +	git -C pm-union branch one one-commit &&
> +	git -C pm-union branch --set-upstream-to=origin/next one &&
> +	git -C pm-union branch two base &&
> +	git -C pm-union branch --set-upstream-to=origin/main two &&
> +	git -C pm-union checkout --detach &&
> +
> +	git -C pm-union branch --prune-merged origin/next origin/main &&

This is more interesting - we don't need the test for a single literal 
upstream if we're doing this. Again we need to test the safety features. 
As these are integration tests you can do that at the same time as 
testing that some branches are removed - you don't need so many separate 
(expensive) tests.

> +	test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a local upstream' '
> +	test_when_finished "rm -rf pm-local" &&
> +	git clone pm-upstream pm-local &&
> +	git -C pm-local remote add fork ../pm-fork &&
> +	test_config -C pm-local remote.pushDefault fork &&
> +	test_config -C pm-local push.default current &&
> +	git -C pm-local checkout -b trunk &&
> +	git -C pm-local branch one one-commit &&
> +	git -C pm-local branch --set-upstream-to=trunk one &&
> +	git -C pm-local merge --ff-only one-commit &&
> +
> +	git -C pm-local branch --prune-merged trunk &&

Can't we test a local upstream at the same time as a remote upstream and 
save a test case?

> +	test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> +	test_when_finished "rm -rf pm-unmerged" &&
> +	git clone pm-upstream pm-unmerged &&
> +	git -C pm-unmerged remote add fork ../pm-fork &&
> +	test_config -C pm-unmerged remote.pushDefault fork &&
> +	test_config -C pm-unmerged push.default current &&
> +	git -C pm-unmerged checkout -b wip origin/wip &&
> +	git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> +	test_commit -C pm-unmerged local-only &&
> +	git -C pm-unmerged checkout - &&
> +
> +	git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> +	test_grep "not fully merged" err &&
> +	test_grep ! "If you are sure you want to delete it" err &&

I'm always suspicious of test_grep when we know what the output should 
look like - it might be better to use test_cmp. This test does not check 
that we also delete branches that are merged when we see one that isn't.

I'm going to stop here - the tests I've read seem to me to be too much 
like unit tests checking one aspect of the implementation in isolation 
rather than checking that the whole feature works as expected.

Thanks

Phillip

> +	git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> +	test_when_finished "rm -rf pm-nohead" &&
> +	git clone pm-upstream pm-nohead &&
> +	git -C pm-nohead remote add fork ../pm-fork &&
> +	test_config -C pm-nohead remote.pushDefault fork &&
> +	test_config -C pm-nohead push.default current &&
> +	git -C pm-nohead branch topic one-commit &&
> +	git -C pm-nohead branch --set-upstream-to=origin/next topic &&
> +
> +	git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
> +
> +	test_grep ! "not yet merged to HEAD" err &&
> +	test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged skips branches whose upstream is gone' '
> +	test_when_finished "rm -rf pm-upstream-gone" &&
> +	git clone pm-upstream pm-upstream-gone &&
> +	git -C pm-upstream-gone remote add fork ../pm-fork &&
> +	test_config -C pm-upstream-gone remote.pushDefault fork &&
> +	test_config -C pm-upstream-gone push.default current &&
> +	git -C pm-upstream-gone branch one one-commit &&
> +	git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
> +	git -C pm-upstream-gone branch --prune-merged "origin/*" &&
> +
> +	git -C pm-upstream-gone rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged never deletes the checked-out branch' '
> +	test_when_finished "rm -rf pm-head" &&
> +	git clone pm-upstream pm-head &&
> +	git -C pm-head remote add fork ../pm-fork &&
> +	test_config -C pm-head remote.pushDefault fork &&
> +	test_config -C pm-head push.default current &&
> +	git -C pm-head checkout -b one one-commit &&
> +	git -C pm-head branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-head branch --prune-merged "origin/*" &&
> +
> +	git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged spares branches that push back to their upstream' '
> +	test_when_finished "rm -rf pm-push-eq" &&
> +	git clone pm-upstream pm-push-eq &&
> +	git -C pm-push-eq checkout --detach &&
> +
> +	git -C pm-push-eq branch --prune-merged "origin/*" &&
> +
> +	git -C pm-push-eq rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
> +	test_when_finished "rm -rf pm-push-branch" &&
> +	git clone pm-upstream pm-push-branch &&
> +	git -C pm-push-branch remote add fork ../pm-fork &&
> +	test_config -C pm-push-branch remote.pushDefault fork &&
> +	test_config -C pm-push-branch push.default current &&
> +	test_config -C pm-push-branch branch.main.pushRemote origin &&
> +	git -C pm-push-branch checkout --detach &&
> +
> +	git -C pm-push-branch branch --prune-merged "origin/*" &&
> +
> +	git -C pm-push-branch rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> +	test_when_finished "rm -rf pm-push-diff" &&
> +	git clone pm-upstream pm-push-diff &&
> +	git -C pm-push-diff remote add fork ../pm-fork &&
> +	test_config -C pm-push-diff remote.pushDefault fork &&
> +	test_config -C pm-push-diff push.default current &&
> +	git -C pm-push-diff branch topic one-commit &&
> +	git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> +	git -C pm-push-diff checkout --detach &&
> +
> +	git -C pm-push-diff branch --prune-merged "origin/*" &&
> +
> +	test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires at least one <branch>' '
> +	test_must_fail git -C forked branch --prune-merged 2>err &&
> +	test_grep "requires at least one <branch>" err
> +'
> +
> +test_expect_success '--prune-merged takes positional <branch> arguments' '
> +	test_when_finished "rm -rf pm-positional" &&
> +	git clone pm-upstream pm-positional &&
> +	git -C pm-positional remote add fork ../pm-fork &&
> +	test_config -C pm-positional remote.pushDefault fork &&
> +	test_config -C pm-positional push.default current &&
> +	git -C pm-positional branch one one-commit &&
> +	git -C pm-positional branch --set-upstream-to=origin/next one &&
> +	git -C pm-positional branch two base &&
> +	git -C pm-positional branch --set-upstream-to=origin/main two &&
> +	git -C pm-positional checkout --detach &&
> +
> +	git -C pm-positional branch --prune-merged origin/next origin/main &&
> +
> +	test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
> +'
> +
>   test_done


^ permalink raw reply

* Re: [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Phillip Wood @ 2026-06-16  9:57 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget, git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren
In-Reply-To: <d691d5051b35a569dbd3f4a0488030a7d84d72f9.1780999917.git.gitgitgadget@gmail.com>

Hi Harald

On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> 
> Setting branch.<name>.pruneMerged=false exempts that branch from
> "git branch --prune-merged", which is useful for a topic you want
> to keep developing after an early round of it has been merged
> upstream. Unless --quiet is given, each skip is reported so the
> user knows why their topic was kept.

Sounds good

> @@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv,
>   		if (!push || !strcmp(push, upstream))
>   			continue;
>   
> +		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
> +		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
> +		    !opt_out) {
> +			if (!quiet)
> +				fprintf(stderr,
> +					_("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
> +					short_name, short_name);
> +			strbuf_release(&key);
> +			continue;
> +		}
> +		strbuf_release(&key);

As this is in a loop we don't want to free the buffer on each iteration, 
only at the end. You should call strbuf_reset() just before 
strbuf_addf() above and then move this call to strbuf_release() out of 
the loop.

> +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
> +	test_when_finished "rm -rf pm-optout" &&
> +	git clone pm-upstream pm-optout &&
> +	git -C pm-optout remote add fork ../pm-fork &&
> +	test_config -C pm-optout remote.pushDefault fork &&
> +	test_config -C pm-optout push.default current &&
> +	git -C pm-optout branch one one-commit &&
> +	git -C pm-optout branch --set-upstream-to=origin/next one &&
> +	git -C pm-optout branch two two-commit &&
> +	git -C pm-optout branch --set-upstream-to=origin/next two &&
> +	test_config -C pm-optout branch.one.pruneMerged false &&
> +
> +	git -C pm-optout branch --prune-merged "origin/*" 2>err &&
> +
> +	git -C pm-optout rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
> +	test_grep "Skipping .one." err

Do we really need a whole new setup to test this - can't we just add a 
protected branch to an existing test?

Thanks

Phillip

> +
> +test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
> +	test_when_finished "rm -rf pm-optout-d" &&
> +	git clone pm-upstream pm-optout-d &&
> +	git -C pm-optout-d branch one one-commit &&
> +	git -C pm-optout-d branch --set-upstream-to=origin/next one &&
> +	test_config -C pm-optout-d branch.one.pruneMerged false &&
> +
> +	git -C pm-optout-d branch -d one &&
> +	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
> +'
> +
>   test_done


^ permalink raw reply

* Re: [PATCH v14 6/6] branch: add --dry-run for --prune-merged
From: Phillip Wood @ 2026-06-16  9:57 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget, git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
In-Reply-To: <ede8c6172963fb8d15f0ae28f4e11501cf42be6c.1780999917.git.gitgitgadget@gmail.com>

Hi Harald

On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> 
> With --dry-run, --prune-merged prints the local branches it would
> delete, one "Would delete branch <name>" line each, and exits
> without touching any ref. The same filtering applies, so the output
> is exactly the set that the real run would delete.

I can see this being very useful.

> diff --git a/builtin/branch.c b/builtin/branch.c
> index 52a0371292..7c52a88af2 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
>   }
>   
>   static int prune_merged_branches(int argc, const char **argv,
> -				 int quiet)
> +				 int quiet, int dry_run)

Let's not start adding multiple boolean augments - use a flags argument 
like we do for delete_branches() - if you get feedback on one patch you 
should think about whether it applies later in the series as well. The 
rest of the implementation looks good.

> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 3f7b1fc3d6..305c0141fc 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
>   	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
>   '
>   
> +test_expect_success '--prune-merged --dry-run lists but does not delete' '

A good way to test --dry-run would be to add it to an existing test 
before calling --prune-merged without --dry-run.

Thanks

Phillip

> +	test_when_finished "rm -rf pm-dry" &&
> +	git clone pm-upstream pm-dry &&
> +	git -C pm-dry remote add fork ../pm-fork &&
> +	test_config -C pm-dry remote.pushDefault fork &&
> +	test_config -C pm-dry push.default current &&
> +	git -C pm-dry branch one one-commit &&
> +	git -C pm-dry branch --set-upstream-to=origin/next one &&
> +	git -C pm-dry branch two two-commit &&
> +	git -C pm-dry branch --set-upstream-to=origin/next two &&
> +
> +	git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
> +	test_grep "Would delete branch one " actual &&
> +	test_grep "Would delete branch two " actual &&
> +
> +	git -C pm-dry rev-parse --verify refs/heads/one &&
> +	git -C pm-dry rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
> +	test_when_finished "rm -rf pm-dry-mixed" &&
> +	git clone pm-upstream pm-dry-mixed &&
> +	git -C pm-dry-mixed remote add fork ../pm-fork &&
> +	test_config -C pm-dry-mixed remote.pushDefault fork &&
> +	test_config -C pm-dry-mixed push.default current &&
> +	git -C pm-dry-mixed checkout -b wip origin/next &&
> +	git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
> +	test_commit -C pm-dry-mixed local-only &&
> +	git -C pm-dry-mixed checkout - &&
> +	git -C pm-dry-mixed branch merged one-commit &&
> +	git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
> +
> +	git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out &&
> +	test_grep "Would delete branch merged" out &&
> +	test_grep ! "Would delete branch wip" out &&
> +	git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
> +	git -C pm-dry-mixed rev-parse --verify refs/heads/merged
> +'
> +
> +test_expect_success '--dry-run without --prune-merged is rejected' '
> +	test_must_fail git -C forked branch --dry-run 2>err &&
> +	test_grep "requires --prune-merged" err
> +'
> +
>   test_done


^ permalink raw reply

* [PATCH v3 3/3] replay: offer an option to linearize the commit topology
From: Toon Claes @ 2026-06-16  9:26 UTC (permalink / raw)
  To: git; +Cc: Toon Claes, Johannes Schindelin, Johannes Schindelin
In-Reply-To: <20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com>

From: Johannes Schindelin <Johannes.Schindelin@gmx.de>

One of the stated goals of git-replay(1) is to allow implementing the
git-rebase(1) functionality on the server side.

The default mode of git-rebase(1) is to act as if `--no-rebase-merges`
was given. This mode drops merge commits instead of replaying them, and
linearizes the commit history into a sequence of the
regular (single-parent) commits.

Add option `--linearize` to git-replay(1) to do the same.

Co-authored-by: Toon Claes <toon@iotcl.com>
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Toon Claes <toon@iotcl.com>
---
 Documentation/git-replay.adoc |  8 ++++-
 builtin/replay.c              |  6 +++-
 replay.c                      | 32 +++++++++++++++-----
 replay.h                      |  5 ++++
 t/t3650-replay-basics.sh      | 68 ++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 109 insertions(+), 10 deletions(-)

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index a32f72aead..ef56ee0f1b 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
 --------
 [verse]
 (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
+			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
 
 DESCRIPTION
 -----------
@@ -88,6 +88,12 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
 
+--linearize::
+	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
+	i.e. it cherry-picks only non-merge commits, each one on top of the
+	previous one.
+	This option is incompatible with `--revert`.
+
 <revision-range>::
 	Range of commits to replay; see "Specifying Ranges" in
 	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
diff --git a/builtin/replay.c b/builtin/replay.c
index 39e3a86f6c..62962c73c7 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -85,7 +85,7 @@ int cmd_replay(int argc,
 	const char *const replay_usage[] = {
 		N_("(EXPERIMENTAL!) git replay "
 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
+		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
 		NULL
 	};
 	struct option replay_options[] = {
@@ -111,6 +111,8 @@ int cmd_replay(int argc,
 			     N_("mode"),
 			     N_("control ref update behavior (update|print)"),
 			     PARSE_OPT_NONEG),
+		OPT_BOOL(0, "linearize", &opts.linearize,
+			 N_("drop merge commits, replaying only non-merge commits")),
 		OPT_END()
 	};
 
@@ -132,6 +134,8 @@ int cmd_replay(int argc,
 				  opts.contained, "--contained");
 	die_for_incompatible_opt2(!!opts.ref, "--ref",
 				  !!opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.revert, "--revert",
+				  opts.linearize, "--linearize");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
diff --git a/replay.c b/replay.c
index 7921d7dba3..5539daff00 100644
--- a/replay.c
+++ b/replay.c
@@ -277,12 +277,16 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
+					  struct commit *replayed_base,
 					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
-	struct commit *base, *replayed_base;
+	struct commit *base;
 	struct tree *pickme_tree, *base_tree, *replayed_base_tree;
 
+	if (replayed_base && reverse)
+		BUG("Linearizing commits is not supported when replaying in reverse");
+
 	if (pickme->parents) {
 		base = pickme->parents->item;
 		base_tree = repo_get_commit_tree(repo, base);
@@ -291,7 +295,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = get_mapped_commit(replayed_commits, base, onto);
+	if (!replayed_base)
+		replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -430,12 +435,25 @@ int replay_revisions(struct rev_info *revs,
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
 
-		if (commit->parents && commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
+		if (commit->parents && commit->parents->next) {
+			if (!opts->linearize)
+				die(_("replaying merge commits is not supported yet!"));
+			/*
+			 * Drop the merge commit: do not pick it and leave
+			 * last_commit unchanged, so its children (and any ref
+			 * pointing at it) are reparented onto the previous
+			 * non-merge commit, which the ref-update loop below uses.
+			 */
+		} else {
+			struct commit *to_pick = reverse ? last_commit : onto;
+			last_commit =
+				pick_regular_commit(revs->repo, commit,
+						    replayed_commits, to_pick,
+						    &merge_opt, &result,
+						    opts->linearize ? last_commit : NULL,
+						    reverse, opts->empty);
+		}
 
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  reverse ? last_commit : onto,
-						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 
diff --git a/replay.h b/replay.h
index 1851a07705..07e6fdcca3 100644
--- a/replay.h
+++ b/replay.h
@@ -62,6 +62,11 @@ struct replay_revisions_options {
 	 * Defaults to REPLAY_EMPTY_COMMIT_DROP.
 	 */
 	enum replay_empty_commit_action empty;
+
+	/*
+	 * Whether to linearize the commits (i.e. drop merge commits).
+	 */
+	int linearize;
 };
 
 /* This struct is used as an out-parameter by `replay_revisions()`. */
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 3353bc4a4d..1874d06769 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -52,8 +52,12 @@ test_expect_success 'setup' '
 	test_merge P O --no-ff &&
 	git switch main &&
 
+	git switch --orphan unrelated &&
+	test_commit unrelated-root &&
+
 	git switch -c conflict B &&
-	test_commit C.conflict C.t conflict
+	test_commit C.conflict C.t conflict &&
+	git branch -D unrelated
 '
 
 test_expect_success 'setup bare' '
@@ -97,6 +101,12 @@ test_expect_success '--advance and --contained cannot be used together' '
 	test_grep "cannot be used together" actual
 '
 
+test_expect_success '--revert and --linearize cannot be used together' '
+	test_must_fail git replay --revert=main --linearize \
+		topic1..topic2 2>actual &&
+	test_grep "cannot be used together" actual
+'
+
 test_expect_success 'cannot advance target ... ordering would be ill-defined' '
 	echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
 	test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
@@ -565,4 +575,60 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' '
 	test_grep "cannot be used with multiple revision ranges" err
 '
 
+test_expect_success 'replay to rebase merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
+	git replay --ref-action=print --linearize \
+		--onto unrelated-root topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J I B A unrelated-root >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'replay to cherry-pick merge commit with --linearize' '
+	git replay --ref-action=print --linearize \
+		--advance main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+
+	git log --format=%s $(cut -f 3 -d " " result) >actual &&
+	test_write_lines O N J M L B A >expect &&
+	test_cmp expect actual &&
+
+	printf "update refs/heads/main " >expect &&
+	printf "%s " $(cut -f 3 -d " " result) >>expect &&
+	git rev-parse main >>expect &&
+	test_cmp expect result
+'
+
+test_expect_success 'replay --linearize produces the same patches' '
+	git replay --ref-action=print --linearize \
+		--onto main I..topic-with-merge >result &&
+
+	test_line_count = 1 result &&
+	tip=$(cut -f 3 -d " " result) &&
+
+	# range-diff does not care about the dropped merge,
+	# so the original commits (I..topic-with-merge)
+	# and the replayed chain (main..tip) must produce identical patches.
+	git range-diff I..topic-with-merge main..$tip >out &&
+	test_file_not_empty out &&
+	! grep -v "=" out &&
+
+	git log --oneline main..$tip >out &&
+	test_line_count = 3 out
+'
+
 test_done

-- 
2.53.0.1323.g189a785ab5


^ permalink raw reply related

* [PATCH v3 2/3] replay: add helper to put entry into mapped_commits
From: Toon Claes @ 2026-06-16  9:26 UTC (permalink / raw)
  To: git; +Cc: Toon Claes
In-Reply-To: <20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com>

The function replay_revisions() in replay.c is rather lengthy. Extract
the logic to put a commit entry into mapped_commits into a helper
function put_mapped_commit().

While at it, rename mapped_commit() to get_mapped_commit() to pair with
this new function.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 31 ++++++++++++++++++++-----------
 1 file changed, 20 insertions(+), 11 deletions(-)

diff --git a/replay.c b/replay.c
index 1f8e5b083b..7921d7dba3 100644
--- a/replay.c
+++ b/replay.c
@@ -243,9 +243,9 @@ static void set_up_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
+static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits,
+					struct commit *commit,
+					struct commit *fallback)
 {
 	khint_t pos;
 	if (!commit)
@@ -256,6 +256,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
 	return kh_value(replayed_commits, pos);
 }
 
+static void put_mapped_commit(kh_oid_map_t *replayed_commits,
+			      struct commit *commit,
+			      struct commit *new_commit)
+{
+	khint_t pos;
+	int ret;
+
+	pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret);
+	if (ret == 0)
+		BUG("Duplicate rewritten commit: %s\n",
+		    oid_to_hex(&commit->object.oid));
+
+	kh_value(replayed_commits, pos) = new_commit;
+}
+
 static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *pickme,
 					  kh_oid_map_t *replayed_commits,
@@ -276,7 +291,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		base_tree = lookup_tree(repo, repo->hash_algo->empty_tree);
 	}
 
-	replayed_base = mapped_commit(replayed_commits, base, onto);
+	replayed_base = get_mapped_commit(replayed_commits, base, onto);
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
@@ -414,8 +429,6 @@ int replay_revisions(struct rev_info *revs,
 	replayed_commits = kh_init_oid_map();
 	while ((commit = get_revision(revs))) {
 		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
 
 		if (commit->parents && commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
@@ -427,11 +440,7 @@ int replay_revisions(struct rev_info *revs,
 			break;
 
 		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
+		put_mapped_commit(replayed_commits, commit, last_commit);
 
 		/* Update any necessary branches */
 		if (ref)

-- 
2.53.0.1323.g189a785ab5


^ permalink raw reply related

* [PATCH v3 1/3] replay: refactor enum replay_mode into a bool
From: Toon Claes @ 2026-06-16  9:26 UTC (permalink / raw)
  To: git; +Cc: Toon Claes
In-Reply-To: <20260616-toon-git-replay-drop-merges-v3-0-153e9eb99ce1@iotcl.com>

In 2760ee4983 (replay: add --revert mode to reverse commit changes,
2026-03-26) the enum `replay_mode` was introduced. This has two possible
values:

 - The value `REPLAY_MODE_REVERT` is used when option `--revert` is
   passed to git-replay(1). When using this value the commits are
   processed in reverse order and the inverse of the changes are
   applied.

 - The value `REPLAY_MODE_PICK` is used when either option `--onto` or
   `--advance` is used. In both cases the commits are processed in
   normal order, and the changes are applied as-is.

Since there are only two possible values of this enum, simplify the code
by converting the enum into a bool. This avoids adding code paths that
check for invalid values of the enum, and shortens code where the value
is checked with a ternary operator.

Signed-off-by: Toon Claes <toon@iotcl.com>
---
 replay.c | 59 +++++++++++++++++++++++++----------------------------------
 1 file changed, 25 insertions(+), 34 deletions(-)

diff --git a/replay.c b/replay.c
index 4ef8abb607..1f8e5b083b 100644
--- a/replay.c
+++ b/replay.c
@@ -18,11 +18,6 @@
  */
 #define the_repository DO_NOT_USE_THE_REPOSITORY
 
-enum replay_mode {
-	REPLAY_MODE_PICK,
-	REPLAY_MODE_REVERT,
-};
-
 static const char *short_commit_name(struct repository *repo,
 				     struct commit *commit)
 {
@@ -81,7 +76,7 @@ static struct commit *create_commit(struct repository *repo,
 				    struct tree *tree,
 				    struct commit *based_on,
 				    struct commit *parent,
-				    enum replay_mode mode)
+				    bool reverse)
 {
 	struct object_id ret;
 	struct object *obj = NULL;
@@ -98,15 +93,13 @@ static struct commit *create_commit(struct repository *repo,
 
 	commit_list_insert(parent, &parents);
 	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	if (mode == REPLAY_MODE_REVERT) {
+	if (reverse) {
 		generate_revert_message(&msg, based_on, repo);
 		/* For revert, use current user as author (NULL = use default) */
-	} else if (mode == REPLAY_MODE_PICK) {
+	} else {
 		find_commit_subject(message, &orig_message);
 		strbuf_addstr(&msg, orig_message);
 		author = get_author(message);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	reset_ident_date();
 	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
@@ -269,7 +262,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  struct commit *onto,
 					  struct merge_options *merge_opt,
 					  struct merge_result *result,
-					  enum replay_mode mode,
+					  bool reverse,
 					  enum replay_empty_commit_action empty)
 {
 	struct commit *base, *replayed_base;
@@ -287,7 +280,21 @@ static struct commit *pick_regular_commit(struct repository *repo,
 	replayed_base_tree = repo_get_commit_tree(repo, replayed_base);
 	pickme_tree = repo_get_commit_tree(repo, pickme);
 
-	if (mode == REPLAY_MODE_PICK) {
+	if (reverse) {
+		/* Revert: swap base and pickme to reverse the diff */
+		const char *pickme_name = short_commit_name(repo, pickme);
+		merge_opt->branch1 = short_commit_name(repo, replayed_base);
+		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
+		merge_opt->ancestor = pickme_name;
+
+		merge_incore_nonrecursive(merge_opt,
+					  pickme_tree,
+					  replayed_base_tree,
+					  base_tree,
+					  result);
+
+		free((char *)merge_opt->branch2);
+	} else {
 		/* Cherry-pick: normal order */
 		merge_opt->branch1 = short_commit_name(repo, replayed_base);
 		merge_opt->branch2 = short_commit_name(repo, pickme);
@@ -303,22 +310,6 @@ static struct commit *pick_regular_commit(struct repository *repo,
 					  result);
 
 		free((char *)merge_opt->ancestor);
-	} else if (mode == REPLAY_MODE_REVERT) {
-		/* Revert: swap base and pickme to reverse the diff */
-		const char *pickme_name = short_commit_name(repo, pickme);
-		merge_opt->branch1 = short_commit_name(repo, replayed_base);
-		merge_opt->branch2 = xstrfmt("parent of %s", pickme_name);
-		merge_opt->ancestor = pickme_name;
-
-		merge_incore_nonrecursive(merge_opt,
-					  pickme_tree,
-					  replayed_base_tree,
-					  base_tree,
-					  result);
-
-		free((char *)merge_opt->branch2);
-	} else {
-		BUG("unexpected replay mode %d", mode);
 	}
 	merge_opt->ancestor = NULL;
 	merge_opt->branch2 = NULL;
@@ -341,7 +332,7 @@ static struct commit *pick_regular_commit(struct repository *repo,
 		}
 	}
 
-	return create_commit(repo, result->tree, pickme, replayed_base, mode);
+	return create_commit(repo, result->tree, pickme, replayed_base, reverse);
 }
 
 void replay_result_release(struct replay_result *result)
@@ -381,13 +372,13 @@ int replay_revisions(struct rev_info *revs,
 	char *revert;
 	const char *ref;
 	struct object_id old_oid;
-	enum replay_mode mode = REPLAY_MODE_PICK;
+	bool reverse;
 	int ret;
 
 	advance = xstrdup_or_null(opts->advance);
 	revert = xstrdup_or_null(opts->revert);
-	if (revert)
-		mode = REPLAY_MODE_REVERT;
+	reverse = !!revert;
+
 	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
 			   &detached_head, &advance, &revert, &onto, &update_refs);
 
@@ -430,8 +421,8 @@ int replay_revisions(struct rev_info *revs,
 			die(_("replaying merge commits is not supported yet!"));
 
 		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  mode == REPLAY_MODE_REVERT ? last_commit : onto,
-						  &merge_opt, &result, mode, opts->empty);
+						  reverse ? last_commit : onto,
+						  &merge_opt, &result, reverse, opts->empty);
 		if (!last_commit)
 			break;
 

-- 
2.53.0.1323.g189a785ab5


^ permalink raw reply related

* [PATCH v3 0/3] Teach git-replay(1) to linearize merge commits
From: Toon Claes @ 2026-06-16  9:26 UTC (permalink / raw)
  To: git; +Cc: Toon Claes, Johannes Schindelin, Johannes Schindelin
In-Reply-To: <20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com>

As an alternative to dscho's patch series to replay merges[1], add
option to git-replay(1) to linearize merges. This mimics what
git-rebase(1) does too with --no-rebase-merges (the default).

The first two patches do some refactoring. The third patch implements
the actual change. This patch was kindly provided by Dscho, which I've
tweaked to be upstreamed.

The --linearize option is only added to git-replay(1) and not to
git-history(1) because in my opinion it doesn't make much sense to do
so, but I'm happy to hear if anyone disagrees.

This series might conflict with Kristoffer's series to make
documentation changes[2], but should be trivial to resolve. And I don't
think there's a conflict with Patrick's series on adding "drop" to
git-history(1)[3].

dscho's series to replay merges[1] needs a bit of rework to fit on top
of this, but I'm happy to help figuring that out. We've been discussing
to either name the option --flatten or --linearize, but I've decided on
"linearize" because the documentation of git-rebase(1) also mentions
"linearize".

[1]: <pull.2106.git.1778107405.gitgitgadget@gmail.com>
[2]: <V2_CV_doc_replay_config.767@msgid.xyz>
[3]: <20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im>

Signed-off-by: Toon Claes <toon@iotcl.com>
---
Changes in v3:
- Add --linearize to Documentation SYNOPSIS, and mention it's
  incompatible with --revert.
- Small language change in help message for --linearize.
- Rephrase comment to include last_commit isn't modified when
  linearizing merges.
- Remove test that was added in earlier versions, but actually is
  a duplicate of 'replaying merge commits is not supported yet'.
- Add test to verify --revert and --linearize are incompatible.
- Properly test that replaying down to root with --linearize works.
- Add test for --linearize with --advance.
- Add test that uses git-range-diff(1) to verify the patches created by
  --linearize are correct.
- Link to v2: https://patch.msgid.link/20260610-toon-git-replay-drop-merges-v2-0-5714a71c6d83@iotcl.com

Changes in v2:
- Restructured the conditions to detect merge commits and added a line
  of comment why the loop continues.
- Rewrote tests to use the history from the setup step and added a few
  test cases.
- Re-added Johannes's Signed-off-by trailer. Johannes gave me the
  patches with this trailer, and if I understand correctly, I can keep
  it. Please let me know if that wrong.
- Link to v1: https://patch.msgid.link/20260608-toon-git-replay-drop-merges-v1-0-e3ee71fce7b4@iotcl.com

---
Johannes Schindelin (1):
      replay: offer an option to linearize the commit topology

Toon Claes (2):
      replay: refactor enum replay_mode into a bool
      replay: add helper to put entry into mapped_commits

 Documentation/git-replay.adoc |   8 ++-
 builtin/replay.c              |   6 ++-
 replay.c                      | 116 ++++++++++++++++++++++++------------------
 replay.h                      |   5 ++
 t/t3650-replay-basics.sh      |  68 ++++++++++++++++++++++++-
 5 files changed, 151 insertions(+), 52 deletions(-)

Range-diff versus v2:

1:  2075988ef1 = 1:  542b1c9267 replay: refactor enum replay_mode into a bool
2:  93ff03be65 = 2:  62f6df8375 replay: add helper to put entry into mapped_commits
3:  ef56010c96 ! 3:  768646ee24 replay: offer an option to linearize the commit topology
    @@ Commit message
         Signed-off-by: Toon Claes <toon@iotcl.com>
     
      ## Documentation/git-replay.adoc ##
    +@@ Documentation/git-replay.adoc: SYNOPSIS
    + --------
    + [verse]
    + (EXPERIMENTAL!) 'git replay' ([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)
    +-			     [--ref=<ref>] [--ref-action=<mode>] <revision-range>
    ++			     [--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>
    + 
    + DESCRIPTION
    + -----------
     @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modifier for `--onto` only).
      +
      The default mode can be configured via the `replay.refAction` configuration variable.
    @@ Documentation/git-replay.adoc: incompatible with `--contained` (which is a modif
     +	In this mode, `git replay` imitates `git rebase --no-rebase-merges`,
     +	i.e. it cherry-picks only non-merge commits, each one on top of the
     +	previous one.
    ++	This option is incompatible with `--revert`.
     +
      <revision-range>::
      	Range of commits to replay; see "Specifying Ranges" in
      	linkgit:git-rev-parse[1]. In `--advance=<branch>` or
     
      ## builtin/replay.c ##
    +@@ builtin/replay.c: int cmd_replay(int argc,
    + 	const char *const replay_usage[] = {
    + 		N_("(EXPERIMENTAL!) git replay "
    + 		   "([--contained] --onto=<newbase> | --advance=<branch> | --revert=<branch>)\n"
    +-		   "[--ref=<ref>] [--ref-action=<mode>] <revision-range>"),
    ++		   "[--ref=<ref>] [--ref-action=<mode>] [--linearize] <revision-range>"),
    + 		NULL
    + 	};
    + 	struct option replay_options[] = {
     @@ builtin/replay.c: int cmd_replay(int argc,
      			     N_("mode"),
      			     N_("control ref update behavior (update|print)"),
      			     PARSE_OPT_NONEG),
     +		OPT_BOOL(0, "linearize", &opts.linearize,
    -+			 N_("ignore merge commits instead of replaying them")),
    ++			 N_("drop merge commits, replaying only non-merge commits")),
      		OPT_END()
      	};
      
    @@ replay.c: int replay_revisions(struct rev_info *revs,
     +			if (!opts->linearize)
     +				die(_("replaying merge commits is not supported yet!"));
     +			/*
    -+			 * When linearizing, a merge commit itself is not picked,
    -+			 * but refs that point to it might need updating.
    ++			 * Drop the merge commit: do not pick it and leave
    ++			 * last_commit unchanged, so its children (and any ref
    ++			 * pointing at it) are reparented onto the previous
    ++			 * non-merge commit, which the ref-update loop below uses.
     +			 */
     +		} else {
     +			struct commit *to_pick = reverse ? last_commit : onto;
    @@ replay.h: struct replay_revisions_options {
      /* This struct is used as an out-parameter by `replay_revisions()`. */
     
      ## t/t3650-replay-basics.sh ##
    -@@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' '
    - 	test_grep "cannot be used with multiple revision ranges" err
    +@@ t/t3650-replay-basics.sh: test_expect_success 'setup' '
    + 	test_merge P O --no-ff &&
    + 	git switch main &&
    + 
    ++	git switch --orphan unrelated &&
    ++	test_commit unrelated-root &&
    ++
    + 	git switch -c conflict B &&
    +-	test_commit C.conflict C.t conflict
    ++	test_commit C.conflict C.t conflict &&
    ++	git branch -D unrelated
      '
      
    -+test_expect_success 'replay merge commit fails' '
    -+	echo "fatal: replaying merge commits is not supported yet!" >expect &&
    -+	test_must_fail git replay --ref-action=print --onto main I..P 2>actual &&
    -+	test_cmp expect actual
    + test_expect_success 'setup bare' '
    +@@ t/t3650-replay-basics.sh: test_expect_success '--advance and --contained cannot be used together' '
    + 	test_grep "cannot be used together" actual
    + '
    + 
    ++test_expect_success '--revert and --linearize cannot be used together' '
    ++	test_must_fail git replay --revert=main --linearize \
    ++		topic1..topic2 2>actual &&
    ++	test_grep "cannot be used together" actual
     +'
     +
    + test_expect_success 'cannot advance target ... ordering would be ill-defined' '
    + 	echo "fatal: ${SQ}--advance${SQ} cannot be used with multiple revision ranges because the ordering would be ill-defined" >expect &&
    + 	test_must_fail git replay --advance=main main topic1 topic2 2>actual &&
    +@@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multiple revision ranges' '
    + 	test_grep "cannot be used with multiple revision ranges" err
    + '
    + 
     +test_expect_success 'replay to rebase merge commit with --linearize' '
    -+	git replay --ref-action=print --linearize --onto main I..topic-with-merge >result &&
    ++	git replay --ref-action=print --linearize \
    ++		--onto main I..topic-with-merge >result &&
     +
     +	test_line_count = 1 result &&
     +
    @@ t/t3650-replay-basics.sh: test_expect_success '--onto with --ref rejects multipl
     +	test_cmp expect actual
     +'
     +
    -+test_expect_success 'replay to rebase merge commit with --linearize down to root commit' '
    -+	git replay --ref-action=print --linearize --onto main A..topic-with-merge >result &&
    ++test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' '
    ++	git replay --ref-action=print --linearize \
    ++		--onto unrelated-root topic-with-merge >result &&
     +
     +	test_line_count = 1 result &&
     +
     +	git log --format=%s $(cut -f 3 -d " " result) >actual &&
    -+	test_write_lines O N J I M L B A >expect &&
    ++	test_write_lines O N J I B A unrelated-root >expect &&
     +	test_cmp expect actual
     +'
    ++
    ++test_expect_success 'replay to cherry-pick merge commit with --linearize' '
    ++	git replay --ref-action=print --linearize \
    ++		--advance main I..topic-with-merge >result &&
    ++
    ++	test_line_count = 1 result &&
    ++
    ++	git log --format=%s $(cut -f 3 -d " " result) >actual &&
    ++	test_write_lines O N J M L B A >expect &&
    ++	test_cmp expect actual &&
    ++
    ++	printf "update refs/heads/main " >expect &&
    ++	printf "%s " $(cut -f 3 -d " " result) >>expect &&
    ++	git rev-parse main >>expect &&
    ++	test_cmp expect result
    ++'
    ++
    ++test_expect_success 'replay --linearize produces the same patches' '
    ++	git replay --ref-action=print --linearize \
    ++		--onto main I..topic-with-merge >result &&
    ++
    ++	test_line_count = 1 result &&
    ++	tip=$(cut -f 3 -d " " result) &&
    ++
    ++	# range-diff does not care about the dropped merge,
    ++	# so the original commits (I..topic-with-merge)
    ++	# and the replayed chain (main..tip) must produce identical patches.
    ++	git range-diff I..topic-with-merge main..$tip >out &&
    ++	test_file_not_empty out &&
    ++	! grep -v "=" out &&
    ++
    ++	git log --oneline main..$tip >out &&
    ++	test_line_count = 3 out
    ++'
     +
      test_done


---
base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
change-id: 20260604-toon-git-replay-drop-merges-807fa008d395


^ permalink raw reply

* [PATCH 4/4] builtin/refs: add "rename" subcommand
From: Patrick Steinhardt @ 2026-06-16  8:44 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano
In-Reply-To: <20260616-pks-refs-writing-subcommands-v1-0-9f5219b6109d@pks.im>

Add a "rename" subcommand to git-refs(1) with the syntax:

  $ git refs rename <oldref> <newref>

It renames <oldref> together with its reflog to <newref>; even when used
on a local branch ref, the current value and the reflog of the ref are
the only things that are renamed. Document it and redirect casual users
to "git branch -m" if that is what they wanted to do.

Co-authored-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-refs.adoc |  10 ++++
 builtin/refs.c              |  42 ++++++++++++++
 t/meson.build               |   1 +
 t/t1466-refs-rename.sh      | 131 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 184 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index 0a887cf5e5..85eb100205 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -22,6 +22,7 @@ git refs exists <ref>
 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
 git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
 git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
+git refs rename [--message=<reason>] <oldref> <newref>
 
 DESCRIPTION
 -----------
@@ -65,6 +66,11 @@ update::
 	`<old-value>` is given, the reference is only updated after verifying
 	that it currently contains `<old-value>`.
 
+rename::
+	Rename the reference `<oldref>` to `<newref>`. The old reference must
+	exist and the new reference must not yet exist, and both must have a
+	well-formed name (see linkgit:git-check-ref-format[1]).
+
 OPTIONS
 -------
 
@@ -106,6 +112,10 @@ include::pack-refs-options.adoc[]
 
 The following options are specific to commands which write references:
 
+`--create-reflog`::
+	Create a reflog for the reference even if one would not ordinarily be
+	created.
+
 `--message=<reason>`::
 	Use the given <reason> string for the reflog entry associated with the
 	update. An empty message is rejected.
diff --git a/builtin/refs.c b/builtin/refs.c
index 3238ddf3f0..b90baf5633 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -27,6 +27,9 @@
 #define REFS_UPDATE_USAGE \
 	N_("git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]")
 
+#define REFS_RENAME_USAGE \
+	N_("git refs rename [--message=<reason>] <oldref> <newref>")
+
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 			    struct repository *repo)
 {
@@ -267,6 +270,43 @@ static int cmd_refs_update(int argc, const char **argv, const char *prefix,
 			       UPDATE_REFS_DIE_ON_ERR);
 }
 
+static int cmd_refs_rename(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_rename_usage[] = {
+		REFS_RENAME_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_END(),
+	};
+	const char *oldref, *newref;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_rename_usage, 0);
+	if (argc != 2)
+		usage(_("rename requires old and new reference name"));
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	oldref = argv[0];
+	newref = argv[1];
+
+	if (check_refname_format(oldref, 0))
+		die(_("invalid ref format: %s"), oldref);
+	if (check_refname_format(newref, 0))
+		die(_("invalid ref format: %s"), newref);
+
+	if (!refs_ref_exists(get_main_ref_store(repo), oldref))
+		die(_("reference does not exist: '%s'"), oldref);
+	if (refs_ref_exists(get_main_ref_store(repo), newref))
+		die(_("reference already exists: '%s'"), newref);
+
+	return refs_rename_ref(get_main_ref_store(repo), oldref, newref, message);
+}
+
 int cmd_refs(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -280,6 +320,7 @@ int cmd_refs(int argc,
 		REFS_OPTIMIZE_USAGE,
 		REFS_DELETE_USAGE,
 		REFS_UPDATE_USAGE,
+		REFS_RENAME_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -291,6 +332,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
 		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
 		OPT_SUBCOMMAND("update", &fn, cmd_refs_update),
+		OPT_SUBCOMMAND("rename", &fn, cmd_refs_rename),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 2063962dab..a1a6880fe6 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -225,6 +225,7 @@ integration_tests = [
   't1463-refs-optimize.sh',
   't1464-refs-delete.sh',
   't1465-refs-update.sh',
+  't1466-refs-rename.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1466-refs-rename.sh b/t/t1466-refs-rename.sh
new file mode 100755
index 0000000000..f80d58e0f4
--- /dev/null
+++ b/t/t1466-refs-rename.sh
@@ -0,0 +1,131 @@
+#!/bin/sh
+
+test_description='git refs rename'
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_ref_matches () {
+	git rev-parse "$1" >expect &&
+	echo "$2" >actual &&
+	test_cmp expect actual
+}
+
+test_expect_success 'rename an existing reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		git refs rename refs/heads/foo refs/heads/bar &&
+		test_must_fail git refs exists refs/heads/foo &&
+		test_ref_matches refs/heads/bar $A
+	)
+'
+
+test_expect_success 'rename moves the reflog along with the reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update --message="rename me" refs/heads/foo $A &&
+		git refs rename refs/heads/foo refs/heads/bar &&
+		git reflog show refs/heads/bar >reflog &&
+		test_grep "rename me" reflog &&
+		test_must_fail git reflog exists refs/heads/foo
+	)
+'
+
+test_expect_success 'rename with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		git refs rename --message="rename reason" refs/heads/foo refs/heads/bar &&
+		git reflog show refs/heads/bar >actual &&
+		test_grep "rename reason" actual
+	)
+'
+
+test_expect_success 'rename a nonexistent reference fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs rename refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "reference does not exist" err
+	)
+'
+
+test_expect_success 'rename to an existing reference fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/bar $B &&
+		test_must_fail git refs rename refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "reference already exists" err
+	)
+'
+
+test_expect_success 'rename with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs rename --message= refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "empty message" err
+	)
+'
+
+test_expect_success 'rename with invalid old reference name fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs rename "refs/heads/foo..bar" refs/heads/bar 2>err &&
+		test_grep "invalid ref format" err
+	)
+'
+
+test_expect_success 'rename with invalid new reference name fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs rename refs/heads/foo "refs/heads/bar..baz" 2>err &&
+		test_grep "invalid ref format" err
+	)
+'
+
+test_expect_success 'rename with too few arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs rename refs/heads/foo 2>err &&
+	test_grep "requires old and new reference name" err
+'
+
+test_expect_success 'rename with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs rename refs/heads/foo refs/heads/bar refs/heads/baz 2>err &&
+	test_grep "requires old and new reference name" err
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related

* [PATCH 3/4] builtin/refs: add "update" subcommand
From: Patrick Steinhardt @ 2026-06-16  8:44 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano
In-Reply-To: <20260616-pks-refs-writing-subcommands-v1-0-9f5219b6109d@pks.im>

Add a new "update" subcommand which mirrors `git update-ref <refname>
<oldoid> <newoid>`. This follows the same reasoning as the preceding
commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-refs.adoc |   7 ++
 builtin/refs.c              |  50 +++++++++++++
 t/meson.build               |   1 +
 t/t1465-refs-update.sh      | 179 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 237 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index c03e8e6ac3..0a887cf5e5 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -21,6 +21,7 @@ git refs list [--count=<count>] [--shell|--perl|--python|--tcl]
 git refs exists <ref>
 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
 git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
+git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
 
 DESCRIPTION
 -----------
@@ -58,6 +59,12 @@ delete::
 	reference is only deleted after verifying that it currently contains
 	`<oldvalue>`.
 
+update::
+	Update the given reference to point at `<new-value>`. This subcommand
+	mirrors `git update-ref` (see linkgit:git-update-ref[1]). When
+	`<old-value>` is given, the reference is only updated after verifying
+	that it currently contains `<old-value>`.
+
 OPTIONS
 -------
 
diff --git a/builtin/refs.c b/builtin/refs.c
index 69eb528522..3238ddf3f0 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -24,6 +24,9 @@
 #define REFS_DELETE_USAGE \
 	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]")
 
+#define REFS_UPDATE_USAGE \
+	N_("git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]")
+
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 			    struct repository *repo)
 {
@@ -219,6 +222,51 @@ static int cmd_refs_delete(int argc, const char **argv, const char *prefix,
 			       argc == 2 ? &oldoid : NULL, flags);
 }
 
+static int cmd_refs_update(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_update_usage[] = {
+		REFS_UPDATE_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	unsigned flags = 0;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_BIT(0 ,"no-deref", &flags,
+			N_("update <refname> not the one it points to"),
+			REF_NO_DEREF),
+		OPT_BIT(0, "create-reflog", &flags, N_("create a reflog"),
+			REF_FORCE_CREATE_REFLOG),
+		OPT_END(),
+	};
+	struct object_id newoid, oldoid;
+	const char *refname;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_update_usage, 0);
+	if (argc < 2 || argc > 3)
+		usage(_("update requires reference name, new value and an optional old value"));
+
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	repo_config(repo, git_default_config, NULL);
+
+	refname = argv[0];
+	if (repo_get_oid_with_flags(repo, argv[1], &newoid,
+				    GET_OID_SKIP_AMBIGUITY_CHECK))
+		die(_("invalid new object ID: %s"), argv[1]);
+	if (argc == 3 &&
+	    repo_get_oid_with_flags(repo, argv[2], &oldoid,
+				    GET_OID_SKIP_AMBIGUITY_CHECK))
+		die(_("invalid old object ID: %s"), argv[2]);
+
+	return refs_update_ref(get_main_ref_store(repo), message, refname,
+			       &newoid, argc == 3 ? &oldoid : NULL, flags,
+			       UPDATE_REFS_DIE_ON_ERR);
+}
+
 int cmd_refs(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -231,6 +279,7 @@ int cmd_refs(int argc,
 		REFS_EXISTS_USAGE,
 		REFS_OPTIMIZE_USAGE,
 		REFS_DELETE_USAGE,
+		REFS_UPDATE_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -241,6 +290,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists),
 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
 		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
+		OPT_SUBCOMMAND("update", &fn, cmd_refs_update),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 1ccf08a3b5..2063962dab 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -224,6 +224,7 @@ integration_tests = [
   't1462-refs-exists.sh',
   't1463-refs-optimize.sh',
   't1464-refs-delete.sh',
+  't1465-refs-update.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1465-refs-update.sh b/t/t1465-refs-update.sh
new file mode 100755
index 0000000000..e7582a6195
--- /dev/null
+++ b/t/t1465-refs-update.sh
@@ -0,0 +1,179 @@
+#!/bin/sh
+
+test_description='git refs update'
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_ref_matches () {
+	git rev-parse "$1" >expect &&
+	echo "$2" >actual &&
+	test_cmp expect actual
+}
+
+test_expect_success 'update creates a new reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_ref_matches refs/heads/foo "$A"
+	)
+'
+
+test_expect_success 'update an existing reference without oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/foo $B &&
+		test_ref_matches refs/heads/foo $B
+	)
+'
+
+test_expect_success 'update with matching oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/foo $B $A &&
+		test_ref_matches refs/heads/foo $B
+	)
+'
+
+test_expect_success 'update with stale oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update refs/heads/foo $B $B 2>err &&
+		test_grep " but expected " err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update with invalid new value fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs update refs/heads/foo invalid-oid 2>err &&
+		test_grep "invalid new object ID" err &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'update with invalid old value fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update refs/heads/foo $B invalid-oid 2>err &&
+		test_grep "invalid old object ID" err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update --no-deref rewrites the symref itself' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git symbolic-ref refs/heads/symref refs/heads/foo &&
+		git refs update --no-deref refs/heads/symref $B &&
+		test_must_fail git symbolic-ref refs/heads/symref &&
+		test_ref_matches refs/heads/symref $B &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update does not create a reflog by default' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/foo $A &&
+		test_must_fail git reflog exists refs/foo
+	)
+'
+
+test_expect_success 'update creates a reflog with --create-reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update --create-reflog refs/foo $A &&
+		git reflog exists refs/foo
+	)
+'
+
+test_expect_success 'update with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update --message=update-reason refs/heads/foo $B &&
+		git reflog show refs/heads/foo >actual &&
+		test_grep "update-reason$" actual
+	)
+'
+
+test_expect_success 'update with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update --message= refs/heads/foo $B 2>err &&
+		test_grep "empty message" err
+	)
+'
+
+test_expect_success 'update with too few arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs update refs/heads/foo 2>err &&
+	test_grep "requires reference name, new value" err
+'
+
+test_expect_success 'update with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		test_must_fail git refs update refs/heads/foo $A $B extra 2>err &&
+		test_grep "requires reference name, new value" err
+	)
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ 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