* Re: [PATCH GSoC v14 06/13] fetch-pack: move function to connect.c
From: Pablo Sabater @ 2026-06-27 21:04 UTC (permalink / raw)
To: Chandra Pratap
Cc: git, chriscool, eric.peijian, gitster, jltobler, karthik.188,
peff, toon, Jonathan Tan, Calvin Wan
In-Reply-To: <CA+J6zkRm_F4MQ2K8Ayv8PGJOx+pNAg73+p-4VdOgkxeuKAkKew@mail.gmail.com>
El vie, 26 jun 2026 a las 14:14, Chandra Pratap
(<chandrapratap3519@gmail.com>) escribió:
>
> On Thu, 25 Jun 2026 at 17:43, Pablo Sabater <pabloosabaterr@gmail.com> wrote:
> >
> > write_fetch_command_and_capabilities will be refactored in a subsequent
>
> Nit: The paragraph below and the preceding patches refer to this function
> as `write_fetch_command_and_capabilities()`. It will be nice to maintain
> consistency throughout this series.
I'll do that.
>
> > commit where it will become a more general-purpose function, making it
> > more accessible to additional commands in the future.
> >
> > Move `write_fetch_command_and_capabilities()` to `connect.c`, where
> > there are similar purpose functions.
> >
> > Because string_list is only used as a pointer, use a forward
> > declaration [1].
> >
> > [1]: https://lore.kernel.org/git/Z0RIqUAoEob8lGfM@pks.im/
> >
> > Helped-by: Jonathan Tan <jonathantanmy@google.com>
> > Helped-by: Christian Couder <chriscool@tuxfamily.org>
> > Signed-off-by: Calvin Wan <calvinwan@google.com>
> > Signed-off-by: Eric Ju <eric.peijian@gmail.com>
> > Signed-off-by: Pablo Sabater <pabloosabaterr@gmail.com>
> > ---
> [snip]
Thanks for the feedback,
Pablo.
^ permalink raw reply
* Re: [PATCH GSoC v14 05/13] fetch-pack: prepare function to be moved
From: Pablo Sabater @ 2026-06-27 21:06 UTC (permalink / raw)
To: Karthik Nayak
Cc: git, chandrapratap3519, chriscool, eric.peijian, gitster,
jltobler, peff, toon, Jonathan Tan, Calvin Wan
In-Reply-To: <CAOLa=ZScS3Gmm5BAgJF69phpaDXGnP_j9jx+bMhn_tfF65RXEg@mail.gmail.com>
El vie, 26 jun 2026 a las 18:54, Karthik Nayak
(<karthik.188@gmail.com>) escribió:
>
> Pablo Sabater <pabloosabaterr@gmail.com> writes:
>
> The subject doesn't really give much insight into what the patch does.
> Perhaps something like:
>
> fetch-pack: use repo config in `write_fetch_command_and_capabilities()`
> fetch-pack: drop static variable use in
> `write_fetch_command_and_capabilities()`
>
> > `write_fetch_command_and_capabilities()` will be refactored and moved in
> > subsequent commits where it will become a more general-purpose function,
> > making it more accessible to additional commands in the future.
> >
> > To move `write_fetch_command_and_capabilities()` to `connect.c`, we
> > previously need to adjust how `advertise_sid` is managed. Currently in
>
> I don't think 'previously' makes sense here.
>
> > `fetch_pack.c`, `advertise_sid` is a static variable, modified using
> > `repo_config_get_bool()`.
> >
>
> Perhaps:
>
> To move `write_fetch_command_and_capabilities()` to `connect.c`,
> drop the usage of file static variable `advertise_sid` within the
> function. Currently, `advertise_sid` is modified...
>
> >
> > Initialize `advertise_sid` at the begining by directly using
> > `repo_config_get_bool()`. This change is safe because:
> >
> > In the original `fetch-pack.c` code, there are only two places that write
> > `advertise_sid`:
> >
>
> This needs to be modified no? This is from the prev patch, where we
> moved and refactored in the same patch, this no longer is the case.
>
> > 1. In function `do_fetch_pack()`:
> > if (!server_supports("session_id"))
> > advertise_sid = 0;
> > 2. In function `fetch_pack_config()`:
> > repo_config_get_bool("transfer.advertisesid", &advertise_sid);
> >
> > About 1, since `do_fetch_pack()` is only relevant for protocol v1, this
> > assignment can be ignored, as `write_fetch_command_and_capabilities()`
> > is only used in v2.
> >
> > About 2, `repo_config_get_bool()` is from `config.h` and it's an
> > out-of-box dependency of `connect.c`, so we can reuse it directly.
> >
> > Helped-by: Jonathan Tan <jonathantanmy@google.com>
> > Helped-by: Christian Couder <chriscool@tuxfamily.org>
> > Signed-off-by: Calvin Wan <calvinwan@google.com>
> > Signed-off-by: Eric Ju <eric.peijian@gmail.com>
> > Signed-off-by: Pablo Sabater <pabloosabaterr@gmail.com>
> > ---
> > fetch-pack.c | 5 ++++-
> > 1 file changed, 4 insertions(+), 1 deletion(-)
> >
> > diff --git a/fetch-pack.c b/fetch-pack.c
> > index f13951d154..ad07603755 100644
> > --- a/fetch-pack.c
> > +++ b/fetch-pack.c
> > @@ -1380,6 +1380,9 @@ static void write_fetch_command_and_capabilities(struct strbuf *req_buf,
> > const struct string_list *server_options)
> > {
> > const char *hash_name;
> > + int advertise_sid;
> > +
> > + repo_config_get_bool(the_repository, "transfer.advertisesid", &advertise_sid);
> >
> > ensure_server_supports_v2("fetch");
> > packet_buf_write(req_buf, "command=fetch");
> > @@ -1395,7 +1398,7 @@ static void write_fetch_command_and_capabilities(struct strbuf *req_buf,
> > }
> >
> > if (server_feature_v2("object-format", &hash_name)) {
> > - int hash_algo = hash_algo_by_name(hash_name);
> > + const unsigned int hash_algo = hash_algo_by_name(hash_name);
> >
>
> Agreed with Chandra, this needs to be assessed.
>
> > if (hash_algo_by_ptr(the_hash_algo) != hash_algo)
> > die(_("mismatched algorithms: client %s; server %s"),
> > the_hash_algo->name, hash_name);
> >
> > --
> > 2.54.0
I'll reword the commit message with all you and Chandra reviewed.
Thanks for the feedback,
Pablo.
^ permalink raw reply
* Re: Security Vulnerability in Git 2.54.0/OpenSSL 3.5.6 Status
From: Todd Zullinger @ 2026-06-27 21:07 UTC (permalink / raw)
To: Person, Tim; +Cc: git@vger.kernel.org
In-Reply-To: <SN4P221MB0713994458A94BFCB51F7AC494EA2@SN4P221MB0713.NAMP221.PROD.OUTLOOK.COM>
Hi,
Person, Tim wrote:
> I am writing to determine when Git plans to release an
> update installer to patch the security vulnerability in
> Git 2.54.0 because of the included OpenSSL executable.
> This vulnerability is rated "Critical" in the CVE
> (https://www.cve.org/CVERecord?id=CVE-2026-34182). An
> updated version of the OpenSSL.exe fixing this problem has
> been available since 06/12/2026. I am just wondering
> if/when you plan to address this major security issue.
The Git project does not distribute any binaries. You
likely want to direct this to the Git for Windows project¹.
That said, it's not even clear to me that the CVE you
reference affects git's usage of OpenSSL.
From a little skimming, the issue affects use of CMS (which
is something like the successor to S/MIME, as far as I can
tell).
The only place where git gets close to that area is if you
configure it to use x509 as gpg.program. And then git uses
gpgsm, which is not affected by the CVE in OpenSSL.
¹ https://gitforwindows.org/
--
Todd
^ permalink raw reply
* RE: Security Vulnerability in Git 2.54.0/OpenSSL 3.5.6 Status
From: Person, Tim @ 2026-06-27 21:17 UTC (permalink / raw)
To: Todd Zullinger; +Cc: git@vger.kernel.org
In-Reply-To: <20260627210718.zl0eH_Sc@teonanacatl.net>
Todd,
Thank you for the reply, the explanation, and the information about who to contact.
Thanks,
Tim
-----Original Message-----
From: Todd Zullinger <tmz@pobox.com>
Sent: Saturday, June 27, 2026 2:07 PM
To: Person, Tim <Tim.Person@personent.com>
Cc: git@vger.kernel.org
Subject: Re: Security Vulnerability in Git 2.54.0/OpenSSL 3.5.6 Status
[You don't often get email from tmz@pobox.com. Learn why this is important at https://aka.ms/LearnAboutSenderIdentification ]
[CAUTION: This email originated from outside of the organization. Do not click links or open attachments unless you recognize the sender and know the content is safe.]
Hi,
Person, Tim wrote:
> I am writing to determine when Git plans to release an update
> installer to patch the security vulnerability in Git 2.54.0 because of
> the included OpenSSL executable.
> This vulnerability is rated "Critical" in the CVE
> (https://www/
> .cve.org%2FCVERecord%3Fid%3DCVE-2026-34182&data=05%7C02%7CTim.Person%4
> 0personentcloud.mail.onmicrosoft.com%7C350b58458bd84a5312f308ded490243
> 8%7Ce2de18dc8323462e8c47561025ebc66c%7C0%7C0%7C639181913006654964%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C40000%7C%7C%7C&sdata=caMSGrA%2FfpxkKs2o%2Bg1dE9JuEQQlOK3IBt8BzbZ%2F7GM%3D&reserved=0). An updated version of the OpenSSL.exe fixing this problem has been available since 06/12/2026. I am just wondering if/when you plan to address this major security issue.
The Git project does not distribute any binaries. You likely want to direct this to the Git for Windows project¹.
That said, it's not even clear to me that the CVE you reference affects git's usage of OpenSSL.
From a little skimming, the issue affects use of CMS (which is something like the successor to S/MIME, as far as I can tell).
The only place where git gets close to that area is if you configure it to use x509 as gpg.program. And then git uses gpgsm, which is not affected by the CVE in OpenSSL.
¹ https://gitforwindows.org/
--
Todd
^ permalink raw reply
* Re: 2.54.0: fyi: endless loop at 100% CPU
From: Michael Montalbo @ 2026-06-27 23:03 UTC (permalink / raw)
To: Michael Montalbo, git, Steffen Nurpmeso
In-Reply-To: <20260627201558.Bw6A-jbx@steffen%sdaoden.eu>
On Sat, Jun 27, 2026 at 1:16 PM Steffen Nurpmeso <steffen@sdaoden.eu> wrote:
>
> Thanks for these pointers, i did not know about such configuration
> variables. I will set them like you show.
No problem! Just to clarify, I'm not sure you should actually use those
configuration values verbatim. I was more pointing in the direction of
potentially relevant options for debugging / working around the issue.
^ permalink raw reply
* Re: [PATCH v2 5/6] t: convert grep assertions to test_grep
From: Junio C Hamano @ 2026-06-28 1:41 UTC (permalink / raw)
To: SZEDER Gábor
Cc: Michael Montalbo via GitGitGadget, git, D. Ben Knoble,
Eric Sunshine, Michael Montalbo
In-Reply-To: <xmqq4iio59uv.fsf@gitster.g>
Junio C Hamano <gitster@pobox.com> writes:
> SZEDER Gábor <szeder.dev@gmail.com> writes:
>
>> I think in this case checking the file3's contents is wrong, because
>> at this point file3 should not exist in the first place. I've sent a
>> patch to fix this long ago, but apparently didn't manage to follow
>> through back then.
>>
>> https://lore.kernel.org/git/20211010172809.1472914-1-szeder.dev@gmail.com/
>
> Thanks. I guess the test_grep can be extended to catch this case,
> where
>
> test_grep ! -e pattern1 -e pattern2 file
>
> does not find any hits, but only because 'file' is missing, as an
> error, ...
Wait. The necessary check is already there, isn't it?
test_grep () {
eval "last_arg=\${$#}"
test -f "$last_arg" ||
BUG "test_grep requires a file to read as the last parameter"
So why don't we see it every time we run that test that inspects
file3's contents with Michael's series merged in? Puzzled...
^ permalink raw reply
* Re: [PATCH v3 0/2] Silence po catalog output under "make -s"
From: Junio C Hamano @ 2026-06-28 1:43 UTC (permalink / raw)
To: Johannes Sixt; +Cc: Harald Nordgren, git, Harald Nordgren via GitGitGadget
In-Reply-To: <40b7eee4-6b45-449f-a3a0-0ae415097041@kdbg.org>
Johannes Sixt <j6t@kdbg.org> writes:
> Am 26.06.26 um 21:27 schrieb Harald Nordgren:
>> What should I expect here, will it be merged to master now?
>
> These patches are cooking in my respective j6t-testing branches in my
> repositories[1][2]. I'll ask for inclusion in the Git repository in the
> coming weeks (but certainly not for v2.55).
>
> -- Hannes
>
> [1] https://github.com/j6t/git-gui/commits/j6t-testing/
> [2] https://github.com/j6t/gitk/commits/j6t-testing/
Thanks.
^ permalink raw reply
* Re: [PATCH] t3420-rebase-autostash: don't try to grep non-existing files
From: Junio C Hamano @ 2026-06-28 1:45 UTC (permalink / raw)
To: SZEDER Gábor; +Cc: git, Michael Montalbo, Denton Liu
In-Reply-To: <aj90x3DsER5HASUS@szeder.dev>
SZEDER Gábor <szeder.dev@gmail.com> writes:
> On Sun, Oct 10, 2021 at 07:28:09PM +0200, SZEDER Gábor wrote:
>> Several tests in 't3420-rebase-autostash.sh' start various rebase
>> processes that are expected to fail because of merge conflicts. The
>> tests [1] checking that 'git rebase --quit' and autostash work
>> together as expected after such a failure then run '! grep ...' to
>> ensure that the dirty contents of the file is gone. However, due to
>> the test repo's history and the choice of upstream branch that file
>> shouldn't exist in the conflicted state at all, and thus it shouldn't
>> exist after the subsequent 'git rebase --quit' either. Consequently,
>> this 'grep' doesn't fail as expected, i.e. because it can't find the
>> dirty content, but instead it fails, because it can't open the file.
>>
>> Thighten this check by using 'test_path_is_missing' instead, thereby
>> avoiding unexpected errors from 'grep' as well.
>>
>> Previously 2745817028 (t3420-rebase-autostash: don't try to grep
>> non-existing files, 2018-08-22) fixed a couple of similar issues; this
>> one was added later in 9b2df3e8d0 (rebase: save autostash entry into
>> stash reflog on --quit, 2020-04-28).
>>
>> [1] This patch modifies only a single test, but that test is run
>> several times with different strategies ('--apply', '--merge', and
>> '--interactive'), hence the plural "tests".
>>
>> Signed-off-by: SZEDER Gábor <szeder.dev@gmail.com>
>> ---
>> t/t3420-rebase-autostash.sh | 2 +-
>> 1 file changed, 1 insertion(+), 1 deletion(-)
>>
>> diff --git a/t/t3420-rebase-autostash.sh b/t/t3420-rebase-autostash.sh
>> index 43fcb68f27..bbe82d2c0c 100755
>> --- a/t/t3420-rebase-autostash.sh
>> +++ b/t/t3420-rebase-autostash.sh
>> @@ -200,7 +200,7 @@ testrebase () {
>> git rebase --quit &&
>> test_when_finished git stash drop &&
>> test_path_is_missing $dotest/autostash &&
>> - ! grep dirty file3 &&
>> + test_path_is_missing file3 &&
>> git stash show -p >actual &&
>> test_cmp expect actual &&
>> git reset --hard &&
>> --
>> 2.33.0.1279.g1a260bf8c2
>
> It appears that this patch might have fallen quite deep through the
> cracks... ;)
Yeah, that indeed seems to be the case. It is surprising that
nobody even had any comment on it back then.
> But the issue this patch is addressing is still there, and the patch
> still applies cleanly after almost 5 years.
Will take a look and queue. Thanks.
^ permalink raw reply
* Re: [PATCH] http: accept https:// proxies again
From: Junio C Hamano @ 2026-06-28 1:54 UTC (permalink / raw)
To: Johannes Schindelin via GitGitGadget; +Cc: git, Aliwoto, Johannes Schindelin
In-Reply-To: <pull.2161.git.1782580676734.gitgitgadget@gmail.com>
"Johannes Schindelin via GitGitGadget" <gitgitgadget@gmail.com>
writes:
> http.c | 2 ++
> 1 file changed, 2 insertions(+)
>
> diff --git a/http.c b/http.c
> index 8e5a4d8bcf..8c0f831365 100644
> --- a/http.c
> +++ b/http.c
> @@ -802,6 +802,8 @@ static int set_curl_proxy_type(CURL *result, const char *protocol)
> if (has_proxy_cert_password())
> curl_easy_setopt(result, CURLOPT_PROXY_KEYPASSWD,
> proxy_cert_auth.password);
> +
> + return 0;
> }
>
> return -1;
That lack of "return 0" is so glaringly obvious when you point it
out like this patch does, and it is surprising it has been missed
initially.
From this function nothing returns an error anymore, and looking at
the preimage of 663d7abe (http: reject unsupported proxy URL
schemes, 2026-05-05) that is the source of the bug, the original did
not do anything when the corresponding code did not find and set any
proxy settings, either.
So perhaps it is a better fix to make it just a function that
returns void with early returns?
Thanks.
^ permalink raw reply
* Re: [PATCH v2 5/6] t: convert grep assertions to test_grep
From: Junio C Hamano @ 2026-06-28 2:03 UTC (permalink / raw)
To: SZEDER Gábor
Cc: Michael Montalbo via GitGitGadget, git, D. Ben Knoble,
Eric Sunshine, Michael Montalbo
In-Reply-To: <xmqqldbz4f1a.fsf@gitster.g>
Junio C Hamano <gitster@pobox.com> writes:
> Junio C Hamano <gitster@pobox.com> writes:
>
>> SZEDER Gábor <szeder.dev@gmail.com> writes:
>>
>>> I think in this case checking the file3's contents is wrong, because
>>> at this point file3 should not exist in the first place. I've sent a
>>> patch to fix this long ago, but apparently didn't manage to follow
>>> through back then.
>>>
>>> https://lore.kernel.org/git/20211010172809.1472914-1-szeder.dev@gmail.com/
>>
>> Thanks. I guess the test_grep can be extended to catch this case,
>> where
>>
>> test_grep ! -e pattern1 -e pattern2 file
>>
>> does not find any hits, but only because 'file' is missing, as an
>> error, ...
>
> Wait. The necessary check is already there, isn't it?
>
> test_grep () {
> eval "last_arg=\${$#}"
>
> test -f "$last_arg" ||
> BUG "test_grep requires a file to read as the last parameter"
>
> So why don't we see it every time we run that test that inspects
> file3's contents with Michael's series merged in? Puzzled...
Ah, of course. Michael sidesteps this mechanism by not using
"test_grep !", with
! grep dirty file3 && # lint-ok: file may not exist after --quit
and if we realize that "may not exist" is actually "never exists",
then your other patch from 5 years ago would become the most
sensible fix for this line.
It may not be a bad idea to go through "# lint-ok:" introduced by
Michael's series with finer toothed comb (there are only a handful
of them) and see if there are similar "look, the file we are
grepping in never exists with correctly running Git" gotchas.
Thanks.
^ permalink raw reply
* Re: [PATCH v4 1/1] environment: move excludes_file into repo_config_values
From: Tian Yuchen @ 2026-06-28 3:19 UTC (permalink / raw)
To: Junio C Hamano
Cc: git, cirnovskyv, szeder.dev, Christian Couder, Ayush Chandekar,
Olamide Caleb Bello
In-Reply-To: <xmqqv7b34snt.fsf@gitster.g>
On 6/28/26 04:47, Junio C Hamano wrote:
> Tian Yuchen <cat@malon.dev> writes:
>
>> Hi all,
>>
>> Apologies again for the duplicate...
>>
>> On 6/28/26 00:08, Tian Yuchen wrote:
>>
>>> +const char *repo_excludes_file(struct repository *repo)
>>> +{
>>> + if (!repo || !repo->initialized)
>>> + return NULL;
>
> I might already have said this, but I am not sure why want to be as
> loose as this code. It is not limited to this line, but I think we
> saw plenty of other "We know we must get an already initialized
> thing here, and the subsequent operation we perform on that thing
> will cause us to die() later, so let's return silently and early
> to avoid hitting die()" attempts to sweep problems under the rug.
>
> Wouldn't we rather want to try to be more strict and say
>
> if (!repo || !repo->initialized)
> BUG("repo must be an initialied repository");
>
> here? Aren't all the callers of this function supposed to be
> dealing with an already initialized repository?
>
That makes sense, but from my point of view...
'repo_config_values()' already has a check for 'repo->initialized'. If
we're absolutely certain that the 'repo' is initialized, wouldn't it be
better to simply remove all the checks inside the getter and leave the
judgment to 'repo_config_values()'?
This also aligns to some extent with the previous flag getters: since an
uninitialized 'repo' will trigger a BUG() in 'repo_config_values()', but
"reading and writing these flags when the 'repo' is uninitialized" is
sometimes a valid operation that's why we choose to intercept
'repo->initialized' _before_ 'repo_config_values()' and fallback to the
hard-coded values.
This gives the impression that _'repo_config_values()' is the function
responsible for checking_, and the way flags are handled is an exception
to this approach, which I think is more consistent and self-explanatory.
What do you think?
>
>>> + if (!repo_config_values(repo)->excludes_file)
>>> + repo_config_values(repo)->excludes_file = xdg_config_home("ignore");
>>> +
>>> + return repo_config_values(repo)->excludes_file;
>>> +}
>>
>> One more thing:
>>
>> I deliberately didn't write a comment for the getter because it will
>> probably be merged with comments from the previous several patches in
>> some form in the near future... I'm not sure if it would be more
>> appropriate to write a separate patch to add the corresponding comments
>> then.
>
> That's very sensible.
>
Thanks.
regards, yuchen
^ permalink raw reply
* Re: [PATCH v4 1/1] environment: move excludes_file into repo_config_values
From: Tian Yuchen @ 2026-06-28 3:38 UTC (permalink / raw)
To: Junio C Hamano
Cc: git, cirnovskyv, szeder.dev, Christian Couder, Ayush Chandekar,
Olamide Caleb Bello
In-Reply-To: <eabb8169-2c13-4961-9b21-f44b1fa66f70@malon.dev>
On 6/28/26 11:19, Tian Yuchen wrote:
> Let it be noticed by repo_config_values() function to
> catch offending callers for now, and once the codebase becomes ready
> to use one repo_config_values per repository, this function does not
> have to change.
And
> Wouldn't we rather want to try to be more strict and say
>
> if (!repo || !repo->initialized)
> BUG("repo must be an initialied repository");
>
> here? Aren't all the callers of this function supposed to be
> dealing with an already initialized repository?
In my opinion, these two suggestions are not entirely consistent, and I
think we need to determine the most appropriate approach.
Regards, yuchen
^ permalink raw reply
* Re: [PATCH] http: accept https:// proxies again
From: Junio C Hamano @ 2026-06-28 5:10 UTC (permalink / raw)
To: Johannes Schindelin via GitGitGadget; +Cc: git, Aliwoto, Johannes Schindelin
In-Reply-To: <xmqq8q7z4eg3.fsf@gitster.g>
Junio C Hamano <gitster@pobox.com> writes:
> From this function nothing returns an error anymore, and looking at
> the preimage of 663d7abe (http: reject unsupported proxy URL
> schemes, 2026-05-05) that is the source of the bug, the original did
> not do anything when the corresponding code did not find and set any
> proxy settings, either.
>
> So perhaps it is a better fix to make it just a function that
> returns void with early returns?
Nah, I was being stupid. Disregard the above.
The whole point of 663d7abe was that we wanted to reject what we did
not recognise, and we cannot do so without returning "good/bad" from
that function. The bug was that we did recognise https:// but still
returned -1 because of the bug, which the patch in the thread fixed.
Thanks.
^ permalink raw reply
* Re: [PATCH v3 1/2] branch: suggest <remote>/<branch> on upstream slip
From: Junio C Hamano @ 2026-06-28 7:00 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
In-Reply-To: <9883c28482be4ad43f0f999c2e6be9f9dd9fb13b.1782583345.git.gitgitgadget@gmail.com>
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1572a4f9ef..dede60d27b 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -706,6 +706,29 @@ static int edit_branch_description(const char *branch_name)
> return 0;
> }
>
> +static void die_if_upstream_looks_like_remote(const char *new_upstream, const char *branch_name)
> +{
> + struct strbuf remote_ref = STRBUF_INIT;
> + int code;
> +
> + if (strchr(new_upstream, '/') ||
> + !remote_is_configured(remote_get(new_upstream), 0))
> + return;
> +
> + strbuf_addf(&remote_ref, "refs/remotes/%s/%s", new_upstream, branch_name);
> + if (!refs_ref_exists(get_main_ref_store(the_repository), remote_ref.buf)) {
> + strbuf_release(&remote_ref);
> + return;
> + }
> +
> + code = die_message(_("--set-upstream-to takes a single <remote>/<branch> argument"));
> + advise_if_enabled(ADVICE_SET_UPSTREAM_FAILURE,
> + _("Did you mean to use: git branch --set-upstream-to=%s/%s?"),
> + new_upstream, branch_name);
> + strbuf_release(&remote_ref);
> + exit(code);
> +}
> +
> int cmd_branch(int argc,
> const char **argv,
> const char *prefix,
> @@ -957,6 +980,15 @@ int cmd_branch(int argc,
> if (!refs_ref_exists(get_main_ref_store(the_repository), branch->refname)) {
> if (!argc || branch_checked_out(branch->refname))
> die(_("no commit on branch '%s' yet"), branch->name);
> + /*
> + * Check the advice up front to avoid the ref
> + * lookups when the hint is off. The helper still
> + * calls advise_if_enabled() so the hint carries the
> + * standard "disable this message" instructions.
> + */
> + if (argc == 1 &&
> + advice_enabled(ADVICE_SET_UPSTREAM_FAILURE))
> + die_if_upstream_looks_like_remote(new_upstream, argv[0]);
> die(_("branch '%s' does not exist"), branch->name);
> }
Hmph, something like adding a single liner in the caller, like this. ...
code = die_message(_("--set-upstream-to takes a single <remote>/<branch> argument"));
+ /* use _if_enabled here to show the hint on how to disable */
advise_if_enabled(ADVICE_SET_UPSTREAM_FAILURE,
_("Did you mean to use: git branch --set-upstream-to=%s/%s?"),
new_upstream, branch_name);
strbuf_release(&remote_ref);
exit(code);
... was what I meant, because the most puzzling piece is that the
function calls _if_enabled form there, when the caller is presumably
already checked _enabled() and leaves the reader wondering if there
are other callers of this function that does not check before
calling it.
But this is so tiny a thing that once the code is written, it is
probably not worth the churn to redo it. Let's declare victory and
mark the topic ready for 'next'?
^ permalink raw reply
* Re: [PATCH v3 1/2] branch: suggest <remote>/<branch> on upstream slip
From: Harald Nordgren @ 2026-06-28 7:21 UTC (permalink / raw)
To: Junio C Hamano; +Cc: Harald Nordgren via GitGitGadget, git
In-Reply-To: <xmqqfr272lq7.fsf@gitster.g>
Let's do it!
Harald
^ permalink raw reply
* [PATCH 0/3] fixing expensive http test timeouts
From: Jeff King @ 2026-06-28 7:57 UTC (permalink / raw)
To: Michael Montalbo; +Cc: Patrick Steinhardt, git, Junio C Hamano
In-Reply-To: <CAC2QwmLkHUymvtYbjY8aQO9_VogvaSXdbb1_DSZtcBttGfN0tg@mail.gmail.com>
On Fri, Jun 26, 2026 at 04:26:28PM -0700, Michael Montalbo wrote:
> I think Peff and Patrick's suggestion to just increase the Apache timeout
> makes sense. I ran some experiments using a really long timeout with an
> artificially slowed down CI runner and all the jobs made progress
> (if slowly) without stalling, and eventually completed successfully:
>
> https://github.com/mmontalbo/git/actions/runs/28267019651
>
> I haven't spent a lot of time trying to figure out what the right timeout
> value should be. An hour definitely seems like overkill, with something
> on the order of 5-10 minutes seeming more reasonable, but I don't
> have a principled number.
Here are some patches to keep things moving along. I arbitrarily picked
10 minutes, because multiplying the 1-minute default by 10 felt right. ;)
The first one just bumps the timeout and should make our problems go
away. The other two are optimizations, but I'm on the fence on whether
the final patch is worth it.
Thanks again for all of the digging.
[1/3]: t/lib-httpd: bump apache timeout
[2/3]: t5551: put many-tags case into its own repo
[3/3]: t5551: pack refs after creating many tags
t/lib-httpd/apache.conf | 1 +
t/t5551-http-fetch-smart.sh | 10 ++++++----
2 files changed, 7 insertions(+), 4 deletions(-)
-Peff
^ permalink raw reply
* [PATCH 1/3] t/lib-httpd: bump apache timeout
From: Jeff King @ 2026-06-28 8:00 UTC (permalink / raw)
To: Michael Montalbo; +Cc: Patrick Steinhardt, git, Junio C Hamano
In-Reply-To: <20260628075716.GA3525066@coredump.intra.peff.net>
Since enabling more tests with 7a094d68a2 (ci: run expensive tests on
push builds to integration branches, 2026-05-08), we sometimes see test
failures or timeouts in GitHub CI. The culprit seems to be the "enormous
ref negotiation" test in t5551, which creates ~100k tag refs in our http
server-side repo.
Iterating through the loose refs of this repo to generate a ref
advertisement can take a long time, especially on a platform with slow
I/O. On my otherwise unloaded local machine, a cold cache ref
advertisement takes ~10s. On a busy CI machine running tests in
parallel, it can presumably top 60s, which runs afoul of Apache's
default CGI timeout.
The result in t5551 is a test failure, where Apache simply hangs up the
connection and the client reports an error. But worse, t5559 runs the
same test with HTTP/2, and a bug in Apache causes the connection to hang
indefinitely! We eventually see this as a CI timeout after 6 hours.
Let's bump Apache's timeout to something much larger: 600 seconds. This
doesn't eliminate the possibility of a timeout, but it makes it much
less likely. It should eliminate both the test failures and the CI
timeouts in practice, and it protects us from running into similar
problems with other tests in the future.
There are two counter-arguments to consider.
One, could/should we just make the test faster? Probably yes. The
biggest mistake here is having such an absurd number of unpacked refs on
a system which is bottle-necked on I/O. But I think it's worth bumping
the timeout so that we can fix this (and possibly other) correctness
issues, and then consider performance separately (which we'll do in
subsequent patches).
And two, is this just papering over a problem that users might see in
the real world? We could teach Git to handle this case more gracefully
with optimizations or keep-alives. But I think it's really an artificial
situation. You need a combination of this silly number of loose refs,
plus a very heavily loaded system. If you were trying to run a real
server and it took more than 60s to generate the ref advertisement, I
don't think the timeout is your biggest problem. Your crappy service is,
and you should adjust your resources to match your load. I.e., it is
probably reasonable for Git to assume that advertisements happen
fast-ish and don't need protocol-level keepalives.
Though the patch here is small, tons of work went into analyzing the
problem. Many thanks to the contributors credited below.
Helped-by: Michael Montalbo <mmontalbo@gmail.com>
Helped-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Jeff King <peff@peff.net>
---
I didn't reference Michael's bugzilla report directly, because you can't
read it without a login. :(
Maybe it's worth doing anyway?
t/lib-httpd/apache.conf | 1 +
1 file changed, 1 insertion(+)
diff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf
index 40a690b0bb..4149fc1078 100644
--- a/t/lib-httpd/apache.conf
+++ b/t/lib-httpd/apache.conf
@@ -4,6 +4,7 @@ DocumentRoot www
LogFormat "%h %l %u %t \"%r\" %>s %b" common
CustomLog access.log common
ErrorLog error.log
+Timeout 600
<IfModule !mod_log_config.c>
LoadModule log_config_module modules/mod_log_config.so
</IfModule>
--
2.55.0.rc2.353.gf769b6597e
^ permalink raw reply related
* [PATCH 2/3] t5551: put many-tags case into its own repo
From: Jeff King @ 2026-06-28 8:03 UTC (permalink / raw)
To: Michael Montalbo; +Cc: Patrick Steinhardt, git, Junio C Hamano
In-Reply-To: <20260628075716.GA3525066@coredump.intra.peff.net>
Most of the t5551 http fetch tests use a handful of refs. But there are
a few test cases which check our handling of large numbers of refs.
These tests use the same server-side repo, so all subsequent tests end
up having to consider those extra refs, too.
The result is that the test script is a bit slower than it needs to be.
In a normal run, moving the "2,000 tags" test into its own repo drops my
runtime for the whole script from ~2.7s to ~1.9s.
This is a modest gain, but when we add the "--long" flag it gets much
bigger. There we trigger a test (marked with EXPENSIVE) that adds
100,000 tags, and the script runtime jumps to ~95s. But if we use the
same "many tags" repo for that, our runtime drops to just ~37s.
This is a pretty easy win to drop the cost of the script. It may even be
a larger gain on a heavily loaded system, since one of the main costs
here is unpacked refs, which are heavy on system time and I/O costs.
It's possible we are reducing test coverage, since all of those other
tests were inadvertently using large ref advertisements (and thus could
have uncovered some unexpected interaction). But that seems somewhat
unlikely; the tests targeted at the large number of refs are doing
roughly similar things to the other tests.
Note that the real performance culprit is the 100k-tag --long test, not
the 2k-tag one. So we could just let the 100k one use its own repo, and
keep the 2k tags in the main repo. But since these two tests are
somewhat interlinked, it's easier to just move them both (and it does
provide a small gain even for the 2000-tag test). I also notice that the
2000-tag test is gated on the CMDLINE_LIMIT prereq, and without that the
later EXPENSIVE test will fail (since we won't have a too-many-refs
clone). Nobody seems to have noticed or complained after many years, and
I left it alone for this patch.
Signed-off-by: Jeff King <peff@peff.net>
---
t/t5551-http-fetch-smart.sh | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/t/t5551-http-fetch-smart.sh b/t/t5551-http-fetch-smart.sh
index e236e526f0..cd851f24b8 100755
--- a/t/t5551-http-fetch-smart.sh
+++ b/t/t5551-http-fetch-smart.sh
@@ -397,15 +397,16 @@ create_tags () {
}
test_expect_success 'create 2,000 tags in the repo' '
+ git init "$HTTPD_DOCUMENT_ROOT_PATH/many-tags.git" &&
(
- cd "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
+ cd "$HTTPD_DOCUMENT_ROOT_PATH/many-tags.git" &&
create_tags 1 2000
)
'
test_expect_success CMDLINE_LIMIT \
'clone the 2,000 tag repo to check OS command line overflow' '
- run_with_limited_cmdline git clone $HTTPD_URL/smart/repo.git too-many-refs &&
+ run_with_limited_cmdline git clone $HTTPD_URL/smart/many-tags.git too-many-refs &&
(
cd too-many-refs &&
git for-each-ref refs/tags >actual &&
@@ -483,12 +484,12 @@ test_expect_success 'test allowanysha1inwant with unreachable' '
test_expect_success EXPENSIVE 'http can handle enormous ref negotiation' '
test_when_finished "rm -f tags" &&
(
- cd "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
+ cd "$HTTPD_DOCUMENT_ROOT_PATH/many-tags.git" &&
create_tags 2001 50000
) &&
git -C too-many-refs fetch -q --tags &&
(
- cd "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
+ cd "$HTTPD_DOCUMENT_ROOT_PATH/many-tags.git" &&
create_tags 50001 100000
) &&
git -C too-many-refs fetch -q --tags &&
--
2.55.0.rc2.353.gf769b6597e
^ permalink raw reply related
* [PATCH 3/3] t5551: pack refs after creating many tags
From: Jeff King @ 2026-06-28 8:07 UTC (permalink / raw)
To: Michael Montalbo; +Cc: Patrick Steinhardt, git, Junio C Hamano
In-Reply-To: <20260628075716.GA3525066@coredump.intra.peff.net>
We have two tests that create 2,000 and 100,000 tags respectively.
After doing so, the resulting state can be a bit slow to work with when
using the "files" ref backend, as each of those refs is in its own file.
This isn't a very realistic scenario, as we'd expect most of those refs
to be packed. If they accrue over time along with objects, they'd get
packed by maintenance/gc runs. And if you have a process that creates a
ton of refs at once (like a big fast-import), the usual recommendation
is to run maintenance afterwards.
So let's follow that recommendation and pack the refs ourselves.
Unfortunately, this does not seem to produce an improvement to the
run-time of the test script! That's because after producing this state,
we perform only a few fetches of it. And packing the refs costs at least
as much as serving a ref advertisement (both have to iterate the refs,
but packing additionally must write .lock files as we pack).
My wall-clock time was slightly improved (but within the noise) with
this patch, but my user and system CPU time were slightly worse!
However, on a loaded system with I/O bottlenecks, it may be a net win.
That's somewhat of a guess, though.
It would be nice if we had a way to generate all of these refs without
writing so many individual files. But even if we taught the ref code to
write large cases directly to the packed-refs file, we'd still need to
take individual locks. The real solution is a backend like reftable,
which shaves ~30% off of the test runtime.
Signed-off-by: Jeff King <peff@peff.net>
---
I'm iffy on whether this one is worth it.
If you apply just this patch without patch 2, then the run-time does
improve quite a bit. The cost of packing is amortized by the improved
performance for all of those subsequent tests (but after patch 2, they
never even see the unpacked state).
Likewise, I suspect this would make our timeout problems go away even
without patch 1.
So the whole series _could_ be reduced to just this one patch. But
hopefully the reasoning given in the earlier patches makes sense, at
which point this one is kind of superfluous.
t/t5551-http-fetch-smart.sh | 1 +
1 file changed, 1 insertion(+)
diff --git a/t/t5551-http-fetch-smart.sh b/t/t5551-http-fetch-smart.sh
index cd851f24b8..e2e729216f 100755
--- a/t/t5551-http-fetch-smart.sh
+++ b/t/t5551-http-fetch-smart.sh
@@ -393,6 +393,7 @@ create_tags () {
tag=$(perl -e "print \"bla\" x 30") &&
sed -e "s|^:\([^ ]*\) \(.*\)$|create refs/tags/$tag-\1 \2|" <marks >input &&
git update-ref --stdin <input &&
+ git pack-refs --all &&
rm input
}
--
2.55.0.rc2.353.gf769b6597e
^ permalink raw reply related
* Re: [PATCH] meson: wire up USE_NSEC build knob
From: Jeff King @ 2026-06-28 8:18 UTC (permalink / raw)
To: Patrick Steinhardt
Cc: D. Ben Knoble, git, brian m . carlson, Junio C Hamano,
Ramsay Jones
In-Reply-To: <ajjuoS5Qc3K0nCRl@pks.im>
On Mon, Jun 22, 2026 at 10:13:21AM +0200, Patrick Steinhardt wrote:
> > So I guess if we wanted to go further it would take some digging as to
> > how each platform behaves, and then flipping the config.make.uname knob
> > for ones where it can be argued that the behavior is always reasonable.
>
> Yeah, it would be nice indeed to figure out whether these concerns still
> apply. If they do, I would argue that it might even make sense to remove
> the build option completely. It doesn't really make sense in my opinion
> to have a build option that nobody uses and that is subtly broken when
> enabled.
I suspect it works just fine on some platforms and some filesystems
(i.e., those that actually store nanoseconds on disk). So probably Linux
with ext4 is OK. That's just guessing, though.
If I understand the original problem correctly, then doing this:
touch foo
ls --full-time foo
echo 3 | sudo tee /proc/sys/vm/drop_caches
ls --full-time foo
should be instructive. If it shows the same time for both "ls" calls,
then USE_NSEC would be fine. If it doesn't, then the system is losing
the nanosecond information when it drops the cache and has to reload
from disk (and thus USE_NSEC would cause spurious stat mismatches).
On my ext4 system, I get the same answers. So far so good.
I get the same answers with a loopback-mounted ext2 system. Which
surprised me a bit, but even unmounting and remounting the filesystem,
the nanosecond times are still there. So...I guess ext2 supports
nanoseconds.
I tried with a vfat mount, and it also works: we don't have nanoseconds
either before or after. That makes sense, and implies that modern Linux
will always be OK (because it limits the cached VFS response to what the
underlying filesystem can handle).
So...maybe this is just a non-issue these days, at least on Linux?
> > But that's all outside the scope of your patch here.
>
> Kind of, I guess. If we figure that this mechanism is still subtly broken
> then I'd argue that it doesn't make sense to expose the option via
> Meson.
True, but AFAICT it probably is safe these days, at least one some
platforms.
-Peff
^ permalink raw reply
* [PATCH v6 0/4] history: add squash subcommand to fold a range
From: Harald Nordgren via GitGitGadget @ 2026-06-28 8:29 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren
In-Reply-To: <pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com>
Adds git history squash <revision-range> to fold a range of commits.
Changes in v6:
* git history squash now accepts multiple revision arguments, read like the
arguments to git-rev-list, so a compound range such as @~3.. ^topic
works.
* The base to reparent onto is now the oldest in-range commit's parent; a
boundary other than that base means the range has more than one base and
is rejected. This also fixes the earlier overly-restrictive handling of
merges and side branches.
* A single-commit range (e.g. @^!) is rejected with "nothing to squash"
(this also covers the @^!-style example that previously succeeded
silently).
* Commit messages reworded: the squash commit now gives an overview of
fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.
Changes in v5:
* The range walk now uses --ancestry-path, so only commits descended from
the base are folded; a single revision such as HEAD or HEAD~1 is now
rejected as "not a <base>..<tip> range" rather than treated as a squash
down to the root.
* This adopts the --ancestry-path suggestion; the multi-base rejection is
unchanged, so a side branch that forked before the base and merged in is
still refused.
* Added tests covering more merge topologies: two interior merges, a nested
merge, an octopus merge, an octopus arm forked before the base, a merge
among the descendants replayed above the range, and a ref pointing at an
interior merge commit.
Changes in v4:
* git history squash now detects when another ref points at a commit inside
the range being folded and refuses, with an advice.historyUpdateRefs hint
to use --update-refs=head.
* A merge inside the range is folded fine as long as the range has a single
base; a range with merge commit at the tip or base also folds correctly.
Only a range with more than one base is rejected.
Changes in v3:
* Moved the feature out of git rebase and into a new git history squash
<revision-range> subcommand, per the list discussion. git rebase --squash
is dropped.
* Takes an arbitrary range (git history squash @~3.., git history squash
@~5..@~2), folding it into the oldest commit and replaying any
descendants on top.
* Implemented as a single tree operation rather than picking each commit,
so there are no repeated conflict stops (addresses Phillip's efficiency
point).
* A merge inside the range is folded fine, only a range with more than one
base is rejected.
* --reedit-message seeds the editor with every folded-in message, not just
the oldest.
Harald Nordgren (4):
history: extract helper for a commit's parent tree
history: give commit_tree_ext a message template
history: add squash subcommand to fold a range
history: re-edit a squash with every message
Documentation/config/advice.adoc | 4 +
Documentation/git-history.adoc | 29 ++
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 357 +++++++++++++++++---
t/meson.build | 1 +
t/t3455-history-squash.sh | 550 +++++++++++++++++++++++++++++++
7 files changed, 905 insertions(+), 38 deletions(-)
create mode 100755 t/t3455-history-squash.sh
base-commit: 6c3d7b73556db708feb3b16232fab1efc4353428
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v6
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v6
Pull-Request: https://github.com/git/git/pull/2337
Range-diff vs v5:
1: 0f1ae9b05a = 1: fea6b79e60 history: extract helper for a commit's parent tree
2: a97ffab1e6 = 2: e2674e0bc4 history: give commit_tree_ext a message template
3: 04e18ef979 ! 3: 811e393ab4 history: add squash subcommand to fold a range
@@ Commit message
Add "git history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
- message and authorship and taking the tree of the newest commit, so the
- range collapses into a single commit. Commits above the range are
- replayed on top of the result.
+ message and authorship and taking the tree of the newest commit, then
+ replays the commits above the range on top. fixup!, squash! and amend!
+ commits are folded like any other and are not interpreted, so the
+ squashed message comes from the oldest commit, or from an editor with
+ --reedit-message.
- The range is given as <base>..<tip>, so "git history squash @~3.."
- folds the three most recent commits and "git history squash @~5..@~2"
- squashes an interior range. A merge inside the range is folded like any
- other commit, but the range must have a single base, so a range with
- more than one entry point is rejected.
-
- The folded commits leave the history, so by default the command refuses
- when another ref points at one of them. Use "--update-refs=head" to
- rewrite only the current branch and leave those refs untouched.
+ The range is read like the arguments to "git rev-list", so several
+ arguments such as "@~3.. ^topic" are allowed. A merge inside the range
+ is folded when its other parent is reachable from the base, otherwise
+ the range has more than one base and is rejected. By default the command
+ also refuses when a ref points at a commit that the fold would discard.
+ Use --update-refs=head to rewrite only the current branch instead.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-history.adoc: linkgit:gitglossary[7].
+the commit just below the oldest commit to squash. For example, `git
+history squash @~3..` folds the three most recent commits into one, and
+`git history squash @~5..@~2` squashes an interior range while leaving
-+the two newest commits in place.
++the two newest commits in place. _<revision-range>_ is read like the
++arguments to linkgit:git-rev-list[1], so several arguments may be given,
++for example `@~3.. ^topic` to additionally exclude what is already on
++`topic`.
++
+The oldest commit's message and authorship are preserved by default,
+unless you specify `--reedit-message`. A merge commit inside the range is
@@ builtin/history.c: out:
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
-+ const char *range,
++ const char **argv,
+ struct commit **base_out,
+ struct commit **oldest_out,
+ struct commit **tip_out,
@@ builtin/history.c: out:
+{
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
++ struct commit_list *boundaries = NULL, *b;
+ struct strvec args = STRVEC_INIT;
+ size_t i;
+ int ret;
@@ builtin/history.c: out:
+ strvec_push(&args, "--topo-order");
+ strvec_push(&args, "--boundary");
+ strvec_push(&args, "--ancestry-path");
-+ strvec_push(&args, range);
++ strvec_pushv(&args, argv);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (args.nr != 1) {
-+ ret = error(_("'%s' does not name a revision range"), range);
++ ret = error(_("unrecognized argument: %s"), args.v[1]);
+ goto out;
+ }
+
+ /*
-+ * A squash needs a base to reparent onto, so the argument has to
-+ * exclude something, as in "<base>..<tip>". A single revision has no
-+ * such bottom commit and cannot be squashed.
++ * A squash needs a base to reparent onto, so the range has to exclude
++ * something, as in "<base>..<tip>". A revision range with no such
++ * bottom commit cannot be squashed.
+ */
+ for (i = 0; i < revs.cmdline.nr; i++)
+ if (revs.cmdline.rev[i].flags & UNINTERESTING)
+ break;
+ if (i == revs.cmdline.nr) {
-+ ret = error(_("'%s' is not a '<base>..<tip>' range"), range);
++ ret = error(_("not a '<base>..<tip>' revision range"));
+ goto out;
+ }
+
@@ builtin/history.c: out:
+
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
-+ if (base) {
-+ ret = error(_("range '%s' has more than one base; "
-+ "cannot squash"), range);
-+ goto out;
-+ }
-+ base = commit;
++ commit_list_insert(commit, &boundaries);
+ continue;
+ }
+ if (!oldest)
@@ builtin/history.c: out:
+ }
+
+ if (!oldest) {
-+ ret = error(_("the range '%s' is empty"), range);
++ ret = error(_("the revision range is empty"));
++ goto out;
++ }
++
++ if (oldest == tip) {
++ ret = error(_("the revision range holds a single commit; "
++ "nothing to squash"));
+ goto out;
+ }
+
-+ if (!base)
-+ BUG("a non-empty range must have a boundary commit");
++ if (!oldest->parents)
++ BUG("an in-range commit must have a parent");
++ base = oldest->parents->item;
++
++ /*
++ * A boundary other than the base is an in-range commit reaching a
++ * commit outside the range, so the range has more than one base.
++ */
++ for (b = boundaries; b; b = b->next) {
++ if (b->item != base) {
++ ret = error(_("the revision range has more than one base; "
++ "cannot squash"));
++ goto out;
++ }
++ }
+
+ *base_out = base;
+ *oldest_out = oldest;
@@ builtin/history.c: out:
+ ret = 0;
+
+out:
++ commit_list_free(boundaries);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
@@ builtin/history.c: out:
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
-+ if (argc != 1) {
-+ ret = error(_("command expects a single revision range"));
++ if (!argc) {
++ ret = error(_("command expects a revision range"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
@@ builtin/history.c: out:
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
-+ ret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip,
++ ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
+ &interior);
+ if (ret < 0)
+ goto out;
@@ t/t3455-history-squash.sh (new)
+
+test_expect_success 'errors on missing range argument' '
+ test_must_fail git history squash 2>err &&
-+ test_grep "command expects a single revision range" err
-+'
-+
-+test_expect_success 'errors on too many arguments' '
-+ test_must_fail git history squash start.. HEAD 2>err &&
-+ test_grep "command expects a single revision range" err
++ test_grep "expects a revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+ test_must_fail git history squash HEAD..HEAD 2>err &&
-+ test_grep "the range .* is empty" err
++ test_grep "the revision range is empty" err
+'
+
+test_expect_success 'errors on a single revision that is not a range' '
+ test_must_fail git history squash HEAD 2>err &&
-+ test_grep "is not a .*range" err &&
++ test_grep "not a .*range" err &&
+ test_must_fail git history squash HEAD~1 2>err &&
-+ test_grep "is not a .*range" err
++ test_grep "not a .*range" err
++'
++
++test_expect_success 'errors on a range holding a single commit' '
++ git reset --hard three &&
++ head_before=$(git rev-parse HEAD) &&
++
++ test_must_fail git history squash "HEAD^!" 2>err &&
++ test_grep "single commit; nothing to squash" err &&
++ test_cmp_rev "$head_before" HEAD
++'
++
++test_expect_success 'accepts multiple revision arguments with an exclusion' '
++ git reset --hard three &&
++ git branch -f keep HEAD~2 &&
++ tip_tree=$(git rev-parse HEAD^{tree}) &&
++
++ git history squash start..HEAD ^keep &&
++
++ git log --format="%s" start..HEAD >actual &&
++ cat >expect <<-\EOF &&
++ two
++ one
++ EOF
++ test_cmp expect actual &&
++ test_cmp_rev keep HEAD~1 &&
++ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
++
++ git branch -D keep
++'
++
++test_expect_success 'squashes a branch the current branch is not on' '
++ git reset --hard three &&
++ main=$(git symbolic-ref --short HEAD) &&
++ head_before=$(git rev-parse HEAD) &&
++ git checkout -b off-history start &&
++ test_commit --no-tag off-one off a &&
++ test_commit --no-tag off-two off b &&
++ git checkout "$main" &&
++
++ git history squash start..off-history &&
++
++ git rev-list --count start..off-history >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
++ test_cmp_rev "$head_before" HEAD &&
++
++ git branch -D off-history
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
@@ t/t3455-history-squash.sh (new)
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
-+test_expect_success 'squashing a single-commit range replays the rest' '
-+ git reset --hard three &&
-+ tip_tree=$(git rev-parse HEAD^{tree}) &&
-+
-+ git history squash start..@~2 &&
-+
-+ git log --format="%s" start..HEAD >actual &&
-+ cat >expect <<-\EOF &&
-+ three
-+ two
-+ one
-+ EOF
-+ test_cmp expect actual &&
-+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
-+'
+
+test_expect_success 'reuses the message of a fixup! commit in the range' '
+ git reset --hard start &&
@@ t/t3455-history-squash.sh (new)
+
+test_expect_success 'squashes a range whose internal merge has a single base' '
+ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag before-side file b &&
+ git checkout -b inner-side &&
+ test_commit --no-tag on-inner-side inner x &&
-+ git checkout - &&
++ git checkout "$main" &&
+ test_commit --no-tag after-side file c &&
+ git merge --no-ff -m merge inner-side &&
+ git branch -D inner-side &&
@@ t/t3455-history-squash.sh (new)
+
+test_expect_success 'folds a merge of a branch that forked at the base' '
+ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b base-fork-side &&
+ test_commit --no-tag base-fork-side side x &&
-+ git checkout - &&
++ git checkout "$main" &&
+ test_commit --no-tag base-fork-main file b &&
+ git merge --no-ff -m "merge base-fork-side" base-fork-side &&
+ git branch -D base-fork-side &&
@@ t/t3455-history-squash.sh (new)
+ test_path_is_file side
+'
+
++test_expect_success 'refuses a merge whose other parent is outside the range' '
++ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
++ git checkout -b outside-parent &&
++ test_commit --no-tag outside-parent outside x &&
++ git checkout "$main" &&
++ test_commit --no-tag outside-main file b &&
++ base=$(git rev-parse HEAD) &&
++ test_commit --no-tag outside-mid file c &&
++ git merge --no-ff -m "merge outside-parent" outside-parent &&
++ git branch -D outside-parent &&
++ merged=$(git rev-parse HEAD) &&
++
++ test_must_fail git history squash "$base.." 2>err &&
++ test_grep "more than one base" err &&
++ test_cmp_rev "$merged" HEAD
++'
++
+test_expect_success 'folds a range whose tip is a merge commit' '
+ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag tipmerge-base file b &&
+ git checkout -b tipmerge-side &&
+ test_commit --no-tag tipmerge-side side x &&
-+ git checkout - &&
++ git checkout "$main" &&
+ test_commit --no-tag tipmerge-main file c &&
+ git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
+ git branch -D tipmerge-side &&
@@ t/t3455-history-squash.sh (new)
+
+test_expect_success 'folds a range whose base is a merge commit' '
+ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b basemerge-side &&
+ test_commit --no-tag basemerge-side side x &&
-+ git checkout - &&
++ git checkout "$main" &&
+ test_commit --no-tag basemerge-main file b &&
+ git merge --no-ff -m "merge basemerge-side" basemerge-side &&
+ git branch -D basemerge-side &&
@@ t/t3455-history-squash.sh (new)
+
+test_expect_success 'refuses to squash a range with more than one base' '
+ git reset --hard start &&
-+ head_before=$(git rev-parse HEAD) &&
++ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b forked-before &&
+ test_commit forked-side fside x &&
-+ git checkout - &&
-+ test_commit forked-main file b &&
++ git checkout "$main" &&
++ test_commit forked-base file b &&
++ base=$(git rev-parse HEAD) &&
++ test_commit forked-main file c &&
+ git merge --no-ff -m merge forked-before &&
+ merged=$(git rev-parse HEAD) &&
+
-+ test_must_fail git history squash forked-main.. 2>err &&
++ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range with two interior merges' '
+ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag two-merge-a file a1 &&
+ git checkout -b two-merge-s1 &&
+ test_commit --no-tag two-merge-s1 s1 x &&
-+ git checkout - &&
++ git checkout "$main" &&
+ git merge --no-ff -m "merge s1" two-merge-s1 &&
+ test_commit --no-tag two-merge-b file b1 &&
+ git checkout -b two-merge-s2 &&
+ test_commit --no-tag two-merge-s2 s2 y &&
-+ git checkout - &&
++ git checkout "$main" &&
+ git merge --no-ff -m "merge s2" two-merge-s2 &&
+ git branch -D two-merge-s1 two-merge-s2 &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
@@ t/t3455-history-squash.sh (new)
+test_expect_success 'refuses when a descendant above the range is a merge' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
-+ test_commit --no-tag desc-base file b &&
++ test_commit --no-tag desc-one file b &&
++ test_commit --no-tag desc-two file c &&
+ git tag desc-tip &&
+ git checkout -b desc-above &&
+ test_commit --no-tag desc-above above x &&
+ git checkout "$main" &&
-+ test_commit --no-tag desc-main file c &&
++ test_commit --no-tag desc-main file d &&
+ git merge --no-ff -m "merge desc-above" desc-above &&
+ git branch -D desc-above &&
+ head_before=$(git rev-parse HEAD) &&
4: a758e1f084 ! 4: 4edf012b77 history: re-edit a squash with every message
@@ Commit message
Gather the messages of every commit in the range, oldest first, and use
them as the editor template when re-editing, mirroring how "git rebase
- -i" presents a squash. The combined message is built before the
- descendant walk so it is not disturbed by the flags that walk leaves on
- the commits.
+ -i" presents a squash.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-history.adoc ##
-@@ Documentation/git-history.adoc: history squash @~3..` folds the three most recent commits into one, and
- `git history squash @~5..@~2` squashes an interior range while leaving
- the two newest commits in place.
+@@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several arguments may be given,
+ for example `@~3.. ^topic` to additionally exclude what is already on
+ `topic`.
+
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
--
gitgitgadget
^ permalink raw reply
* [PATCH v6 1/4] history: extract helper for a commit's parent tree
From: Harald Nordgren via GitGitGadget @ 2026-06-28 8:29 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Three places resolve the tree of a commit's first parent, falling back
to the empty tree for a root commit, each repeating the same parse and
oidcpy dance. Extract a first_parent_tree_oid() helper and route the
existing callers through it.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 58 +++++++++++++++++++++--------------------------
1 file changed, 26 insertions(+), 32 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 091465a59e..f95f26e684 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -157,6 +157,25 @@ out:
return ret;
}
+static int first_parent_tree_oid(struct repository *repo,
+ struct commit *commit,
+ struct object_id *out)
+{
+ struct commit *parent = commit->parents ? commit->parents->item : NULL;
+
+ if (!parent) {
+ oidcpy(out, repo->hash_algo->empty_tree);
+ return 0;
+ }
+
+ if (repo_parse_commit(repo, parent))
+ return error(_("unable to parse parent commit %s"),
+ oid_to_hex(&parent->object.oid));
+
+ oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
+ return 0;
+}
+
static int commit_tree_with_edited_message(struct repository *repo,
const char *action,
struct commit *original,
@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,
{
struct object_id parent_tree_oid;
const struct object_id *tree_oid;
- struct commit *parent;
tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- parent = original->parents ? original->parents->item : NULL;
- if (parent) {
- if (repo_parse_commit(repo, parent)) {
- return error(_("unable to parse parent commit %s"),
- oid_to_hex(&parent->object.oid));
- }
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return commit_tree_ext(repo, action, original, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,
struct commit *original,
struct tree *result)
{
- struct commit *parent = original->parents ? original->parents->item : NULL;
struct object_id parent_tree_oid;
- if (parent) {
- if (repo_parse_commit(repo, parent))
- return error(_("unable to parse parent of %s"),
- oid_to_hex(&original->object.oid));
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return oideq(&result->object.oid, &parent_tree_oid);
}
@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,
struct tree *split_tree;
int ret;
- if (original->parents) {
- if (repo_parse_commit(repo, original->parents->item)) {
- ret = error(_("unable to parse parent commit %s"),
- oid_to_hex(&original->parents->item->object.oid));
- goto out;
- }
-
- parent_tree_oid = *get_commit_tree_oid(original->parents->item);
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
+ ret = -1;
+ goto out;
}
original_commit_tree_oid = get_commit_tree_oid(original);
--
gitgitgadget
^ permalink raw reply related
* [PATCH v6 2/4] history: give commit_tree_ext a message template
From: Harald Nordgren via GitGitGadget @ 2026-06-28 8:29 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
commit_tree_ext() reuses the message of the commit it is handed. A
caller that folds several commits together wants to seed the message
from more than that single commit, so add an optional message_template
parameter. When NULL, the behavior is unchanged.
Pass NULL from the existing fixup and split callers.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index f95f26e684..305bde3102 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -101,6 +101,7 @@ enum commit_tree_flags {
static int commit_tree_ext(struct repository *repo,
const char *action,
struct commit *commit_with_message,
+ const char *message_template,
const struct commit_list *parents,
const struct object_id *old_tree,
const struct object_id *new_tree,
@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
+ if (!message_template)
+ message_template = original_body;
+
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
+ message_template, action, &commit_message);
if (ret < 0)
goto out;
} else {
- strbuf_addstr(&commit_message, original_body);
+ strbuf_addstr(&commit_message, message_template);
}
original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
return -1;
- return commit_tree_ext(repo, action, original, original->parents,
+ return commit_tree_ext(repo, action, original, NULL, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,
goto out;
if (!skip_commit) {
- ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
&original_tree->object.oid, &merge_result.tree->object.oid,
&rewritten, flags);
if (ret < 0) {
@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
--
gitgitgadget
^ permalink raw reply related
* [PATCH v6 3/4] history: add squash subcommand to fold a range
From: Harald Nordgren via GitGitGadget @ 2026-06-28 8:29 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
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 history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
message and authorship and taking the tree of the newest commit, then
replays the commits above the range on top. fixup!, squash! and amend!
commits are folded like any other and are not interpreted, so the
squashed message comes from the oldest commit, or from an editor with
--reedit-message.
The range is read like the arguments to "git rev-list", so several
arguments such as "@~3.. ^topic" are allowed. A merge inside the range
is folded when its other parent is reachable from the base, otherwise
the range has more than one base and is rejected. By default the command
also refuses when a ref points at a commit that the fold would discard.
Use --update-refs=head to rewrite only the current branch instead.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/advice.adoc | 4 +
Documentation/git-history.adoc | 28 ++
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 224 ++++++++++++++
t/meson.build | 1 +
t/t3455-history-squash.sh | 513 +++++++++++++++++++++++++++++++
7 files changed, 772 insertions(+)
create mode 100755 t/t3455-history-squash.sh
diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 257db58918..f4d692d136 100644
--- a/Documentation/config/advice.adoc
+++ b/Documentation/config/advice.adoc
@@ -55,6 +55,10 @@ all advice messages.
forceDeleteBranch::
Shown when the user tries to delete a not fully merged
branch without the force option set.
+ historyUpdateRefs::
+ Shown when `git history squash` refuses because a ref points
+ into the range being folded, to tell the user about
+ `--update-refs=head`.
ignoredHook::
Shown when a hook is ignored because the hook is not
set as executable.
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 2ba8121795..123ad5d4bc 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
DESCRIPTION
-----------
@@ -97,6 +98,33 @@ linkgit:gitglossary[7].
It is invalid to select either all or no hunks, as that would lead to
one of the commits becoming empty.
+`squash <revision-range>`::
+ Fold all commits in _<revision-range>_ into the oldest commit of that
+ range. The resulting commit keeps the oldest commit's message and
+ authorship and takes the tree of the range's newest commit, so the
+ whole range collapses into a single commit. Commits above the range
+ are replayed on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash @~3..` folds the three most recent commits into one, and
+`git history squash @~5..@~2` squashes an interior range while leaving
+the two newest commits in place. _<revision-range>_ is read like the
+arguments to linkgit:git-rev-list[1], so several arguments may be given,
+for example `@~3.. ^topic` to additionally exclude what is already on
+`topic`.
++
+The oldest commit's message and authorship are preserved by default,
+unless you specify `--reedit-message`. A merge commit inside the range is
+folded like any other, but the range must have a single base, so a range
+that reaches more than one entry point (for example a side branch that
+forked before the range and was later merged into it) is rejected.
++
+The folded commits disappear from the history, so with the default
+`--update-refs=branches` the command refuses when another ref points at
+one of them. Rerun with `--update-refs=head` to rewrite only the current
+branch and leave those refs pointing at the old commits.
+
OPTIONS
-------
diff --git a/advice.c b/advice.c
index 0018501b7b..5c6ff95e31 100644
--- a/advice.c
+++ b/advice.c
@@ -58,6 +58,7 @@ static struct {
[ADVICE_FETCH_SHOW_FORCED_UPDATES] = { "fetchShowForcedUpdates" },
[ADVICE_FORCE_DELETE_BRANCH] = { "forceDeleteBranch" },
[ADVICE_GRAFT_FILE_DEPRECATED] = { "graftFileDeprecated" },
+ [ADVICE_HISTORY_UPDATE_REFS] = { "historyUpdateRefs" },
[ADVICE_IGNORED_HOOK] = { "ignoredHook" },
[ADVICE_IMPLICIT_IDENTITY] = { "implicitIdentity" },
[ADVICE_MERGE_CONFLICT] = { "mergeConflict" },
diff --git a/advice.h b/advice.h
index 8def280688..911b4e4643 100644
--- a/advice.h
+++ b/advice.h
@@ -25,6 +25,7 @@ enum advice_type {
ADVICE_FETCH_SHOW_FORCED_UPDATES,
ADVICE_FORCE_DELETE_BRANCH,
ADVICE_GRAFT_FILE_DEPRECATED,
+ ADVICE_HISTORY_UPDATE_REFS,
ADVICE_IGNORED_HOOK,
ADVICE_IMPLICIT_IDENTITY,
ADVICE_MERGE_CONFLICT,
diff --git a/builtin/history.c b/builtin/history.c
index 305bde3102..5a1b42c063 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
#define USE_THE_REPOSITORY_VARIABLE
#include "builtin.h"
+#include "advice.h"
#include "cache-tree.h"
#include "commit.h"
#include "commit-reach.h"
@@ -30,6 +31,8 @@
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
+#define GIT_HISTORY_SQUASH_USAGE \
+ N_("git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]")
static void change_data_free(void *util, const char *str UNUSED)
{
@@ -973,6 +976,225 @@ out:
return ret;
}
+/*
+ * Resolve a "<base>..<tip>" revision range into the base commit just outside
+ * the range (which becomes the parent of the squashed commit), the oldest
+ * commit contained in the range (whose message the squash reuses), and the
+ * range tip (whose tree becomes the result). A merge inside the range is fine,
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+ const char **argv,
+ struct commit **base_out,
+ struct commit **oldest_out,
+ struct commit **tip_out,
+ struct oidset *interior_out)
+{
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+ struct commit_list *boundaries = NULL, *b;
+ struct strvec args = STRVEC_INIT;
+ size_t i;
+ int ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_push(&args, "--boundary");
+ strvec_push(&args, "--ancestry-path");
+ strvec_pushv(&args, argv);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (args.nr != 1) {
+ ret = error(_("unrecognized argument: %s"), args.v[1]);
+ goto out;
+ }
+
+ /*
+ * A squash needs a base to reparent onto, so the range has to exclude
+ * something, as in "<base>..<tip>". A revision range with no such
+ * bottom commit cannot be squashed.
+ */
+ for (i = 0; i < revs.cmdline.nr; i++)
+ if (revs.cmdline.rev[i].flags & UNINTERESTING)
+ break;
+ if (i == revs.cmdline.nr) {
+ ret = error(_("not a '<base>..<tip>' revision range"));
+ goto out;
+ }
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
+ commit_list_insert(commit, &boundaries);
+ continue;
+ }
+ if (!oldest)
+ oldest = commit;
+ if (tip)
+ oidset_insert(interior_out, &tip->object.oid);
+ tip = commit;
+ }
+
+ if (!oldest) {
+ ret = error(_("the revision range is empty"));
+ goto out;
+ }
+
+ if (oldest == tip) {
+ ret = error(_("the revision range holds a single commit; "
+ "nothing to squash"));
+ goto out;
+ }
+
+ if (!oldest->parents)
+ BUG("an in-range commit must have a parent");
+ base = oldest->parents->item;
+
+ /*
+ * A boundary other than the base is an in-range commit reaching a
+ * commit outside the range, so the range has more than one base.
+ */
+ for (b = boundaries; b; b = b->next) {
+ if (b->item != base) {
+ ret = error(_("the revision range has more than one base; "
+ "cannot squash"));
+ goto out;
+ }
+ }
+
+ *base_out = base;
+ *oldest_out = oldest;
+ *tip_out = tip;
+ ret = 0;
+
+out:
+ commit_list_free(boundaries);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
+struct interior_ref_cb {
+ const struct oidset *interior;
+ const char *name;
+};
+
+static int find_interior_ref(const struct reference *ref, void *cb_data)
+{
+ struct interior_ref_cb *data = cb_data;
+
+ if (oidset_contains(data->interior, ref->oid)) {
+ data->name = xstrdup(ref->name);
+ return 1;
+ }
+
+ return 0;
+}
+
+static int cmd_history_squash(int argc,
+ const char **argv,
+ const char *prefix,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_SQUASH_USAGE,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct oidset interior = OIDSET_INIT;
+ struct commit *base, *oldest, *tip, *rewritten;
+ const struct object_id *base_tree_oid, *tip_tree_oid;
+ struct commit_list *parents = NULL;
+ struct rev_info revs = { 0 };
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
+ if (!argc) {
+ ret = error(_("command expects a revision range"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
+ &interior);
+ if (ret < 0)
+ goto out;
+
+ if (action == REF_ACTION_BRANCHES) {
+ struct interior_ref_cb cb = { .interior = &interior };
+
+ refs_for_each_ref(get_main_ref_store(repo),
+ find_interior_ref, &cb);
+ if (cb.name) {
+ ret = error(_("'%s' points into the squashed range"),
+ cb.name);
+ advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
+ _("Use --update-refs=head to rewrite only "
+ "the current branch and leave such refs "
+ "untouched."));
+ free((char *)cb.name);
+ goto out;
+ }
+ }
+
+ ret = setup_revwalk(repo, action, tip, &revs);
+ if (ret < 0)
+ goto out;
+
+ base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+ tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+ commit_list_append(base, &parents);
+
+ ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+ base_tree_oid, tip_tree_oid, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing squashed commit"));
+ goto out;
+ }
+
+ strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, tip, rewritten,
+ reflog_msg.buf, dry_run,
+ REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ strbuf_release(&reflog_msg);
+ oidset_clear(&interior);
+ commit_list_free(parents);
+ release_revisions(&revs);
+ return ret;
+}
+
int cmd_history(int argc,
const char **argv,
const char *prefix,
@@ -982,6 +1204,7 @@ int cmd_history(int argc,
GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
+ GIT_HISTORY_SQUASH_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
@@ -989,6 +1212,7 @@ int cmd_history(int argc,
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
+ OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
OPT_END(),
};
diff --git a/t/meson.build b/t/meson.build
index 3219264fe7..63ea26b8ed 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -399,6 +399,7 @@ integration_tests = [
't3451-history-reword.sh',
't3452-history-split.sh',
't3453-history-fixup.sh',
+ 't3455-history-squash.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
new file mode 100755
index 0000000000..94ee54eb24
--- /dev/null
+++ b/t/t3455-history-squash.sh
@@ -0,0 +1,513 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'setup linear history touching two files' '
+ test_commit base file a &&
+ git tag start &&
+ test_commit --no-tag one other x &&
+ test_commit --no-tag two file c &&
+ test_commit three file d
+'
+
+test_expect_success 'errors on missing range argument' '
+ test_must_fail git history squash 2>err &&
+ test_grep "expects a revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+ test_must_fail git history squash HEAD..HEAD 2>err &&
+ test_grep "the revision range is empty" err
+'
+
+test_expect_success 'errors on a single revision that is not a range' '
+ test_must_fail git history squash HEAD 2>err &&
+ test_grep "not a .*range" err &&
+ test_must_fail git history squash HEAD~1 2>err &&
+ test_grep "not a .*range" err
+'
+
+test_expect_success 'errors on a range holding a single commit' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "HEAD^!" 2>err &&
+ test_grep "single commit; nothing to squash" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'accepts multiple revision arguments with an exclusion' '
+ git reset --hard three &&
+ git branch -f keep HEAD~2 &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..HEAD ^keep &&
+
+ git log --format="%s" start..HEAD >actual &&
+ cat >expect <<-\EOF &&
+ two
+ one
+ EOF
+ test_cmp expect actual &&
+ test_cmp_rev keep HEAD~1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+ git branch -D keep
+'
+
+test_expect_success 'squashes a branch the current branch is not on' '
+ git reset --hard three &&
+ main=$(git symbolic-ref --short HEAD) &&
+ head_before=$(git rev-parse HEAD) &&
+ git checkout -b off-history start &&
+ test_commit --no-tag off-one off a &&
+ test_commit --no-tag off-two off b &&
+ git checkout "$main" &&
+
+ git history squash start..off-history &&
+
+ git rev-list --count start..off-history >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D off-history
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
+ git reset --hard three &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ git log --format="%s" -1 >subject &&
+ echo one >expect &&
+ test_cmp expect subject &&
+ git reflog >reflog &&
+ test_grep "squash: updating" reflog
+'
+
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+ git reset --hard three &&
+ final_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..@~1 &&
+
+ git log --format="%s" start..HEAD >actual &&
+ cat >expect <<-\EOF &&
+ three
+ one
+ EOF
+ test_cmp expect actual &&
+
+ test_cmp_rev start HEAD~2 &&
+ test "$final_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes when the base is the root commit' '
+ git reset --hard three &&
+ root=$(git rev-list --max-parents=0 HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash "$root.." &&
+
+ git rev-list --count "$root..HEAD" >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$root" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+
+test_expect_success 'reuses the message of a fixup! commit in the range' '
+ git reset --hard start &&
+ test_commit --no-tag reg1 file b &&
+ git commit --allow-empty -m "fixup! reg1" &&
+ test_commit reg2 file c &&
+
+ git history squash start.. &&
+
+ git log --format="%s" -1 >actual &&
+ echo reg1 >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success 'keeps the oldest message even if it is a fixup!' '
+ git reset --hard start &&
+ test_commit --no-tag "fixup! something" file b &&
+ test_commit tail file c &&
+
+ git history squash start.. &&
+
+ git log --format="%s" -1 >actual &&
+ echo "fixup! something" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success 'preserves authorship of the oldest commit' '
+ git reset --hard start &&
+ GIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \
+ test_commit --no-tag oldest file b &&
+ test_commit newest file c &&
+
+ git history squash start.. &&
+
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Squasher <squash@example.com>" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--dry-run predicts the rewrite without performing it' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --dry-run start.. >out &&
+ predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git history squash start.. &&
+ test "$predicted" = "$(git rev-parse HEAD)" &&
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success '--update-refs=head only moves HEAD' '
+ git reset --hard three &&
+ git branch -f other HEAD &&
+ other_before=$(git rev-parse other) &&
+
+ git history squash --update-refs=head start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$other_before" other
+'
+
+test_expect_success 'refuses to fold a range a ref points into' '
+ git reset --hard three &&
+ git branch -f mid HEAD~1 &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "error: .* points into the squashed range" err &&
+ test_grep "hint: .*--update-refs=head" err &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D mid
+'
+
+test_expect_success 'advice.historyUpdateRefs silences the hint' '
+ git reset --hard three &&
+ git branch -f mid HEAD~1 &&
+
+ test_must_fail git -c advice.historyUpdateRefs=false \
+ history squash start.. 2>err &&
+ test_grep "points into the squashed range" err &&
+ test_grep ! "hint:" err &&
+
+ git branch -D mid
+'
+
+test_expect_success '--update-refs=head folds past a ref pointing into the range' '
+ git reset --hard three &&
+ git branch -f mid HEAD~1 &&
+ mid_before=$(git rev-parse mid) &&
+
+ git history squash --update-refs=head start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$mid_before" mid &&
+
+ git branch -D mid
+'
+
+test_expect_success 'refuses to fold a range a tag points into' '
+ git reset --hard three &&
+ git tag -f mark HEAD~1 &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "refs/tags/mark" err &&
+ test_grep "points into the squashed range" err &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git tag -d mark
+'
+
+test_expect_success 'squashes a range whose internal merge has a single base' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag before-side file b &&
+ git checkout -b inner-side &&
+ test_commit --no-tag on-inner-side inner x &&
+ git checkout "$main" &&
+ test_commit --no-tag after-side file c &&
+ git merge --no-ff -m merge inner-side &&
+ git branch -D inner-side &&
+ test_commit --no-tag after-merge file d &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ git log --format="%s" -1 >subject &&
+ echo before-side >expect &&
+ test_cmp expect subject &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file inner
+'
+
+test_expect_success 'folds a merge of a branch that forked at the base' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b base-fork-side &&
+ test_commit --no-tag base-fork-side side x &&
+ git checkout "$main" &&
+ test_commit --no-tag base-fork-main file b &&
+ git merge --no-ff -m "merge base-fork-side" base-fork-side &&
+ git branch -D base-fork-side &&
+ test_commit --no-tag base-fork-tail file c &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file side
+'
+
+test_expect_success 'refuses a merge whose other parent is outside the range' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b outside-parent &&
+ test_commit --no-tag outside-parent outside x &&
+ git checkout "$main" &&
+ test_commit --no-tag outside-main file b &&
+ base=$(git rev-parse HEAD) &&
+ test_commit --no-tag outside-mid file c &&
+ git merge --no-ff -m "merge outside-parent" outside-parent &&
+ git branch -D outside-parent &&
+ merged=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range whose tip is a merge commit' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag tipmerge-base file b &&
+ git checkout -b tipmerge-side &&
+ test_commit --no-tag tipmerge-side side x &&
+ git checkout "$main" &&
+ test_commit --no-tag tipmerge-main file c &&
+ git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
+ git branch -D tipmerge-side &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file side
+'
+
+test_expect_success 'folds a range whose base is a merge commit' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b basemerge-side &&
+ test_commit --no-tag basemerge-side side x &&
+ git checkout "$main" &&
+ test_commit --no-tag basemerge-main file b &&
+ git merge --no-ff -m "merge basemerge-side" basemerge-side &&
+ git branch -D basemerge-side &&
+ base=$(git rev-parse HEAD) &&
+ test_commit --no-tag basemerge-one file c &&
+ test_commit --no-tag basemerge-two file d &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash "$base.." &&
+
+ git rev-list --count "$base..HEAD" >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$base" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'refuses to squash a range with more than one base' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b forked-before &&
+ test_commit forked-side fside x &&
+ git checkout "$main" &&
+ test_commit forked-base file b &&
+ base=$(git rev-parse HEAD) &&
+ test_commit forked-main file c &&
+ git merge --no-ff -m merge forked-before &&
+ merged=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range with two interior merges' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag two-merge-a file a1 &&
+ git checkout -b two-merge-s1 &&
+ test_commit --no-tag two-merge-s1 s1 x &&
+ git checkout "$main" &&
+ git merge --no-ff -m "merge s1" two-merge-s1 &&
+ test_commit --no-tag two-merge-b file b1 &&
+ git checkout -b two-merge-s2 &&
+ test_commit --no-tag two-merge-s2 s2 y &&
+ git checkout "$main" &&
+ git merge --no-ff -m "merge s2" two-merge-s2 &&
+ git branch -D two-merge-s1 two-merge-s2 &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file s1 &&
+ test_path_is_file s2
+'
+
+test_expect_success 'folds a range with a nested merge' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b nested-outer &&
+ test_commit --no-tag nested-outer outer x &&
+ git checkout -b nested-inner &&
+ test_commit --no-tag nested-inner inner y &&
+ git checkout nested-outer &&
+ git merge --no-ff -m "merge inner" nested-inner &&
+ git checkout "$main" &&
+ test_commit --no-tag nested-main file b1 &&
+ git merge --no-ff -m "merge outer" nested-outer &&
+ git branch -D nested-outer nested-inner &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file outer &&
+ test_path_is_file inner
+'
+
+test_expect_success 'folds a range with an octopus merge' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag octo-base file a1 &&
+ git checkout -b octo-1 &&
+ test_commit --no-tag octo-1 o1 x &&
+ git checkout "$main" &&
+ git checkout -b octo-2 &&
+ test_commit --no-tag octo-2 o2 y &&
+ git checkout "$main" &&
+ git merge --no-ff -m octopus octo-1 octo-2 &&
+ git branch -D octo-1 octo-2 &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file o1 &&
+ test_path_is_file o2
+'
+
+test_expect_success 'refuses an octopus merge with an arm forked before the base' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b octo-pre &&
+ test_commit octo-pre-side pside x &&
+ git checkout "$main" &&
+ test_commit octo-pre-main file b1 &&
+ octo_base=$(git rev-parse HEAD) &&
+ git checkout -b octo-within &&
+ test_commit --no-tag octo-within wside y &&
+ git checkout "$main" &&
+ git merge --no-ff -m octopus octo-pre octo-within &&
+ merged=$(git rev-parse HEAD) &&
+ git branch -D octo-pre octo-within &&
+
+ test_must_fail git history squash "$octo_base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'refuses when a descendant above the range is a merge' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag desc-one file b &&
+ test_commit --no-tag desc-two file c &&
+ git tag desc-tip &&
+ git checkout -b desc-above &&
+ test_commit --no-tag desc-above above x &&
+ git checkout "$main" &&
+ test_commit --no-tag desc-main file d &&
+ git merge --no-ff -m "merge desc-above" desc-above &&
+ git branch -D desc-above &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start..desc-tip 2>err &&
+ test_grep "merge commits is not supported" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'refuses to fold a range a ref points into at a merge' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag refmerge-base file b &&
+ git checkout -b refmerge-side &&
+ test_commit --no-tag refmerge-side side x &&
+ git checkout "$main" &&
+ test_commit --no-tag refmerge-main file c &&
+ git merge --no-ff -m "interior merge" refmerge-side &&
+ git branch -D refmerge-side &&
+ git branch at-merge HEAD &&
+ test_commit --no-tag refmerge-tail file d &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "at-merge" err &&
+ test_grep "points into the squashed range" err &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D at-merge
+'
+
+test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v6 4/4] history: re-edit a squash with every message
From: Harald Nordgren via GitGitGadget @ 2026-06-28 8:29 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
By default "git history squash" reuses the oldest commit's message.
When --reedit-message is given it only reopened that one message, so the
messages of the folded-in commits were lost.
Gather the messages of every commit in the range, oldest first, and use
them as the editor template when re-editing, mirroring how "git rebase
-i" presents a squash.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-history.adoc | 5 +--
builtin/history.c | 61 +++++++++++++++++++++++++++++++++-
t/t3455-history-squash.sh | 37 +++++++++++++++++++++
3 files changed, 100 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 123ad5d4bc..8d4398ab1b 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -114,8 +114,9 @@ arguments to linkgit:git-rev-list[1], so several arguments may be given,
for example `@~3.. ^topic` to additionally exclude what is already on
`topic`.
+
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
+The oldest commit's message and authorship are preserved by default. With
+`--reedit-message`, an editor opens pre-filled with the messages of all the
+folded commits so you can combine them. A merge commit inside the range is
folded like any other, but the range must have a single base, so a range
that reaches more than one entry point (for example a side branch that
forked before the range and was later merged into it) is rejected.
diff --git a/builtin/history.c b/builtin/history.c
index 5a1b42c063..1c31ea9118 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1097,6 +1097,56 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
return 0;
}
+static int build_squash_message(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct strbuf *out)
+{
+ struct rev_info revs;
+ struct commit *commit;
+ struct strvec args = STRVEC_INIT;
+ int n = 0, ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs))) {
+ const char *message, *body;
+ struct strbuf one = STRBUF_INIT;
+
+ message = repo_logmsg_reencode(repo, commit, NULL, NULL);
+ find_commit_subject(message, &body);
+ strbuf_addstr(&one, body);
+ strbuf_trim_trailing_newline(&one);
+
+ if (n++)
+ strbuf_addch(out, '\n');
+ strbuf_addbuf(out, &one);
+ strbuf_addch(out, '\n');
+
+ strbuf_release(&one);
+ repo_unuse_commit_buffer(repo, commit, message);
+ }
+
+ ret = 0;
+
+out:
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
@@ -1121,6 +1171,7 @@ static int cmd_history_squash(int argc,
OPT_END(),
};
struct strbuf reflog_msg = STRBUF_INIT;
+ struct strbuf message = STRBUF_INIT;
struct oidset interior = OIDSET_INIT;
struct commit *base, *oldest, *tip, *rewritten;
const struct object_id *base_tree_oid, *tip_tree_oid;
@@ -1160,6 +1211,12 @@ static int cmd_history_squash(int argc,
}
}
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
+ }
+
ret = setup_revwalk(repo, action, tip, &revs);
if (ret < 0)
goto out;
@@ -1168,7 +1225,8 @@ static int cmd_history_squash(int argc,
tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
commit_list_append(base, &parents);
- ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+ ret = commit_tree_ext(repo, "squash", oldest,
+ message.len ? message.buf : NULL, parents,
base_tree_oid, tip_tree_oid, &rewritten, flags);
if (ret < 0) {
ret = error(_("failed writing squashed commit"));
@@ -1189,6 +1247,7 @@ static int cmd_history_squash(int argc,
out:
strbuf_release(&reflog_msg);
+ strbuf_release(&message);
oidset_clear(&interior);
commit_list_free(parents);
release_revisions(&revs);
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 94ee54eb24..5ef6768826 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -164,6 +164,43 @@ test_expect_success 'preserves authorship of the oldest commit' '
test_cmp expect actual
'
+test_expect_success '--reedit-message offers every folded-in message' '
+ git reset --hard start &&
+ echo b >file &&
+ git add file &&
+ git commit -m "re-one subject" -m "re-one body line" &&
+ test_commit --no-tag re-two file c &&
+ test_commit re-three file d &&
+
+ write_script editor <<-\EOF &&
+ cp "$1" buffer &&
+ echo combined >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ test_grep "re-one subject" buffer &&
+ test_grep "re-one body line" buffer &&
+ test_grep re-two buffer &&
+ test_grep re-three buffer &&
+ git log --format="%s" -1 >actual &&
+ echo combined >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ write_script editor <<-\EOF &&
+ >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ test_must_fail git history squash --reedit-message start.. &&
+
+ test_cmp_rev "$head_before" HEAD
+'
+
test_expect_success '--dry-run predicts the rewrite without performing it' '
git reset --hard three &&
head_before=$(git rev-parse HEAD) &&
--
gitgitgadget
^ permalink raw reply related
page: next (older) | prev (newer) | latest
- recent:[subjects (threaded)|topics (new)|topics (active)]
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox