Skip to content

history: add squash subcommand to fold a range#2337

Open
HaraldNordgren wants to merge 5 commits into
git:masterfrom
HaraldNordgren:rebase-fixup-fold
Open

history: add squash subcommand to fold a range#2337
HaraldNordgren wants to merge 5 commits into
git:masterfrom
HaraldNordgren:rebase-fixup-fold

Conversation

@HaraldNordgren

@HaraldNordgren HaraldNordgren commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Adds git history squash <revision-range> to fold a range of commits.

Changes in v10:

  • Record the full revision expression in squash reflog.
  • Preserve the boundary-walk invariant when sanitizing rev-list options.
  • Clarify amend! and --reedit-message documentation.

Changes in v9:

  • Use the last amend! targeting the oldest folded commit as the default squashed message. Ignore amend! markers targeting later commits while selecting that replacement message.
  • Improve tests.

Changes in v8:

  • --reedit-message now builds the same editor template as git rebase -i --autosquash: fixup!, squash! and amend! commits are grouped under the commit they target instead of shown in commit order, and an amend! replaces its target's message.
  • A fixup!, squash! or amend! is refused only when its target is outside the range, so several fixups for an in-range commit fold together. A range that is entirely markers for one below-range target is combined into a single commit, keeping the last amend! message.
  • Merges inside the range are folded when the range has a single base, with no dedicated opt-in flag, --ancestry-path ensures only commits descended from the base are folded, and a range reaching more than one base is rejected.
  • Rev-list options are accepted and sanitized the way git replay does, forcing the walk order back with a warning, which also fixes git history squash -- --reverse slipping past the previous option check.
  • Kept this as an explicit squash subcommand rather than making --reedit-message the default or renaming the command.

Changes in v7:

  • --reedit-message now builds the same editor template git rebase -i shows for a squash (a combination of N commits banner with each folded message under its own header) and follows autosquash for markers: a fixup! message falls out (commented under a will be skipped header), while a squash! or amend! keeps its body with only the marker subject commented so its remark can be reworded in. Only the message text is affected, every commit's changes are always folded in.
  • Reuse git rebase -i's squash-message code: a preparatory sequencer: commit extracts the banner, header and marker-comment helpers so both rebase and git history squash build the identical template from one source.
  • Refuse a range whose oldest commit is a fixup!, squash! or amend!, since the marker's target cannot be inside the range.
  • Reorder the squash usage so dashed options come before <revision-range>, and spell out HEAD instead of @ in the documentation and examples.
  • Expand the squash commit message and documentation with this overview, and scope the merge limitation so it no longer contradicts squash folding a single-base interior merge.

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.

cc: Phillip Wood phillip.wood123@gmail.com
cc: "D. Ben Knoble" ben.knoble@gmail.com
cc: Patrick Steinhardt ps@pks.im
cc: "Matt Hunter" m@lfurio.us

@taahol

taahol commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Related: gitgitgadget#1135

@HaraldNordgren
HaraldNordgren force-pushed the rebase-fixup-fold branch 13 times, most recently from 38b3973 to bd1bc62 Compare June 14, 2026 18:50
@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2337.git.git.1781465141.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-git-2337/HaraldNordgren/rebase-fixup-fold-v1

To fetch this version to local tag pr-git-2337/HaraldNordgren/rebase-fixup-fold-v1:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-git-2337/HaraldNordgren/rebase-fixup-fold-v1

@HaraldNordgren
HaraldNordgren force-pushed the rebase-fixup-fold branch 3 times, most recently from cb0d34c to 7b87781 Compare June 15, 2026 04:14
@gitgitgadget-git

Copy link
Copy Markdown

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:

> Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits
> into its oldest one, reusing that commit's message.

[2/2] seems to add "--fixup-all" but I agree with the "related idea"
that naming it and modelling it after "merge --squash" would be
easier to understand.

> Related idea: https://github.com/gitgitgadget/git/issues/1135

I also wonder if we can do something like this without adding any
new option or command.  E.g., if you have four patch series, where
the initial implementation HEAD~3 is followed by "oops it was still
wrong" fix-up HEAD~2, HEAD~1 and HEAD, then

    git reset --soft HEAD~3 && git commit --amend --no-edit

is what the user wants to do, no?

@HaraldNordgren HaraldNordgren changed the title rebase: add --fixup to fold a range into its oldest commit rebase: add --squash to fold a range into its oldest commit Jun 15, 2026
@HaraldNordgren HaraldNordgren changed the title rebase: add --squash to fold a range into its oldest commit rebase: add --squash to fold a range into its first commit Jun 15, 2026
@HaraldNordgren
HaraldNordgren force-pushed the rebase-fixup-fold branch 2 times, most recently from 30409d4 to e775fd9 Compare June 15, 2026 08:23
@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-git-2337/HaraldNordgren/rebase-fixup-fold-v8

