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
|
Phillip Wood wrote on the Git mailing list (how to reply to this email): On 01/07/2026 14:47, Junio C Hamano wrote:
> Phillip Wood <phillip.wood123@gmail.com> writes:
> >> The reason we're introducing the history command is to experiment with
>> providing a better user interface for rewriting history without being
>> bound by the limitations of "git rebase". So I think it would entirely
>> appropriate to try a different format for the squash message here. If it
>> turns out to be a success then we can see if we want to use it in "git
>> rebase" as well.
> > Do we know concretely things that are bad in the current way "rebase
> -i" works, so that we can experiment deviation from? Yes - if you apply the way "rebase -i" works to multiple commits you can end up with a message template that has a screen full of commented lines between uncommitted parts of the message. See the example below from earlier in the thread. It is not so much of a problem in "rebase -i" because it only fixes up a single commit at a time so all the commented messages end up at the top of the buffer and at worst you have a few "# fixup! ..." or "# squash! ..." lines mixed in with the uncommitted text.
# This is the combination of 4 commits
# This is the first commit message
Base subject
Base body
# This is the second commit message
# Another subject
# Another body
# This is the third commit message
# fixup! Base subject
# This is the fourth commit message
# amend! Another subject
A better subject
A better body
Thanks
Phillip |
|
Junio C Hamano wrote on the Git mailing list (how to reply to this email): Phillip Wood <phillip.wood123@gmail.com> writes:
> Yes - if you apply the way "rebase -i" works to multiple commits you can
> end up with a message template that has a screen full of commented lines
> between uncommitted parts of the message. See the example below from
> earlier in the thread. It is not so much of a problem in "rebase -i"
> because it only fixes up a single commit at a time so all the commented
> messages end up at the top of the buffer and at worst you have a few "#
> fixup! ..." or "# squash! ..." lines mixed in with the uncommitted text.
>
> # This is the combination of 4 commits
> # This is the first commit message
> Base subject
>
> Base body
>
> # This is the second commit message
> # Another subject
>
> # Another body
>
> # This is the third commit message
> # fixup! Base subject
>
> # This is the fourth commit message
> # amend! Another subject
> A better subject
>
> A better body
In the example, the second one becomes completely empty?
Is the proposal not to show any messages that will be discarded
anyway and not even show them in commented form? I think that makes
sense, and leaving only commit titles for these commits that would
not contribute to the text in the editor given to the user to edit
would indeed be an improvement. For the same reason, as "# amend!"
will replace the message wholesale, it would also be a good idea for
the first commit to be hidden like all the other commits that would
not contribute to the text, so an improved version of the above may
be:
# This is the combination fo 4 commits
# 1. Base subject
# 2. Another subject
# 3. fixup! Base subject
# 4. amend! Another subject
A better subject
A better body.
or something? Is that the direction you want us to take?
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, replaying any descendants on top. Waiting for response(s) to review comment(s). cf. <xmqqse65zyhw.fsf@gitster.g> cf. <akIQLM6xZTHBudWT@pks.im> cf. <3b3af3ef-a043-4af9-964e-429237789c97@gmail.com> source: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com> |
|
Patrick Steinhardt wrote on the Git mailing list (how to reply to this email): On Tue, Jun 30, 2026 at 03:01:43PM +0100, Phillip Wood wrote:
> On 30/06/2026 03:55, Matt Hunter wrote:
[?nip]
> > This is probably a larger question, since (according to the man page) it
> > affects the other 'git history' commands as well. When I run
> > 'git history ...' and discover that I made a mistake after inspecting
> > the results, is there a fool-proof way to undo the change and return to
> > the previous state? My first thought was to run 'git reset --hard ...',
> > but the default behavior of --update-refs (moving other branches) can
> > make this more complicated.
>
> Yes this is a problem to which we don't have a good solution at the moment.
> I believe Jujitsu and git-branchless both have some kind of operations log
> that lets you revert a whole operation rather than just a single ref-update.
> We'd need some way to tie all the ref updates from a single ref transaction
> together either by logging the separately or adding some form of transaction
> id to the reflog. That would be a big change.
Yeah, agreed. I think that the reflog is insufficient for a lot of Git's
operations and that it is way too hard to reason about. It's both too
detailed and not detailed enough at the same time:
- It provides way too much detail about individual reference updates
when all the user cares about is the high-level operation on the
logical level.
- It does not provide enough detail to give information about what the
high-level operation even was.
I don't think that we can fix the reflog to work properly in this case.
But I certainly think that we should explore whether we can eventually
introduce something like an oplog, as well, so that we can easily have
the equivalent of `jj undo`.
It's something that I'd eventually want to get to, but it'll take a
while. So if anybody else beats me to it then please go ahead :)
Patrick |
|
Phillip Wood wrote on the Git mailing list (how to reply to this email): On 01/07/2026 18:41, Junio C Hamano wrote:
> Phillip Wood <phillip.wood123@gmail.com> writes:
> >> Yes - if you apply the way "rebase -i" works to multiple commits you can
>> end up with a message template that has a screen full of commented lines
>> between uncommitted parts of the message. See the example below from
>> earlier in the thread. It is not so much of a problem in "rebase -i"
>> because it only fixes up a single commit at a time so all the commented
>> messages end up at the top of the buffer and at worst you have a few "#
>> fixup! ..." or "# squash! ..." lines mixed in with the uncommitted text.
>>
>> # This is the combination of 4 commits
>> # This is the first commit message
>> Base subject
>>
>> Base body
>>
>> # This is the second commit message
>> # Another subject
>>
>> # Another body
>>
>> # This is the third commit message
>> # fixup! Base subject
>>
>> # This is the fourth commit message
>> # amend! Another subject
>> A better subject
>>
>> A better body
> > In the example, the second one becomes completely empty?
Yes because there is an amend! commit that replaces its message
> Is the proposal not to show any messages that will be discarded
> anyway and not even show them in commented form? I think that makes
> sense, and leaving only commit titles for these commits that would
> not contribute to the text in the editor given to the user to edit
> would indeed be an improvement. For the same reason, as "# amend!"
> will replace the message wholesale, it would also be a good idea for
> the first commit to be hidden like all the other commits that would
> not contribute to the text,
But the amend! commit does not target the first commit - it replaces the message of the second commit so I think we should keep the first message as shown below. You can see a different example with a squash! message at [1]
> # This is the combination fo 4 commits
> # 1. Base subject
> # 2. Another subject
> # 3. fixup! Base subject
> # 4. amend! Another subject
Base subject
Base body
# -------------------------------------
> A better subject
> > A better body.
Thanks
Phillip
[1] https://lore.kernel.org/git/4b505228-4846-4a48-9255-e249f4e70a1f@gmail.com |
|
Junio C Hamano wrote on the Git mailing list (how to reply to this email): Patrick Steinhardt <ps@pks.im> writes:
> Yeah, agreed. I think that the reflog is insufficient for a lot of Git's
> operations and that it is way too hard to reason about.
As the reflog has never been about undoing, this is understandable.
Things like @{-N} notation and "git push --force-if-includes" make
good use of the "what was this ref pointing at historically?"
information, so they give us a proof that it is possible to
programmatically go back and find the necessary state, though. |
|
Phillip Wood wrote on the Git mailing list (how to reply to this email): Hi Harald
On 06/07/2026 09:50, Harald Nordgren via GitGitGadget wrote:
> Adds git history squash <revision-range> to fold a range of commits.
> > Changes in v7:
> > * --reedit-message
There was some discussion [1] about making that the default and renaming it - was that overlooked? If not it would be helpful to comment on those discussions to explain why you don't think it is a good idea.
> 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.
Rebase re-orders commits so that fixups immediately follow their target - do you do that here? I think that is very relevant because here we may be dealing with several different commits each being targeted by a set of fixups and presenting them mixed together will be confusing. When rebase sees an "amend!" commit it comments out the message that is being replaced - it is not clear from this description whether that happens here.
As I've said before I think we would be better off with a summary of the commits that are being squashed and a more compact template message that only contains the messages we want to keep [2]. What are the advantages of having lots of commented lines (or redundant messages if you don't comment out the original when there is an amend! commit) in the middle of the template message?
> * 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.
I think it should allow squashing a bunch of fixups together though. I thought there was a plan [3] to refuse to squash a fixup unless the range included its target.
The range-diff does not show any input sanitization - what happens when the user passes "--reverse" for example? As I said in [4] we should copy what "git replay" does to sanity check the rev-list options, otherwise we've got no idea whether the parent of the first commit returned by get_revision() is the commit we want to use as the parent of the squashed commit.
Thanks
Phillip
[1] https://lore.kernel.org/git/3c35bd17-e884-432d-a400-36a89964ed89@gmail.com/
[2] https://lore.kernel.org/git/4b505228-4846-4a48-9255-e249f4e70a1f@gmail.com
[3] https://lore.kernel.org/git/CAHwyqnWQmObWr3N81_EU6F13iyKp3FfY8KSNFfoAjS4r_0qJrQ@mail.gmail.com/
[4] https://lore.kernel.org/git/f3fe7ff2-3ce9-4e90-95e7-8c620de5628a@gmail.com/
> * 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.
> > Harald Nordgren (5):
> 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
> sequencer: extract helpers for the squash message markers
> history: re-edit a squash with every message
> > Documentation/config/advice.adoc | 4 +
> Documentation/git-history.adoc | 49 ++-
> advice.c | 1 +
> advice.h | 1 +
> builtin/history.c | 390 +++++++++++++++++--
> sequencer.c | 64 ++--
> sequencer.h | 23 ++
> t/meson.build | 1 +
> t/t3455-history-squash.sh | 632 +++++++++++++++++++++++++++++++
> 9 files changed, 1099 insertions(+), 66 deletions(-)
> create mode 100755 t/t3455-history-squash.sh
> > > base-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v7
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v7
> Pull-Request: https://github.com/git/git/pull/2337
> > Range-diff vs v6:
> > 1: fea6b79e60 = 1: 56ed8fadbb history: extract helper for a commit's parent tree
> 2: e2674e0bc4 = 2: 212e9c228f history: give commit_tree_ext a message template
> 3: 811e393ab4 ! 3: cf3346a1cd 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, 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.
> + replays the commits above the range on top. The squashed message comes
> + from the oldest commit, or from an editor with --reedit-message. As that
> + message is reused, a range whose oldest commit is a fixup!, squash! or
> + amend! is refused, since the marker's target cannot be in the range.
> > 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.
> + arguments such as "HEAD~3..HEAD ^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: 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]
> ++git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
> > DESCRIPTION
> -----------
> +@@ Documentation/git-history.adoc: at once.
> + LIMITATIONS
> + -----------
> +
> +-This command does not (yet) work with histories that contain merges. You
> +-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
> ++This command does not (yet) replay merge commits onto the rewritten
> ++history: if a commit that would be replayed is a merge, the operation is
> ++rejected, and you should use linkgit:git-rebase[1] with the
> ++`--rebase-merges` flag instead. The `squash` subcommand can still fold a
> ++merge that lies inside the range, as long as the range has a single base.
> +
> + Furthermore, the command does not support operations that can result in merge
> + conflicts. This limitation is by design as history rewrites are not intended to
> @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
> It is invalid to select either all or no hunks, as that would lead to
> one of the commits becoming empty.
> @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
> ++
> +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`.
> ++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. _<revision-range>_ is read
> ++like the arguments to linkgit:git-rev-list[1], so several arguments may be
> ++given, for example `HEAD~3..HEAD ^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
> @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
> +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.
> ++Because the oldest commit's message is reused, the range may not begin
> ++with a `fixup!`, `squash!`, or `amend!` commit, whose target is
> ++necessarily outside the range.
> +++
> ++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
> -------
> > +@@ Documentation/git-history.adoc: 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.
> +
> + `--empty=(drop|keep|abort)`::
> + Control what happens when a commit becomes empty as a result of the
> > ## advice.c ##
> @@ advice.c: static struct {
> @@ builtin/history.c
> #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]")
> ++ N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>")
> > static void change_data_free(void *util, const char *str UNUSED)
> {
> @@ builtin/history.c: out:
> + return ret;
> +}
> +
> ++static int reject_fixupish_oldest(struct repository *repo,
> ++ struct commit *oldest)
> ++{
> ++ const char *message, *subject;
> ++ int ret = 0;
> ++
> ++ message = repo_logmsg_reencode(repo, oldest, NULL, NULL);
> ++ find_commit_subject(message, &subject);
> ++ if (starts_with(subject, "fixup! ") ||
> ++ starts_with(subject, "squash! ") ||
> ++ starts_with(subject, "amend! "))
> ++ ret = error(_("the range begins with a fixup!, squash! or amend! "
> ++ "commit whose target is not in the range"));
> ++ repo_unuse_commit_buffer(repo, oldest, message);
> ++ return ret;
> ++}
> ++
> +struct interior_ref_cb {
> + const struct oidset *interior;
> + const char *name;
> @@ builtin/history.c: out:
> + if (ret < 0)
> + goto out;
> +
> ++ ret = reject_fixupish_oldest(repo, oldest);
> ++ if (ret < 0)
> ++ goto out;
> ++
> + if (action == REF_ACTION_BRANCHES) {
> + struct interior_ref_cb cb = { .interior = &interior };
> +
> @@ t/t3455-history-squash.sh (new)
> +
> +test_expect_success 'squashes a range into a single commit without changing the tree' '
> + 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 &&
> @@ t/t3455-history-squash.sh (new)
> + test_cmp expect actual
> +'
> +
> -+test_expect_success 'keeps the oldest message even if it is a fixup!' '
> ++test_expect_success 'refuses a range whose oldest commit is a fixup!' '
> + git reset --hard start &&
> + test_commit --no-tag "fixup! something" file b &&
> -+ test_commit tail file c &&
> ++ test_commit --no-tag tail file c &&
> ++ head_before=$(git rev-parse HEAD) &&
> ++
> ++ test_must_fail git history squash start.. 2>err &&
> ++ test_grep "target is not in the range" err &&
> ++ test_cmp_rev "$head_before" HEAD
> ++'
> ++
> ++test_expect_success 'does not interpret squash! or amend! markers' '
> ++ git reset --hard start &&
> ++ test_commit --no-tag marker-oldest file b &&
> ++ git commit --allow-empty -m "squash! marker-oldest" &&
> ++ git commit --allow-empty -m "amend! marker-oldest" &&
> ++ test_commit --no-tag marker-newest file c &&
> +
> + git history squash start.. &&
> +
> ++ git rev-list --count start..HEAD >count &&
> ++ echo 1 >expect &&
> ++ test_cmp expect count &&
> + git log --format="%s" -1 >actual &&
> -+ echo "fixup! something" >expect &&
> ++ echo marker-oldest >expect &&
> + test_cmp expect actual
> +'
> +
> @@ t/t3455-history-squash.sh (new)
> + 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 &&
> -: ---------- > 4: 001356db93 sequencer: extract helpers for the squash message markers
> 4: 4edf012b77 ! 5: 615fe4dd3f history: re-edit a squash with every message
> @@ Commit 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.
> + Gather the messages of every commit in the range, oldest first, and build
> + the same editor template that "git rebase -i" shows for a squash, using
> + add_squash_combination_header(), add_squash_message_header() and
> + squash_subject_comment_len(). Only the message text differs, the changes
> + are always folded in. Following autosquash, a fixup!'s message is
> + commented out in full under a "will be skipped" header, while a squash! or
> + amend! keeps its body with only the marker subject commented.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> > ## Documentation/git-history.adoc ##
> -@@ 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`.
> +@@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], so several arguments may be
> + given, for example `HEAD~3..HEAD ^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
> @@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several
> 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.
> + +
> + Because the oldest commit's message is reused, the range may not begin
> + with a `fixup!`, `squash!`, or `amend!` commit, whose target is
> +-necessarily outside the range.
> ++necessarily outside the range. The changes from every commit in the range
> ++are always folded in. Only the message text differs. With
> ++`--reedit-message` the template mirrors `git rebase -i`: the message of a
> ++`fixup!` elsewhere in the range is commented out in full, while a
> ++`squash!` or `amend!` keeps its message body with only the marker subject
> ++commented, so you can fold the remark into the result.
> + +
> + 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
> > ## builtin/history.c ##
> @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data)
> @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
> + struct commit *tip,
> + struct strbuf *out)
> +{
> ++ struct commit_list *commits = NULL, **tail = &commits, *c;
> + struct rev_info revs;
> + struct commit *commit;
> + struct strvec args = STRVEC_INIT;
> -+ int n = 0, ret;
> ++ int n = 0, total, ret;
> +
> + repo_init_revisions(repo, &revs, NULL);
> + strvec_push(&args, "ignored");
> @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
> + goto out;
> + }
> +
> -+ while ((commit = get_revision(&revs))) {
> ++ while ((commit = get_revision(&revs)))
> ++ tail = &commit_list_insert(commit, tail)->next;
> ++ total = commit_list_count(commits);
> ++
> ++ for (c = commits; c; c = c->next) {
> + const char *message, *body;
> -+ struct strbuf one = STRBUF_INIT;
> ++ size_t commented_len;
> ++ int skip;
> +
> -+ message = repo_logmsg_reencode(repo, commit, NULL, NULL);
> ++ message = repo_logmsg_reencode(repo, c->item, 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);
> ++ skip = starts_with(body, "fixup! ");
> ++ commented_len = skip ? strlen(body) :
> ++ squash_subject_comment_len(body, 1);
> ++
> ++ if (!n)
> ++ add_squash_combination_header(out, total);
> + strbuf_addch(out, '\n');
> ++ add_squash_message_header(out, ++n, skip);
> ++ strbuf_addstr(out, "\n\n");
> ++ strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
> ++ strbuf_addstr(out, body + commented_len);
> ++ strbuf_complete_line(out);
> +
> -+ strbuf_release(&one);
> -+ repo_unuse_commit_buffer(repo, commit, message);
> ++ repo_unuse_commit_buffer(repo, c->item, message);
> + }
> +
> + ret = 0;
> +
> +out:
> ++ commit_list_free(commits);
> + reset_revision_walk();
> + release_revisions(&revs);
> + strvec_clear(&args);
> @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
> + test_commit re-three file d &&
> +
> + write_script editor <<-\EOF &&
> -+ cp "$1" buffer &&
> ++ cat "$1" >edited &&
> + 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 &&
> ++ cat >expect <<-EOF &&
> ++ # This is a combination of 3 commits.
> ++ # This is the 1st commit message:
> ++
> ++ re-one subject
> ++
> ++ re-one body line
> ++
> ++ # This is the commit message #2:
> ++
> ++ re-two
> ++
> ++ # This is the commit message #3:
> ++
> ++ re-three
> ++
> ++ # Please enter the commit message for the squash changes. Lines starting
> ++ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
> ++ # Changes to be committed:
> ++ # modified: file
> ++ #
> ++ EOF
> ++ test_cmp expect edited &&
> + echo combined >expect &&
> ++ git log --format="%s" -1 >actual &&
> + test_cmp expect actual
> +'
> +
> ++test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
> ++ git reset --hard start &&
> ++ test_commit --no-tag mark-base file b &&
> ++ printf "fixup! mark-base\n\nfixup body\n" >msg &&
> ++ echo c >file &&
> ++ git add file &&
> ++ git commit -qF msg &&
> ++ printf "squash! mark-base\n\nsquash remark\n" >msg &&
> ++ echo d >file &&
> ++ git add file &&
> ++ git commit -qF msg &&
> ++ printf "amend! mark-base\n\namended message\n" >msg &&
> ++ echo e >file &&
> ++ git add file &&
> ++ git commit -qF msg &&
> ++
> ++ write_script editor <<-\EOF &&
> ++ cat "$1" >edited
> ++ EOF
> ++ test_set_editor "$(pwd)/editor" &&
> ++ git history squash --reedit-message start.. &&
> ++
> ++ cat >expect <<-EOF &&
> ++ # This is a combination of 4 commits.
> ++ # This is the 1st commit message:
> ++
> ++ mark-base
> ++
> ++ # The commit message #2 will be skipped:
> ++
> ++ # fixup! mark-base
> ++ #
> ++ # fixup body
> ++
> ++ # This is the commit message #3:
> ++
> ++ # squash! mark-base
> ++
> ++ squash remark
> ++
> ++ # This is the commit message #4:
> ++
> ++ # amend! mark-base
> ++
> ++ amended message
> ++
> ++ # Please enter the commit message for the squash changes. Lines starting
> ++ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
> ++ # Changes to be committed:
> ++ # modified: file
> ++ #
> ++ EOF
> ++ test_cmp expect edited &&
> ++ git log -1 --format="%B" >final &&
> ++ test_grep ! "fixup body" final &&
> ++ test_grep "squash remark" final &&
> ++ test_grep "amended message" final
> ++'
> ++
> +test_expect_success '--reedit-message aborts on an empty message' '
> + git reset --hard three &&
> + head_before=$(git rev-parse HEAD) &&
> @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
> + test_cmp_rev "$head_before" HEAD
> +'
> +
> - test_expect_success '--dry-run predicts the rewrite without performing it' '
> + test_expect_success '--update-refs=head only moves HEAD' '
> git reset --hard three &&
> - head_before=$(git rev-parse HEAD) &&
> + git branch -f other HEAD &&
> |
|
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 history squash <revision-range> to fold a range of commits.
What I saw in the range-diff looked all reasonable.
> 3: 811e393ab4 ! 3: cf3346a1cd 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, 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.
> + replays the commits above the range on top. The squashed message comes
> + from the oldest commit, or from an editor with --reedit-message. As that
> + message is reused, a range whose oldest commit is a fixup!, squash! or
> + amend! is refused, since the marker's target cannot be in the range.
> ...
> -+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
> ++git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
> ++static int reject_fixupish_oldest(struct repository *repo,
> ++ struct commit *oldest)
> ++{
> ++ const char *message, *subject;
> ++ int ret = 0;
> ++
> ++ message = repo_logmsg_reencode(repo, oldest, NULL, NULL);
> ++ find_commit_subject(message, &subject);
> ++ if (starts_with(subject, "fixup! ") ||
> ++ starts_with(subject, "squash! ") ||
> ++ starts_with(subject, "amend! "))
> ++ ret = error(_("the range begins with a fixup!, squash! or amend! "
> ++ "commit whose target is not in the range"));
> ++ repo_unuse_commit_buffer(repo, oldest, message);
> ++ return ret;
> ++}
Nice. I often see myself getting rescued by the corresponding sanity
checks in the sequencer.
Will replace. Thanks. |
|
Harald Nordgren wrote on the Git mailing list (how to reply to this email): > There was some discussion [1] about making that the default and renaming
> it - was that overlooked? If not it would be helpful to comment on those
> discussions to explain why you don't think it is a good idea.
Not overlooked, but I side-stepped it because the discussion died
down, and yes I don't agree that it needs to be the default. I could
have mentioned my thinking in the cover letter.
> > 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.
>
> Rebase re-orders commits so that fixups immediately follow their target
> - do you do that here? I think that is very relevant because here we may
> be dealing with several different commits each being targeted by a set
> of fixups and presenting them mixed together will be confusing.
No, I'm not doing that now, but I can take a look at that.
> I think it should allow squashing a bunch of fixups together though. I
> thought there was a plan [3] to refuse to squash a fixup unless the
> range included its target.
I attempted this with reject_fixupish_oldest(), assuming only the
first commit needs to be checked as not being a fixup/squash/amend.
But now I realize that maybe we need to check all of the commits, and
also check if the target is in the range or not. It just makes the
logic a lot bigger.
> The range-diff does not show any input sanitization - what happens when
> the user passes "--reverse" for example? As I said in [4] we should copy
> what "git replay" does to sanity check the rev-list options, otherwise
> we've got no idea whether the parent of the first commit returned by
> get_revision() is the commit we want to use as the parent of the
> squashed commit.
Yeah, good point.
Harald |
|
Harald Nordgren wrote on the Git mailing list (how to reply to this email): > > The range-diff does not show any input sanitization - what happens when
> > the user passes "--reverse" for example? As I said in [4] we should copy
> > what "git replay" does to sanity check the rev-list options, otherwise
> > we've got no idea whether the parent of the first commit returned by
> > get_revision() is the commit we want to use as the parent of the
> > squashed commit.
>
> Yeah, good point.
Well, the code already blocks "--reverse" and other unknown options,
but I can clarify that better in the commit message.
Harald |
|
Phillip Wood wrote on the Git mailing list (how to reply to this email): Hi Harald
On 07/07/2026 09:55, Harald Nordgren wrote:
>>> The range-diff does not show any input sanitization - what happens when
>>> the user passes "--reverse" for example? As I said in [4] we should copy
>>> what "git replay" does to sanity check the rev-list options, otherwise
>>> we've got no idea whether the parent of the first commit returned by
>>> get_revision() is the commit we want to use as the parent of the
>>> squashed commit.
>>
>> Yeah, good point.
> > Well, the code already blocks "--reverse" and other unknown options,
> but I can clarify that better in the commit message.
Well it accepts
git history squash -- --reverse ...
because after calling parse_options() everything after the "--" is passed to setup_revisions(). There was some discussion about accepting rev-list options [2] so it would have been helpful to reference that in the cover letter. The cover letter should explain both the changes you have made and the suggestions that were discussed that have not been implemented so readers can get an overview of how this version relates to the previous discussion. Without that it is impossible to know if you disagree with a suggestion or have just forgotten it.
"git replay" supports arbitrary rev-list options by passing
PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT
to parse_options(), then passing the remaining options to setup_revisions(). After that it checks the various members of `struct rev_info` that it cares about are still set appropriately.
Thanks
Phillip
[1] https://lore.kernel.org/git/xmqqzf0dwalx.fsf@gitster.g |
|
Phillip Wood wrote on the Git mailing list (how to reply to this email): Hi Harald
On 07/07/2026 08:51, Harald Nordgren wrote:
>> There was some discussion [1] about making that the default and renaming
>> it - was that overlooked? If not it would be helpful to comment on those
>> discussions to explain why you don't think it is a good idea.
> > Not overlooked, but I side-stepped it because the discussion died
> down, and yes I don't agree that it needs to be the default. I could
> have mentioned my thinking in the cover letter.
> >>> 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.
>>
>> Rebase re-orders commits so that fixups immediately follow their target
>> - do you do that here? I think that is very relevant because here we may
>> be dealing with several different commits each being targeted by a set
>> of fixups and presenting them mixed together will be confusing.
> > No, I'm not doing that now, but I can take a look at that.
That's great, it is fine to punt things like this which require quite a bit of work to implement to a later re-roll but please be clear in the cover letter so reviewers know what to expect.
>> I think it should allow squashing a bunch of fixups together though. I
>> thought there was a plan [3] to refuse to squash a fixup unless the
>> range included its target.
> > I attempted this with reject_fixupish_oldest(), assuming only the
> first commit needs to be checked as not being a fixup/squash/amend.
> > But now I realize that maybe we need to check all of the commits, and
> also check if the target is in the range or not. It just makes the
> logic a lot bigger.
Yes it is a bit more involved. If the first commit is a fixup! then we should allow the user to squash other fixups with the same target and take the message from the last "amend!" commit if we see one. If there are other commits it the range then we should refuse to squash as you do here.
If the first target is not a fixup then we should refuse fixup commits whose target we have not seen. As well as exact subject matches "git rebase" accepts prefix matches and "fixup! $objectid". I think it is fine to skip the prefix matches to start with here. The $objectid matches shouldn't be too much extra work and I think they are worth supporting because if I remember correctly git-gui creates them. Another gotcha is that fixuping up a fixup prepends a "fixup!" to the subject line so you need to be able to handle things like
fixup! fixup! the real target
fixup! amend! the real target
squash! fixup! the real target
etc. Hopefully looking at the code that handles fixups in the sequencer will help
Thanks
Phillip |
|
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, replaying any descendants on top. Waiting for response(s) to review comment(s). cf. <38493ca6-8fdd-4b6c-9972-5145f3bf0aa4@gmail.com> source: <pull.2337.v7.git.git.1783327849.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, replaying any descendants on top. Waiting for response(s) to review comment(s). cf. <38493ca6-8fdd-4b6c-9972-5145f3bf0aa4@gmail.com> source: <pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com> |
|
/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! |
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>
|
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 |
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>
|
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> |
Adds
git history squash <revision-range>to fold a range of commits.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