history: add squash subcommand to fold a range#2337
Conversation
5f5f6cf to
b475252
Compare
|
Related: gitgitgadget#1135 |
38b3973 to
bd1bc62
Compare
|
/submit |
|
Submitted as pull.2337.git.git.1781465141.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
cb0d34c to
7b87781
Compare
|
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?
|
30409d4 to
e775fd9
Compare
|
/submit |
|
Submitted as pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
This patch series is no longer integrated into seen. |
|
This patch series was integrated into seen via 370f1fd. |
|
There was a status update in the "Cooking" section about the branch 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> |
|
"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! |
|
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 |
|
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 |
|
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. |
|
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. |
|
There was a status update in the "Cooking" section about the branch 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> |
|
/submit |
|
Submitted as pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
This patch series is no longer integrated into seen. |
|
This patch series was integrated into seen via 93453f1. |
|
There was a status update in the "Cooking" section about the branch 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:: | |||
There was a problem hiding this comment.
"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.There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
"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!There was a problem hiding this comment.
Harald Nordgren wrote on the Git mailing list (how to reply to this email):
Good points!
HaraldFolding 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>
|
There was a status update in the "Cooking" section about the branch 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> |
|
/submit |
|
Submitted as pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
"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! |
|
There was a status update in the "Cooking" section about the branch 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> |
|
There was a status update in the "Cooking" section about the branch 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> |
Adds
git history squash <revision-range>to fold a range of commits.Changes in v10:
amend!and--reedit-messagedocumentation.Changes in v9:
amend!targeting the oldest folded commit as the default squashed message. Ignoreamend!markers targeting later commits while selecting that replacement message.Changes in v8:
--reedit-messagenow builds the same editor template asgit rebase -i --autosquash:fixup!,squash!andamend!commits are grouped under the commit they target instead of shown in commit order, and anamend!replaces its target's message.fixup!,squash!oramend!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 lastamend!message.--ancestry-pathensures only commits descended from the base are folded, and a range reaching more than one base is rejected.git replaydoes, forcing the walk order back with a warning, which also fixesgit history squash -- --reverseslipping past the previous option check.squashsubcommand rather than making--reedit-messagethe default or renaming the command.Changes in v7:
--reedit-messagenow builds the same editor templategit rebase -ishows for a squash (acombination of N commitsbanner with each folded message under its own header) and follows autosquash for markers: afixup!message falls out (commented under awill be skippedheader), while asquash!oramend!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.git rebase -i's squash-message code: a preparatorysequencer:commit extracts the banner, header and marker-comment helpers so both rebase andgit history squashbuild the identical template from one source.fixup!,squash!oramend!, since the marker's target cannot be inside the range.<revision-range>, and spell outHEADinstead of@in the documentation and examples.Changes in v6:
git history squashnow accepts multiple revision arguments, read like the arguments togit-rev-list, so a compound range such as@~3.. ^topicworks.@^!) is rejected with "nothing to squash" (this also covers the@^!-style example that previously succeeded silently).fixup!/squash!/amend!handling, rewording, merge-parent and ref behavior.Changes in v5:
--ancestry-path, so only commits descended from the base are folded; a single revision such asHEADorHEAD~1is now rejected as "not a<base>..<tip>range" rather than treated as a squash down to the root.--ancestry-pathsuggestion; the multi-base rejection is unchanged, so a side branch that forked before the base and merged in is still refused.Changes in v4:
git history squashnow detects when another ref points at a commit inside the range being folded and refuses, with anadvice.historyUpdateRefshint to use--update-refs=head.Changes in v3:
git rebaseand into a newgit history squash <revision-range>subcommand, per the list discussion.git rebase --squashis dropped.git history squash @~3..,git history squash @~5..@~2), folding it into the oldest commit and replaying any descendants on top.--reedit-messageseeds 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