To fetch this version to local tag pr-git-2337/HaraldNordgren/rebase-fixup-fold-v8:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-git-2337/HaraldNordgren/rebase-fixup-fold-v8

@gitgitgadget-git

Copy link
Copy Markdown

This patch series is no longer integrated into seen.

@gitgitgadget-git

Copy link
Copy Markdown

This patch series was integrated into seen via 370f1fd.

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/history-squash on the Git mailing list:

The experimental 'git history' command has been taught a new 'squash'
subcommand to fold a range of commits into a single commit, with any
descendants replayed on top.

Needs review.
source: <pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com>

@gitgitgadget-git

Copy link
Copy Markdown

"Matt Hunter" wrote on the Git mailing list (how to reply to this email):

On Fri Jul 10, 2026 at 5:06 AM EDT, Harald Nordgren via GitGitGadget wrote:
> Adds git history squash <revision-range> to fold a range of commits.
>
> Changes in v8:
>
>  * --reedit-message now builds the same editor template as git rebase -i
>    --autosquash: fixup!, squash! and amend! commits are grouped under the
>    commit they target instead of shown in commit order, and an amend!
>    replaces its target's message.
>  * A fixup!, squash! or amend! is refused only when its target is outside
>    the range, so several fixups for an in-range commit fold together. A
>    range that is entirely markers for one below-range target is combined
>    into a single commit, keeping the last amend! message.
>  * Merges inside the range are folded when the range has a single base, with
>    no dedicated opt-in flag, --ancestry-path ensures only commits descended
>    from the base are folded, and a range reaching more than one base is
>    rejected.
>  * Rev-list options are accepted and sanitized the way git replay does,
>    forcing the walk order back with a warning, which also fixes git history
>    squash -- --reverse slipping past the previous option check.
>  * Kept this as an explicit squash subcommand rather than making
>    --reedit-message the default or renaming the command.

This feature looks like it's coming together pretty well imo.  I just have
one observation I want to comment on:

I noticed that 'git history squash <range>', when --reedit-message is
omitted, will ignore any amend! message in the range that targets the
first folded commit.

On the surface, this makes sense.  The feature is pretty explicit that
it will faithfully stick with the first commit's message, unless
modified by use of --reedit-message.

However, this edge case is a little surprising, given that
'git history squash' seems to be aware of the semantics of fixup!, amend!,
and squash! messages whether --reedit-message was given or not.  For instance,
the default command notices when the range contains a squash! commit whose
target is elsewhere (a useful feature).  It seems consistent then, that the
default command would incorporate an amend! it is aware of when placing the
"first commit's" message in the resulting squash.  This seems useful to me
as well.

At the same time, I can understand why the current implementation does
what it does.  So I'm not entirely sure what the correct answer is here.

I'll mention as well that I really like the decisions made for how this
command handles squashing a bunch of related fixups.  This "fixup
consolidation" is a use-case that this command may steal away from rebase
for me.  And the way a final amend! is handled in this case is what got me
thinking about it in the general case.

Thanks for the work on this topic!

@gitgitgadget-git

Copy link
Copy Markdown

Harald Nordgren wrote on the Git mailing list (how to reply to this email):

> This feature looks like it's coming together pretty well imo.  I just have
> one observation I want to comment on:
>
> I noticed that 'git history squash <range>', when --reedit-message is
> omitted, will ignore any amend! message in the range that targets the
> first folded commit.
>
> On the surface, this makes sense.  The feature is pretty explicit that
> it will faithfully stick with the first commit's message, unless
> modified by use of --reedit-message.
>
> However, this edge case is a little surprising, given that
> 'git history squash' seems to be aware of the semantics of fixup!, amend!,
> and squash! messages whether --reedit-message was given or not.  For instance,
> the default command notices when the range contains a squash! commit whose
> target is elsewhere (a useful feature).  It seems consistent then, that the
> default command would incorporate an amend! it is aware of when placing the
> "first commit's" message in the resulting squash.  This seems useful to me
> as well.
>
> At the same time, I can understand why the current implementation does
> what it does.  So I'm not entirely sure what the correct answer is here.
>
> I'll mention as well that I really like the decisions made for how this
> command handles squashing a bunch of related fixups.  This "fixup
> consolidation" is a use-case that this command may steal away from rebase
> for me.  And the way a final amend! is handled in this case is what got me
> thinking about it in the general case.
>
> Thanks for the work on this topic!

Thanks!

That's an interesting observation, I'll see what I can do about it.


Harald

@gitgitgadget-git

Copy link
Copy Markdown

Harald Nordgren wrote on the Git mailing list (how to reply to this email):

I made a fix for this and also took the opportunity to create test
helpers to clarify the tests.

Same pattern as the ones @Phillip Wood helped me with on my
'delete-merged' topic. I would like to push out a new version before
anyone needs to review the current tests -- the new version would be a
lot easier to look at. Should I?


Harald

@gitgitgadget-git

Copy link
Copy Markdown

Ben Knoble wrote on the Git mailing list (how to reply to this email):

> Le 14 juil. 2026 à 00:45, Matt Hunter <m@lfurio.us> a écrit :
> 
> On Fri Jul 10, 2026 at 5:06 AM EDT, Harald Nordgren via GitGitGadget wrote:
>> Adds git history squash <revision-range> to fold a range of commits.

[snip]

> I'll mention as well that I really like the decisions made for how this
> command handles squashing a bunch of related fixups.  This "fixup
> consolidation" is a use-case that this command may steal away from rebase
> for me.  And the way a final amend! is handled in this case is what got me
> thinking about it in the general case.
> 
> Thanks for the work on this topic!

Ditto! I suspect that using a combination of « git history squash » and « git replay » to emulate « git rebase » in non-interactive autosquash mode will be much faster, too, due to the differences in implementation. If that proves to be the case and we can safely do so with feature compatibility, I wonder if it will be worth making the non-interactive autosquash rebase actually delegate through a history squash + replay.

I’m sure there’s a few instances that couldn’t be done (for example when the special! commits cross the current range and upstream; that is, a fixup! for an upstream commit or some such oddity;; there are also conflicts to consider), but in the cases it can be it ought to be a performance win.

@gitgitgadget-git

Copy link
Copy Markdown

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

Ben Knoble <ben.knoble@gmail.com> writes:

>> Thanks for the work on this topic!
>
> Ditto! I suspect that using a combination of « git history squash
> » and « git replay » to emulate « git rebase » in non-interactive
> autosquash mode will be much faster, too, due to the differences
> in implementation. If that proves to be the case and we can safely
> do so with feature compatibility, I wonder if it will be worth
> making the non-interactive autosquash rebase actually delegate
> through a history squash + replay.

Yes, that would be an ideal future, and these efforts move us in
that direction.

> I’m sure there’s a few instances that couldn’t be done (for
> example when the special! commits cross the current range and
> upstream; that is, a fixup! for an upstream commit or some such
> oddity;; there are also conflicts to consider), but in the cases
> it can be it ought to be a performance win.

Since you assume "we can safely do so with feature compatibility"
above, once we are finished, there will, by definition, be no
such "special" commits that the combination cannot handle.  By
the time that happens, we will have replaced the internals of
"rebase [-i]" with a new implementation that does not need to
touch the working tree.

That would indeed be an exciting future.

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/history-squash on the Git mailing list:

The experimental 'git history' command has been taught a new 'squash'
subcommand to fold a range of commits into a single commit, with any
descendants replayed on top.

Expecting a reroll.
cf. <CAHwyqnVYQ6Sk=4ot6=5AbUdqxCrwS15xt_+wX3DB1h369CSqsA@mail.gmail.com>
source: <pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com>

@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-git-2337/HaraldNordgren/rebase-fixup-fold-v9

To fetch this version to local tag pr-git-2337/HaraldNordgren/rebase-fixup-fold-v9:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-git-2337/HaraldNordgren/rebase-fixup-fold-v9

@gitgitgadget-git

Copy link
Copy Markdown

This patch series is no longer integrated into seen.

@gitgitgadget-git

Copy link
Copy Markdown

This patch series was integrated into seen via 93453f1.

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/history-squash on the Git mailing list:

The experimental 'git history' command has been taught a new 'squash'
subcommand to fold a range of commits into a single commit, with any
descendants replayed on top.

Needs review.
source: <pull.2337.v9.git.git.1784128573.gitgitgadget@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>
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>
@@ -59,6 +59,10 @@ all advice messages.
forceDeleteBranch::

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Matt Hunter" wrote on the Git mailing list (how to reply to this email):

Hi Harald,

The new functionality for amend! messages seems to be working well, so I
dug a little deeper and found the following...

On Wed Jul 15, 2026 at 11:16 AM EDT, Harald Nordgren via GitGitGadget wrote:
> ++
> +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 HEAD~3..HEAD` folds the three most recent commits into
> +one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
> +while leaving the two newest commits in place. Several revisions may be
> +given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
> +already on `topic`. Rev-list options may also be given, but any that would
> +change how the range is walked are overridden with a warning.
> ++
> +The oldest commit's message is preserved by default, except that an `amend!`
> +commit targeting it replaces its message.

The new behavior from v9 is documented here, but...

>                                            Specify `--reedit-message` to edit
> +the resulting 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.
> ++
> +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it
> +targets is also in the range, so the fold does not silently absorb a
> +marker meant for a commit outside it. The body after an `amend!` subject
> +replaces the oldest commit's message when the marker targets that commit.

...a redundant explanation appears here too.  Personally, I think this
paragraph flows better if the 'The body after an `amend!`...targets that
commit.' sentence were removed.

> +As an exception, a range made up entirely of markers for one target is combined
> +into a single commit, keeping the last `amend!` message if there is one.
> ++
> +A branch or tag that points at a commit inside the range would be left
> +dangling once those commits are folded away, so with the default
> +`--update-refs=branches` the command refuses. Rerun with
> +`--update-refs=head` to rewrite only the current branch and leave such
> +refs pointing at the old commits.
> +
>  OPTIONS
>  -------
>  
> @@ -107,7 +147,8 @@ OPTIONS
>  	ref updates is generally safe.
>  
>  `--reedit-message`::
> -	Open an editor to modify the target commit's message.
> +	Open an editor to modify the rewritten commit's message. For `squash`
> +	the editor is pre-filled with the messages of all the folded commits.

At the moment of this patch, this is a false statement, though it is
made true by patch 5/5 pre-filling all messages.

> diff --git a/builtin/history.c b/builtin/history.c
> index cbba25096f..edf98a21d3 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> +
> +	repo_init_revisions(repo, &revs, NULL);
> +	revs.reverse = 1;
> +	revs.topo_order = 1;
> +	revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
> +	revs.simplify_history = 0;
> +	revs.boundary = 1;
> +
> +	strvec_push(&args, "ignored");
> +	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;
> +	}
> +
> +	if (revs.reverse != 1 || revs.topo_order != 1 ||
> +	    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||
> +	    revs.simplify_history != 0) {
> +		warning(_("ignoring rev-list options that would change how the "
> +			  "range is walked"));
> +		revs.reverse = 1;
> +		revs.topo_order = 1;
> +		revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
> +		revs.simplify_history = 0;
> +	}

Should revs.boundary still == 1 be asserted here too?

> +
> +	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", msg_source, message_template,
> +			      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]);

With this format string, the reflog will miss cases like:

    git history squash HEAD~5..HEAD ^origin/master

Only "squash: updating HEAD~5..HEAD" will be recorded in the log.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harald Nordgren wrote on the Git mailing list (how to reply to this email):

Thanks, good points!


Harald

while leaving the two newest commits in place. Several revisions may be
given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
already on `topic`. Rev-list options may also be given, but any that would
change how the range is walked are overridden with a warning.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Matt Hunter" wrote on the Git mailing list (how to reply to this email):

On Wed Jul 15, 2026 at 11:16 AM EDT, Harald Nordgren via GitGitGadget wrote:
> @@ -130,6 +131,15 @@ marker meant for a commit outside it. The body after an `amend!` subject
>  replaces the oldest commit's message when the marker targets that commit. As
>  an exception, a range made up entirely of markers for one target is combined
>  into a single commit, keeping the last `amend!` message if there is one.

> +The changes from every commit in the range are always folded in. Only the
> +message text differs.

This sentence kept puzzling me when I re-read this part.  That isn't to
say that it doesn't make sense on its own, or isn't correct.  But in
this context, I wasn't sure why it was included.

For whatever reason, the diff from your v8 made it click, and I believe
you're trying to explain the previous sentence about the last `amend!`,
stating that all the other _effects_ of the other fixup!s are also kept
even though the message changes.  Is that right?

If so, I might suggest removing this sentence too.  At least to my
brain, it doesn't contribute to my understanding of the command, and
makes the paragraph feel like it changes subject in the middle.

> +With `--reedit-message` the template mirrors `git rebase -i --autosquash`:
> +each `fixup!`, `squash!`, or `amend!` is grouped under the commit it
> +targets rather than shown in commit order. A `fixup!` message is dropped
> +(commented out in full), a `squash!` keeps its body with only the marker
> +subject commented, and an `amend!` replaces its target's message, unless
> +a `squash!` folded into that target first, in which case it keeps its
> +body like a `squash!`.

This bit that comes right after would possibly do better as its own
paragraph imo.

Thanks!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harald Nordgren wrote on the Git mailing list (how to reply to this email):

Good points!


Harald

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
authorship and taking the tree of the newest commit, then replays the
commits above the range on top. The squashed message comes from the
oldest commit by default, or from the body of the last amend! commit
targeting it. An editor opens with the selected message when
--reedit-message is given. A fixup!, squash! or amend! commit is refused
unless the commit it targets is also in the range, so the fold does not
silently absorb a marker meant for a commit outside it. The check runs
the range through todo_list_rearrange_squash(), which leaves such a
marker as a plain pick. Markers whose target is in the range fold in as
usual. As an exception, a range made up entirely of markers for one
target is combined anyway, taking its message from the last amend! if
there is one, so a batch of fixups for the same commit can be collapsed.

The range is read like the arguments to "git rev-list", so several
revisions such as "HEAD~3..HEAD ^topic" may be given, and rev-list
options are accepted too. As "git replay" does, the walk options the fold
relies on are forced after setup_revisions() and a warning is printed if
an option changed them, so the first commit returned is the range's
oldest and its parent is the base regardless of what the user passed
(including after a "--"). 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>
When "git rebase -i" squashes commits it builds an editor template with a
"This is a combination of N commits." banner, a "This is the 1st/Nth
commit message:" header above each kept message (or a "will be skipped"
header for a dropped one), and a commented-out subject for any fixup!,
squash! or amend! commit. The banner, the headers and the
subject-commenting all live in static helpers in sequencer.c wired to the
rebase state, so no other command can present a squash the same way.

Pull the three pieces out into add_squash_combination_header(),
add_squash_message_header() (which takes a flag for the "will be skipped"
variant) and squash_subject_comment_len(), and use them from
update_squash_messages() and append_squash_message(). Also move the
todo_item_flags enum to the header, so a caller reading the output of
todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)
from a plain fixup!. A later change reuses all of this to give "git
history squash --reedit-message" the same template.

No change in behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
By default "git history squash" reuses the oldest commit's message, or
the replacement body from an amend! commit targeting it. When
--reedit-message is given it only reopened that selected message, so the
messages of the other commits in the range were lost.

Gather the message of every commit in the range and build the same editor
template that "git rebase -i --autosquash" shows for a squash, reusing
add_squash_combination_header(), add_squash_message_header() and
squash_subject_comment_len(). Feed the range through
todo_list_rearrange_squash() so that each fixup!, squash! or amend! is
grouped under the commit it targets rather than shown in commit order,
exactly as autosquash would arrange them.

Only the message text differs, the changes are always folded in. A fixup!
message is commented out in full under a "will be skipped" header, a
squash! keeps its body with only the marker subject commented, and an
amend! replaces its target's message unless a squash! already folded into
that target, in which case it behaves like a squash!.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/history-squash on the Git mailing list:

The experimental 'git history' command has been taught a new 'squash'
subcommand to fold a range of commits into a single commit, with any
descendants replayed on top.

Needs review.
source: <pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com>

@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-git-2337/HaraldNordgren/rebase-fixup-fold-v10

To fetch this version to local tag pr-git-2337/HaraldNordgren/rebase-fixup-fold-v10:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-git-2337/HaraldNordgren/rebase-fixup-fold-v10

@gitgitgadget-git

Copy link
Copy Markdown

"Matt Hunter" wrote on the Git mailing list (how to reply to this email):

On Mon Jul 20, 2026 at 4:26 AM EDT, Harald Nordgren via GitGitGadget wrote:
> Adds git history squash <revision-range> to fold a range of commits.
>
> Changes in v10:
>
>  * Record the full revision expression in squash reflog.
>  * Preserve the boundary-walk invariant when sanitizing rev-list options.
>  * Clarify amend! and --reedit-message documentation.
>

v10 looks good to me!
Thanks!

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/history-squash on the Git mailing list:

The experimental 'git history' command has been taught a new 'squash'
subcommand to fold a range of commits into a single commit, with any
descendants replayed on top.

Needs review.
source: <pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com>

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/history-squash on the Git mailing list:

The experimental 'git history' command has been taught a new 'squash'
subcommand to fold a range of commits into a single commit, with any
descendants replayed on top.

Will merge to 'next'.
cf. <DK1KIF2OI8IF.11188A3YEQV1C@lfurio.us>
cf. <DK1KIH6CXW0X.1U2V3GU8L6HB7@lfurio.us>
source: <pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants