* [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
@ 2026-06-14 19:25 Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
` (3 more replies)
0 siblings, 4 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-14 19:25 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren
Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits
into its oldest one, reusing that commit's message.
Related idea: https://github.com/gitgitgadget/git/issues/1135
Harald Nordgren (2):
t3415: remove prepare-commit-msg hook after use
rebase: add --fixup-all to fold a range
Documentation/git-rebase.adoc | 11 ++++
builtin/rebase.c | 13 ++++-
sequencer.c | 24 +++++++-
sequencer.h | 2 +-
t/t3415-rebase-autosquash.sh | 106 ++++++++++++++++++++++++++++++++++
5 files changed, 152 insertions(+), 4 deletions(-)
base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v1
Pull-Request: https://github.com/git/git/pull/2337
--
gitgitgadget
^ permalink raw reply [flat|nested] 23+ messages in thread
* [PATCH 1/2] t3415: remove prepare-commit-msg hook after use
2026-06-14 19:25 [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Harald Nordgren via GitGitGadget
@ 2026-06-14 19:25 ` Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 2/2] rebase: add --fixup-all to fold a range Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
3 siblings, 0 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-14 19:25 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
The "pick and fixup respect commit.cleanup" test left its
prepare-commit-msg hook in place, leaking it into later tests. Remove it
with test_when_finished.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
t/t3415-rebase-autosquash.sh | 1 +
1 file changed, 1 insertion(+)
diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh
index 5033411a43..8964d1cc88 100755
--- a/t/t3415-rebase-autosquash.sh
+++ b/t/t3415-rebase-autosquash.sh
@@ -490,6 +490,7 @@ test_expect_success 'pick and fixup respect commit.cleanup' '
git reset --hard base &&
test_commit --no-tag "fixup! second commit" file1 fixup &&
test_commit something &&
+ test_when_finished "rm -f .git/hooks/prepare-commit-msg" &&
write_script .git/hooks/prepare-commit-msg <<-\EOF &&
printf "\n# Prepared\n" >> "$1"
EOF
--
gitgitgadget
^ permalink raw reply related [flat|nested] 23+ messages in thread
* [PATCH 2/2] rebase: add --fixup-all to fold a range
2026-06-14 19:25 [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
@ 2026-06-14 19:25 ` Harald Nordgren via GitGitGadget
2026-06-15 2:01 ` [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Junio C Hamano
2026-06-15 8:37 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Harald Nordgren via GitGitGadget
3 siblings, 0 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-14 19:25 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
Add "git rebase --autosquash --fixup-all [<upstream>]" to do this
directly. It keeps the first commit in the range as a "pick" and turns
every later commit into a "fixup", so the whole range collapses into a
single commit that reuses the first commit's message. With no <upstream>
argument the range is "@{upstream}..HEAD", folding all unpushed commits
into one.
Fold the commits in their original order, so that any fixup!/squash!
commits already present in the range are folded in as well. Allow the
flag only together with --autosquash, and reject --rebase-merges since a
merge commit cannot be folded into another commit.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-rebase.adoc | 11 ++++
builtin/rebase.c | 13 ++++-
sequencer.c | 24 +++++++-
sequencer.h | 2 +-
t/t3415-rebase-autosquash.sh | 105 ++++++++++++++++++++++++++++++++++
5 files changed, 151 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc
index f6c22d1598..d1a3e4ef64 100644
--- a/Documentation/git-rebase.adoc
+++ b/Documentation/git-rebase.adoc
@@ -602,6 +602,16 @@ option can be used to override that setting.
+
See also INCOMPATIBLE OPTIONS below.
+--fixup-all::
+ Valid only when used with `--autosquash`. Keep the first commit in
+ the range as a `pick` and change every later commit to a `fixup`, so
+ the whole range is folded into a single commit that reuses the first
+ commit's message. With no `<upstream>` argument this folds all commits
+ since `@{upstream}` into one. The commits are folded in their original
+ order, so any `fixup!`/`squash!` commits already in the range are folded
+ in as well. Cannot be combined with `--rebase-merges`, as a merge
+ commit cannot be folded into another commit.
+
--autostash::
--no-autostash::
Automatically create a temporary stash entry before the operation
@@ -652,6 +662,7 @@ are incompatible with the following options:
* --strategy
* --strategy-option
* --autosquash
+ * --fixup-all
* --rebase-merges
* --interactive
* --exec
diff --git a/builtin/rebase.c b/builtin/rebase.c
index fa4f5d9306..a363fbc1f2 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -118,6 +118,7 @@ struct rebase_options {
int allow_rerere_autoupdate;
int keep_empty;
int autosquash;
+ int fixup_all;
char *gpg_sign_opt;
int autostash;
int committer_date_is_author_date;
@@ -329,7 +330,8 @@ static int do_interactive_rebase(struct rebase_options *opts, unsigned flags)
ret = complete_action(the_repository, &replay, flags,
shortrevisions, opts->onto_name, opts->onto,
&opts->orig_head->object.oid, &opts->exec,
- opts->autosquash, opts->update_refs, &todo_list);
+ opts->autosquash, opts->fixup_all, opts->update_refs,
+ &todo_list);
cleanup:
replay_opts_release(&replay);
@@ -1205,6 +1207,8 @@ int cmd_rebase(int argc,
OPT_BOOL(0, "autosquash", &options.autosquash,
N_("move commits that begin with "
"squash!/fixup! under -i")),
+ OPT_BOOL(0, "fixup-all", &options.fixup_all,
+ N_("fold all commits in the range into the first one")),
OPT_BOOL(0, "update-refs", &options.update_refs,
N_("update branches that point to commits "
"that are being rebased")),
@@ -1594,6 +1598,13 @@ int cmd_rebase(int argc,
options.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :
((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);
+ if (options.fixup_all && options.autosquash != 1)
+ die(_("--fixup-all requires --autosquash"));
+
+ if (options.fixup_all && options.rebase_merges)
+ die(_("options '%s' and '%s' cannot be used together"),
+ "--fixup-all", "--rebase-merges");
+
if (options.autosquash == 1) {
imply_merge(&options, "--autosquash");
} else if (options.autosquash == -1) {
diff --git a/sequencer.c b/sequencer.c
index 57855b0066..eeaf6226fe 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -6554,11 +6554,29 @@ static int todo_list_add_update_ref_commands(struct todo_list *todo_list)
return 0;
}
+static void todo_list_fixup_all_but_first(struct todo_list *todo_list)
+{
+ int i, seen_first = 0;
+
+ for (i = 0; i < todo_list->nr; i++) {
+ struct todo_item *item = todo_list->items + i;
+
+ if (!item->commit || item->command == TODO_DROP)
+ continue;
+ if (!seen_first) {
+ seen_first = 1;
+ item->command = TODO_PICK;
+ continue;
+ }
+ item->command = TODO_FIXUP;
+ }
+}
+
int complete_action(struct repository *r, struct replay_opts *opts, unsigned flags,
const char *shortrevisions, const char *onto_name,
struct commit *onto, const struct object_id *orig_head,
struct string_list *commands, unsigned autosquash,
- unsigned update_refs,
+ unsigned fixup_all, unsigned update_refs,
struct todo_list *todo_list)
{
char shortonto[GIT_MAX_HEXSZ + 1];
@@ -6581,7 +6599,9 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
if (update_refs && todo_list_add_update_ref_commands(todo_list))
return -1;
- if (autosquash && todo_list_rearrange_squash(todo_list))
+ if (fixup_all)
+ todo_list_fixup_all_but_first(todo_list);
+ else if (autosquash && todo_list_rearrange_squash(todo_list))
return -1;
if (commands->nr)
diff --git a/sequencer.h b/sequencer.h
index 3164bd437d..9bb6b42c94 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -196,7 +196,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
const char *shortrevisions, const char *onto_name,
struct commit *onto, const struct object_id *orig_head,
struct string_list *commands, unsigned autosquash,
- unsigned update_refs,
+ unsigned fixup_all, unsigned update_refs,
struct todo_list *todo_list);
int todo_list_rearrange_squash(struct todo_list *todo_list);
diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh
index 8964d1cc88..21d4159ebd 100755
--- a/t/t3415-rebase-autosquash.sh
+++ b/t/t3415-rebase-autosquash.sh
@@ -511,4 +511,109 @@ test_expect_success 'pick and fixup respect commit.cleanup' '
test_commit_message HEAD -m "something"
'
+test_expect_success '--fixup-all folds the range into the first commit' '
+ git reset --hard base &&
+ test_commit --no-tag fold1 file_fold a &&
+ test_commit --no-tag fold2 file_fold b &&
+ test_commit --no-tag fold3 file_fold c &&
+ git rebase --autosquash --fixup-all HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "fold1" &&
+ echo c >expect &&
+ test_cmp expect file_fold
+'
+
+test_expect_success '--fixup-all folds smoothly when a fixup! commit is in the series' '
+ git reset --hard base &&
+ test_commit --no-tag foldA file_fold a &&
+ test_commit --no-tag foldB file_fold b &&
+ git commit --allow-empty --fixup HEAD~1 &&
+ git rebase --autosquash --fixup-all HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "foldA" &&
+ echo b >expect &&
+ test_cmp expect file_fold
+'
+
+test_expect_success '--fixup-all picks the first commit even if it is a fixup!' '
+ git reset --hard base &&
+ test_commit --no-tag fixupbase file_fix a &&
+ git commit --allow-empty --fixup HEAD &&
+ test_commit --no-tag fixuptail file_fix b &&
+ git rebase --autosquash --fixup-all HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ echo b >expect &&
+ test_cmp expect file_fix
+'
+
+test_expect_success '--fixup-all with a single commit in range is a no-op' '
+ git reset --hard base &&
+ test_commit --no-tag solo file_solo a &&
+ git rev-parse HEAD >expect &&
+ git rebase --autosquash --fixup-all HEAD~1 &&
+ git rev-parse HEAD >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success '--fixup-all with an empty range succeeds' '
+ git reset --hard base &&
+ git rebase --autosquash --fixup-all HEAD &&
+ test_cmp_rev base HEAD
+'
+
+test_expect_success '--fixup-all skips a dropped commit in the range' '
+ git reset --hard base &&
+ test_commit --no-tag fixdrop1 file_drop a &&
+ git commit --allow-empty -m "empty in the middle" &&
+ test_commit --no-tag fixdrop3 file_drop b &&
+ git rebase --autosquash --empty=drop --fixup-all HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "fixdrop1" &&
+ echo b >expect &&
+ test_cmp expect file_drop
+'
+
+test_expect_success '--fixup-all folds a merge commit in the middle of the range' '
+ git reset --hard base &&
+ test_commit --no-tag mid-first &&
+ git checkout -b mid-side &&
+ test_commit --no-tag mid-merged &&
+ git checkout - &&
+ git merge --no-ff -m "merge mid-side" mid-side &&
+ test_commit --no-tag mid-last &&
+ git rebase --autosquash --fixup-all base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "mid-first" &&
+ test_path_is_file mid-merged.t
+'
+
+test_expect_success '--fixup-all keeps the first flattened commit when a merge sorts first' '
+ git reset --hard base &&
+ git checkout -b head-side &&
+ test_commit --no-tag head-merged &&
+ git checkout - &&
+ git merge --no-ff -m "merge head-side" head-side &&
+ test_commit --no-tag head-last &&
+ git rebase --autosquash --fixup-all base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "head-merged" &&
+ test_path_is_file head-merged.t
+'
+
+test_expect_success '--fixup-all requires --autosquash' '
+ git reset --hard base &&
+ test_must_fail git rebase --fixup-all HEAD~1 2>err &&
+ test_grep "fixup-all requires --autosquash" err &&
+ test_must_fail git rebase --no-autosquash --fixup-all HEAD~1 2>err &&
+ test_grep "fixup-all requires --autosquash" err
+'
+
+test_expect_success '--fixup-all and --rebase-merges cannot be combined' '
+ git reset --hard base &&
+ test_must_fail git rebase --autosquash --rebase-merges \
+ --fixup-all HEAD~1 2>err &&
+ test_grep "cannot be used together" err &&
+ test_path_is_missing .git/rebase-merge
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 23+ messages in thread
* Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
2026-06-14 19:25 [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 2/2] rebase: add --fixup-all to fold a range Harald Nordgren via GitGitGadget
@ 2026-06-15 2:01 ` Junio C Hamano
2026-06-15 8:18 ` Harald Nordgren
2026-06-15 8:37 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Harald Nordgren via GitGitGadget
3 siblings, 1 reply; 23+ messages in thread
From: Junio C Hamano @ 2026-06-15 2:01 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
"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?
^ permalink raw reply [flat|nested] 23+ messages in thread
* Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
2026-06-15 2:01 ` [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Junio C Hamano
@ 2026-06-15 8:18 ` Harald Nordgren
2026-06-15 15:17 ` D. Ben Knoble
0 siblings, 1 reply; 23+ messages in thread
From: Harald Nordgren @ 2026-06-15 8:18 UTC (permalink / raw)
To: Junio C Hamano; +Cc: Harald Nordgren via GitGitGadget, git
> > 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.
Sounds reasonable.
> 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?
I don't think it's enough. First of all the user has to know the N for
HEAD~N, and then 'git reset --soft HEAD~N && git commit --amend
--no-edit' is still quite ugly.
Harald
^ permalink raw reply [flat|nested] 23+ messages in thread
* [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit
2026-06-14 19:25 [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-15 2:01 ` [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Junio C Hamano
@ 2026-06-15 8:37 ` Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
` (3 more replies)
3 siblings, 4 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 8:37 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren
Rename to rebase --squash.
Harald Nordgren (2):
t3415: remove prepare-commit-msg hook after use
rebase: add --squash to fold a range
Documentation/git-rebase.adoc | 11 ++++
builtin/rebase.c | 16 ++++-
sequencer.c | 24 ++++++-
sequencer.h | 2 +-
t/t3415-rebase-autosquash.sh | 118 ++++++++++++++++++++++++++++++++++
5 files changed, 166 insertions(+), 5 deletions(-)
base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v2
Pull-Request: https://github.com/git/git/pull/2337
Range-diff vs v1:
1: c55b9cd6f7 = 1: c55b9cd6f7 t3415: remove prepare-commit-msg hook after use
2: bd1bc62aa8 ! 2: 22d4276ff5 rebase: add --fixup-all to fold a range
@@ Metadata
Author: Harald Nordgren <haraldnordgren@gmail.com>
## Commit message ##
- rebase: add --fixup-all to fold a range
+ rebase: add --squash to fold a range
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
- Add "git rebase --autosquash --fixup-all [<upstream>]" to do this
- directly. It keeps the first commit in the range as a "pick" and turns
- every later commit into a "fixup", so the whole range collapses into a
- single commit that reuses the first commit's message. With no <upstream>
- argument the range is "@{upstream}..HEAD", folding all unpushed commits
- into one.
+ Add "git rebase --squash [<upstream>]" to do this directly. It keeps
+ the first commit in the range as a "pick" and turns every later commit
+ into a "fixup", so the whole range collapses into a single commit that
+ reuses the first commit's message. With no <upstream> argument the range
+ is "@{upstream}..HEAD", folding all unpushed commits into one.
- Fold the commits in their original order, so that any fixup!/squash!
- commits already present in the range are folded in as well. Allow the
- flag only together with --autosquash, and reject --rebase-merges since a
- merge commit cannot be folded into another commit.
+ The option implies the merge backend, so it works on its own without
+ --autosquash. Fold the commits in their original order, so that any
+ fixup!/squash! commits already present in the range are folded in as
+ well. Reject --rebase-merges since a merge commit cannot be folded into
+ another commit.
+ Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-rebase.adoc ##
@@ Documentation/git-rebase.adoc: option can be used to override that setting.
+
See also INCOMPATIBLE OPTIONS below.
-+--fixup-all::
-+ Valid only when used with `--autosquash`. Keep the first commit in
-+ the range as a `pick` and change every later commit to a `fixup`, so
-+ the whole range is folded into a single commit that reuses the first
-+ commit's message. With no `<upstream>` argument this folds all commits
-+ since `@{upstream}` into one. The commits are folded in their original
-+ order, so any `fixup!`/`squash!` commits already in the range are folded
-+ in as well. Cannot be combined with `--rebase-merges`, as a merge
-+ commit cannot be folded into another commit.
++--squash::
++ Keep the first commit in the range as a `pick` and change every later
++ commit to a `fixup`, so the whole range is folded into a single commit
++ that reuses the first commit's message. With no `<upstream>` argument
++ this folds all commits since `@{upstream}` into one. The commits are
++ folded in their original order, so any `fixup!`/`squash!` commits
++ already in the range are folded in as well. Cannot be combined with
++ `--rebase-merges`, as a merge commit cannot be folded into another
++ commit.
+
--autostash::
--no-autostash::
@@ Documentation/git-rebase.adoc: are incompatible with the following options:
* --strategy
* --strategy-option
* --autosquash
-+ * --fixup-all
++ * --squash
* --rebase-merges
* --interactive
* --exec
@@ builtin/rebase.c: struct rebase_options {
int allow_rerere_autoupdate;
int keep_empty;
int autosquash;
-+ int fixup_all;
++ int squash;
char *gpg_sign_opt;
int autostash;
int committer_date_is_author_date;
@@ builtin/rebase.c: static int do_interactive_rebase(struct rebase_options *opts,
shortrevisions, opts->onto_name, opts->onto,
&opts->orig_head->object.oid, &opts->exec,
- opts->autosquash, opts->update_refs, &todo_list);
-+ opts->autosquash, opts->fixup_all, opts->update_refs,
++ opts->autosquash, opts->squash, opts->update_refs,
+ &todo_list);
cleanup:
@@ builtin/rebase.c: int cmd_rebase(int argc,
OPT_BOOL(0, "autosquash", &options.autosquash,
N_("move commits that begin with "
"squash!/fixup! under -i")),
-+ OPT_BOOL(0, "fixup-all", &options.fixup_all,
++ OPT_BOOL(0, "squash", &options.squash,
+ N_("fold all commits in the range into the first one")),
OPT_BOOL(0, "update-refs", &options.update_refs,
N_("update branches that point to commits "
"that are being rebased")),
+@@ builtin/rebase.c: int cmd_rebase(int argc,
+ if ((options.flags & REBASE_INTERACTIVE_EXPLICIT) ||
+ (options.action != ACTION_NONE) ||
+ (options.exec.nr > 0) ||
+- options.autosquash == 1) {
++ options.autosquash == 1 ||
++ options.squash) {
+ allow_preemptive_ff = 0;
+ }
+ if (options.committer_date_is_author_date || options.ignore_date)
@@ builtin/rebase.c: int cmd_rebase(int argc,
options.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :
((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);
-+ if (options.fixup_all && options.autosquash != 1)
-+ die(_("--fixup-all requires --autosquash"));
-+
-+ if (options.fixup_all && options.rebase_merges)
++ if (options.squash && options.rebase_merges)
+ die(_("options '%s' and '%s' cannot be used together"),
-+ "--fixup-all", "--rebase-merges");
++ "--squash", "--rebase-merges");
++
++ if (options.squash)
++ imply_merge(&options, "--squash");
+
if (options.autosquash == 1) {
imply_merge(&options, "--autosquash");
@@ sequencer.c: static int todo_list_add_update_ref_commands(struct todo_list *todo
struct commit *onto, const struct object_id *orig_head,
struct string_list *commands, unsigned autosquash,
- unsigned update_refs,
-+ unsigned fixup_all, unsigned update_refs,
++ unsigned squash, unsigned update_refs,
struct todo_list *todo_list)
{
char shortonto[GIT_MAX_HEXSZ + 1];
@@ sequencer.c: int complete_action(struct repository *r, struct replay_opts *opts,
return -1;
- if (autosquash && todo_list_rearrange_squash(todo_list))
-+ if (fixup_all)
++ if (squash)
+ todo_list_fixup_all_but_first(todo_list);
+ else if (autosquash && todo_list_rearrange_squash(todo_list))
return -1;
@@ sequencer.h: int complete_action(struct repository *r, struct replay_opts *opts,
struct commit *onto, const struct object_id *orig_head,
struct string_list *commands, unsigned autosquash,
- unsigned update_refs,
-+ unsigned fixup_all, unsigned update_refs,
++ unsigned squash, unsigned update_refs,
struct todo_list *todo_list);
int todo_list_rearrange_squash(struct todo_list *todo_list);
@@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit
test_commit_message HEAD -m "something"
'
-+test_expect_success '--fixup-all folds the range into the first commit' '
++test_expect_success '--squash folds the range into the first commit' '
+ git reset --hard base &&
+ test_commit --no-tag fold1 file_fold a &&
+ test_commit --no-tag fold2 file_fold b &&
+ test_commit --no-tag fold3 file_fold c &&
-+ git rebase --autosquash --fixup-all HEAD~3 &&
++ git rebase --squash HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "fold1" &&
+ echo c >expect &&
+ test_cmp expect file_fold
+'
+
-+test_expect_success '--fixup-all folds smoothly when a fixup! commit is in the series' '
++test_expect_success '--squash folds smoothly when a fixup! commit is in the series' '
+ git reset --hard base &&
+ test_commit --no-tag foldA file_fold a &&
+ test_commit --no-tag foldB file_fold b &&
+ git commit --allow-empty --fixup HEAD~1 &&
-+ git rebase --autosquash --fixup-all HEAD~3 &&
++ git rebase --squash HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "foldA" &&
+ echo b >expect &&
+ test_cmp expect file_fold
+'
+
-+test_expect_success '--fixup-all picks the first commit even if it is a fixup!' '
++test_expect_success '--squash picks the first commit even if it is a fixup!' '
+ git reset --hard base &&
+ test_commit --no-tag fixupbase file_fix a &&
+ git commit --allow-empty --fixup HEAD &&
+ test_commit --no-tag fixuptail file_fix b &&
-+ git rebase --autosquash --fixup-all HEAD~3 &&
++ git rebase --squash HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ echo b >expect &&
+ test_cmp expect file_fix
+'
+
-+test_expect_success '--fixup-all with a single commit in range is a no-op' '
++test_expect_success '--squash with a single commit in range is a no-op' '
+ git reset --hard base &&
+ test_commit --no-tag solo file_solo a &&
+ git rev-parse HEAD >expect &&
-+ git rebase --autosquash --fixup-all HEAD~1 &&
++ git rebase --squash HEAD~1 &&
+ git rev-parse HEAD >actual &&
+ test_cmp expect actual
+'
+
-+test_expect_success '--fixup-all with an empty range succeeds' '
++test_expect_success '--squash with an empty range succeeds' '
+ git reset --hard base &&
-+ git rebase --autosquash --fixup-all HEAD &&
++ git rebase --squash HEAD &&
+ test_cmp_rev base HEAD
+'
+
-+test_expect_success '--fixup-all skips a dropped commit in the range' '
++test_expect_success '--squash skips a dropped commit in the range' '
+ git reset --hard base &&
+ test_commit --no-tag fixdrop1 file_drop a &&
+ git commit --allow-empty -m "empty in the middle" &&
+ test_commit --no-tag fixdrop3 file_drop b &&
-+ git rebase --autosquash --empty=drop --fixup-all HEAD~3 &&
++ git rebase --squash --empty=drop HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "fixdrop1" &&
+ echo b >expect &&
+ test_cmp expect file_drop
+'
+
-+test_expect_success '--fixup-all folds a merge commit in the middle of the range' '
++test_expect_success '--squash folds a merge commit in the middle of the range' '
+ git reset --hard base &&
+ test_commit --no-tag mid-first &&
+ git checkout -b mid-side &&
@@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit
+ git checkout - &&
+ git merge --no-ff -m "merge mid-side" mid-side &&
+ test_commit --no-tag mid-last &&
-+ git rebase --autosquash --fixup-all base &&
++ git rebase --squash base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "mid-first" &&
+ test_path_is_file mid-merged.t
+'
+
-+test_expect_success '--fixup-all keeps the first flattened commit when a merge sorts first' '
++test_expect_success '--squash keeps the first flattened commit when a merge sorts first' '
+ git reset --hard base &&
+ git checkout -b head-side &&
+ test_commit --no-tag head-merged &&
+ git checkout - &&
+ git merge --no-ff -m "merge head-side" head-side &&
+ test_commit --no-tag head-last &&
-+ git rebase --autosquash --fixup-all base &&
++ git rebase --squash base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "head-merged" &&
+ test_path_is_file head-merged.t
+'
+
-+test_expect_success '--fixup-all requires --autosquash' '
++test_expect_success '--squash takes precedence over --autosquash' '
+ git reset --hard base &&
-+ test_must_fail git rebase --fixup-all HEAD~1 2>err &&
-+ test_grep "fixup-all requires --autosquash" err &&
-+ test_must_fail git rebase --no-autosquash --fixup-all HEAD~1 2>err &&
-+ test_grep "fixup-all requires --autosquash" err
++ test_commit --no-tag combo-first &&
++ test_commit --no-tag combo-mid &&
++ git commit --allow-empty --fixup HEAD~1 &&
++ test_commit --no-tag combo-last &&
++ git rebase --autosquash --squash base &&
++ test_cmp_rev base HEAD~1 &&
++ test_commit_message HEAD -m "combo-first"
++'
++
++test_expect_success '--squash folds the range with rebase.autosquash set' '
++ test_config rebase.autosquash true &&
++ git reset --hard base &&
++ test_commit --no-tag cfg-first &&
++ test_commit --no-tag cfg-last &&
++ git rebase --squash base &&
++ test_cmp_rev base HEAD~1 &&
++ test_commit_message HEAD -m "cfg-first"
+'
+
-+test_expect_success '--fixup-all and --rebase-merges cannot be combined' '
++test_expect_success '--squash and --rebase-merges cannot be combined' '
+ git reset --hard base &&
-+ test_must_fail git rebase --autosquash --rebase-merges \
-+ --fixup-all HEAD~1 2>err &&
++ test_must_fail git rebase --rebase-merges --squash HEAD~1 2>err &&
+ test_grep "cannot be used together" err &&
+ test_path_is_missing .git/rebase-merge
+'
--
gitgitgadget
^ permalink raw reply [flat|nested] 23+ messages in thread
* [PATCH v2 1/2] t3415: remove prepare-commit-msg hook after use
2026-06-15 8:37 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Harald Nordgren via GitGitGadget
@ 2026-06-15 8:37 ` Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 2/2] rebase: add --squash to fold a range Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
3 siblings, 0 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 8:37 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
The "pick and fixup respect commit.cleanup" test left its
prepare-commit-msg hook in place, leaking it into later tests. Remove it
with test_when_finished.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
t/t3415-rebase-autosquash.sh | 1 +
1 file changed, 1 insertion(+)
diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh
index 5033411a43..8964d1cc88 100755
--- a/t/t3415-rebase-autosquash.sh
+++ b/t/t3415-rebase-autosquash.sh
@@ -490,6 +490,7 @@ test_expect_success 'pick and fixup respect commit.cleanup' '
git reset --hard base &&
test_commit --no-tag "fixup! second commit" file1 fixup &&
test_commit something &&
+ test_when_finished "rm -f .git/hooks/prepare-commit-msg" &&
write_script .git/hooks/prepare-commit-msg <<-\EOF &&
printf "\n# Prepared\n" >> "$1"
EOF
--
gitgitgadget
^ permalink raw reply related [flat|nested] 23+ messages in thread
* [PATCH v2 2/2] rebase: add --squash to fold a range
2026-06-15 8:37 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
@ 2026-06-15 8:37 ` Harald Nordgren via GitGitGadget
2026-06-16 10:10 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Phillip Wood
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
3 siblings, 0 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 8:37 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
Add "git rebase --squash [<upstream>]" to do this directly. It keeps
the first commit in the range as a "pick" and turns every later commit
into a "fixup", so the whole range collapses into a single commit that
reuses the first commit's message. With no <upstream> argument the range
is "@{upstream}..HEAD", folding all unpushed commits into one.
The option implies the merge backend, so it works on its own without
--autosquash. Fold the commits in their original order, so that any
fixup!/squash! commits already present in the range are folded in as
well. Reject --rebase-merges since a merge commit cannot be folded into
another commit.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-rebase.adoc | 11 ++++
builtin/rebase.c | 16 ++++-
sequencer.c | 24 ++++++-
sequencer.h | 2 +-
t/t3415-rebase-autosquash.sh | 117 ++++++++++++++++++++++++++++++++++
5 files changed, 165 insertions(+), 5 deletions(-)
diff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc
index f6c22d1598..4244288148 100644
--- a/Documentation/git-rebase.adoc
+++ b/Documentation/git-rebase.adoc
@@ -602,6 +602,16 @@ option can be used to override that setting.
+
See also INCOMPATIBLE OPTIONS below.
+--squash::
+ Keep the first commit in the range as a `pick` and change every later
+ commit to a `fixup`, so the whole range is folded into a single commit
+ that reuses the first commit's message. With no `<upstream>` argument
+ this folds all commits since `@{upstream}` into one. The commits are
+ folded in their original order, so any `fixup!`/`squash!` commits
+ already in the range are folded in as well. Cannot be combined with
+ `--rebase-merges`, as a merge commit cannot be folded into another
+ commit.
+
--autostash::
--no-autostash::
Automatically create a temporary stash entry before the operation
@@ -652,6 +662,7 @@ are incompatible with the following options:
* --strategy
* --strategy-option
* --autosquash
+ * --squash
* --rebase-merges
* --interactive
* --exec
diff --git a/builtin/rebase.c b/builtin/rebase.c
index fa4f5d9306..2df9f04728 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -118,6 +118,7 @@ struct rebase_options {
int allow_rerere_autoupdate;
int keep_empty;
int autosquash;
+ int squash;
char *gpg_sign_opt;
int autostash;
int committer_date_is_author_date;
@@ -329,7 +330,8 @@ static int do_interactive_rebase(struct rebase_options *opts, unsigned flags)
ret = complete_action(the_repository, &replay, flags,
shortrevisions, opts->onto_name, opts->onto,
&opts->orig_head->object.oid, &opts->exec,
- opts->autosquash, opts->update_refs, &todo_list);
+ opts->autosquash, opts->squash, opts->update_refs,
+ &todo_list);
cleanup:
replay_opts_release(&replay);
@@ -1205,6 +1207,8 @@ int cmd_rebase(int argc,
OPT_BOOL(0, "autosquash", &options.autosquash,
N_("move commits that begin with "
"squash!/fixup! under -i")),
+ OPT_BOOL(0, "squash", &options.squash,
+ N_("fold all commits in the range into the first one")),
OPT_BOOL(0, "update-refs", &options.update_refs,
N_("update branches that point to commits "
"that are being rebased")),
@@ -1471,7 +1475,8 @@ int cmd_rebase(int argc,
if ((options.flags & REBASE_INTERACTIVE_EXPLICIT) ||
(options.action != ACTION_NONE) ||
(options.exec.nr > 0) ||
- options.autosquash == 1) {
+ options.autosquash == 1 ||
+ options.squash) {
allow_preemptive_ff = 0;
}
if (options.committer_date_is_author_date || options.ignore_date)
@@ -1594,6 +1599,13 @@ int cmd_rebase(int argc,
options.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :
((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);
+ if (options.squash && options.rebase_merges)
+ die(_("options '%s' and '%s' cannot be used together"),
+ "--squash", "--rebase-merges");
+
+ if (options.squash)
+ imply_merge(&options, "--squash");
+
if (options.autosquash == 1) {
imply_merge(&options, "--autosquash");
} else if (options.autosquash == -1) {
diff --git a/sequencer.c b/sequencer.c
index 57855b0066..bb42b40796 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -6554,11 +6554,29 @@ static int todo_list_add_update_ref_commands(struct todo_list *todo_list)
return 0;
}
+static void todo_list_fixup_all_but_first(struct todo_list *todo_list)
+{
+ int i, seen_first = 0;
+
+ for (i = 0; i < todo_list->nr; i++) {
+ struct todo_item *item = todo_list->items + i;
+
+ if (!item->commit || item->command == TODO_DROP)
+ continue;
+ if (!seen_first) {
+ seen_first = 1;
+ item->command = TODO_PICK;
+ continue;
+ }
+ item->command = TODO_FIXUP;
+ }
+}
+
int complete_action(struct repository *r, struct replay_opts *opts, unsigned flags,
const char *shortrevisions, const char *onto_name,
struct commit *onto, const struct object_id *orig_head,
struct string_list *commands, unsigned autosquash,
- unsigned update_refs,
+ unsigned squash, unsigned update_refs,
struct todo_list *todo_list)
{
char shortonto[GIT_MAX_HEXSZ + 1];
@@ -6581,7 +6599,9 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
if (update_refs && todo_list_add_update_ref_commands(todo_list))
return -1;
- if (autosquash && todo_list_rearrange_squash(todo_list))
+ if (squash)
+ todo_list_fixup_all_but_first(todo_list);
+ else if (autosquash && todo_list_rearrange_squash(todo_list))
return -1;
if (commands->nr)
diff --git a/sequencer.h b/sequencer.h
index 3164bd437d..1d5a164f02 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -196,7 +196,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
const char *shortrevisions, const char *onto_name,
struct commit *onto, const struct object_id *orig_head,
struct string_list *commands, unsigned autosquash,
- unsigned update_refs,
+ unsigned squash, unsigned update_refs,
struct todo_list *todo_list);
int todo_list_rearrange_squash(struct todo_list *todo_list);
diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh
index 8964d1cc88..ce9abe5147 100755
--- a/t/t3415-rebase-autosquash.sh
+++ b/t/t3415-rebase-autosquash.sh
@@ -511,4 +511,121 @@ test_expect_success 'pick and fixup respect commit.cleanup' '
test_commit_message HEAD -m "something"
'
+test_expect_success '--squash folds the range into the first commit' '
+ git reset --hard base &&
+ test_commit --no-tag fold1 file_fold a &&
+ test_commit --no-tag fold2 file_fold b &&
+ test_commit --no-tag fold3 file_fold c &&
+ git rebase --squash HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "fold1" &&
+ echo c >expect &&
+ test_cmp expect file_fold
+'
+
+test_expect_success '--squash folds smoothly when a fixup! commit is in the series' '
+ git reset --hard base &&
+ test_commit --no-tag foldA file_fold a &&
+ test_commit --no-tag foldB file_fold b &&
+ git commit --allow-empty --fixup HEAD~1 &&
+ git rebase --squash HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "foldA" &&
+ echo b >expect &&
+ test_cmp expect file_fold
+'
+
+test_expect_success '--squash picks the first commit even if it is a fixup!' '
+ git reset --hard base &&
+ test_commit --no-tag fixupbase file_fix a &&
+ git commit --allow-empty --fixup HEAD &&
+ test_commit --no-tag fixuptail file_fix b &&
+ git rebase --squash HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ echo b >expect &&
+ test_cmp expect file_fix
+'
+
+test_expect_success '--squash with a single commit in range is a no-op' '
+ git reset --hard base &&
+ test_commit --no-tag solo file_solo a &&
+ git rev-parse HEAD >expect &&
+ git rebase --squash HEAD~1 &&
+ git rev-parse HEAD >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success '--squash with an empty range succeeds' '
+ git reset --hard base &&
+ git rebase --squash HEAD &&
+ test_cmp_rev base HEAD
+'
+
+test_expect_success '--squash skips a dropped commit in the range' '
+ git reset --hard base &&
+ test_commit --no-tag fixdrop1 file_drop a &&
+ git commit --allow-empty -m "empty in the middle" &&
+ test_commit --no-tag fixdrop3 file_drop b &&
+ git rebase --squash --empty=drop HEAD~3 &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "fixdrop1" &&
+ echo b >expect &&
+ test_cmp expect file_drop
+'
+
+test_expect_success '--squash folds a merge commit in the middle of the range' '
+ git reset --hard base &&
+ test_commit --no-tag mid-first &&
+ git checkout -b mid-side &&
+ test_commit --no-tag mid-merged &&
+ git checkout - &&
+ git merge --no-ff -m "merge mid-side" mid-side &&
+ test_commit --no-tag mid-last &&
+ git rebase --squash base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "mid-first" &&
+ test_path_is_file mid-merged.t
+'
+
+test_expect_success '--squash keeps the first flattened commit when a merge sorts first' '
+ git reset --hard base &&
+ git checkout -b head-side &&
+ test_commit --no-tag head-merged &&
+ git checkout - &&
+ git merge --no-ff -m "merge head-side" head-side &&
+ test_commit --no-tag head-last &&
+ git rebase --squash base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "head-merged" &&
+ test_path_is_file head-merged.t
+'
+
+test_expect_success '--squash takes precedence over --autosquash' '
+ git reset --hard base &&
+ test_commit --no-tag combo-first &&
+ test_commit --no-tag combo-mid &&
+ git commit --allow-empty --fixup HEAD~1 &&
+ test_commit --no-tag combo-last &&
+ git rebase --autosquash --squash base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "combo-first"
+'
+
+test_expect_success '--squash folds the range with rebase.autosquash set' '
+ test_config rebase.autosquash true &&
+ git reset --hard base &&
+ test_commit --no-tag cfg-first &&
+ test_commit --no-tag cfg-last &&
+ git rebase --squash base &&
+ test_cmp_rev base HEAD~1 &&
+ test_commit_message HEAD -m "cfg-first"
+'
+
+test_expect_success '--squash and --rebase-merges cannot be combined' '
+ git reset --hard base &&
+ test_must_fail git rebase --rebase-merges --squash HEAD~1 2>err &&
+ test_grep "cannot be used together" err &&
+ test_path_is_missing .git/rebase-merge
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 23+ messages in thread
* Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
2026-06-15 8:18 ` Harald Nordgren
@ 2026-06-15 15:17 ` D. Ben Knoble
2026-06-16 8:34 ` Patrick Steinhardt
0 siblings, 1 reply; 23+ messages in thread
From: D. Ben Knoble @ 2026-06-15 15:17 UTC (permalink / raw)
To: Harald Nordgren
Cc: Junio C Hamano, Harald Nordgren via GitGitGadget, git,
Patrick Steinhardt
On Mon, Jun 15, 2026 at 4:22 AM Harald Nordgren
<haraldnordgren@gmail.com> wrote:
> > > Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits
> > > into its oldest one, reusing that commit's message.
[snip]
> > 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?
>
> I don't think it's enough. First of all the user has to know the N for
> HEAD~N, and then 'git reset --soft HEAD~N && git commit --amend
> --no-edit' is still quite ugly.
Well, there are a few ways to get this more easily than counting; for example,
- git rev-list @{u}.. | tail -n1
- the lovely ":/<pattern>" or "@^{/<pattern>}" revision notations
- etc.
---
Stepping back a moment and assuming that the important thing you want
is the "squash" (and not necessarily the "rebase" moving commits onto
a new base), I wonder about
git history squash <range>
which would squash all commits in the (now arbitrary!) range into the
first. That makes it somewhat more versatile at selecting commits, I
think, at the cost that re-basing is somewhat harder. That is, you
could then do
git history squash @~3..
and things like
git history squash @~5..@~2
As a future extension, I think we could support merge commits: merges
could be replayed as a merge into the final squash instead (creating
an octopus merge if there are multiple merges to replay), though I'm
hand-waving what we should do for conflicts. (We _do_ know what the
final tree should look like—the same as the final commit in the
range—so maybe we can actually avoid all conflicts?)
Anyway, I've cc'd Patrick for his opinion about whether this fits in
"git-history".
--
D. Ben Knoble
^ permalink raw reply [flat|nested] 23+ messages in thread
* Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
2026-06-15 15:17 ` D. Ben Knoble
@ 2026-06-16 8:34 ` Patrick Steinhardt
2026-06-17 9:30 ` Harald Nordgren
0 siblings, 1 reply; 23+ messages in thread
From: Patrick Steinhardt @ 2026-06-16 8:34 UTC (permalink / raw)
To: D. Ben Knoble
Cc: Harald Nordgren, Junio C Hamano, Harald Nordgren via GitGitGadget,
git
On Mon, Jun 15, 2026 at 11:17:21AM -0400, D. Ben Knoble wrote:
> On Mon, Jun 15, 2026 at 4:22 AM Harald Nordgren
> <haraldnordgren@gmail.com> wrote:
> > > > Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits
> > > > into its oldest one, reusing that commit's message.
> [snip]
> > > 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?
> >
> > I don't think it's enough. First of all the user has to know the N for
> > HEAD~N, and then 'git reset --soft HEAD~N && git commit --amend
> > --no-edit' is still quite ugly.
>
> Well, there are a few ways to get this more easily than counting; for example,
>
> - git rev-list @{u}.. | tail -n1
> - the lovely ":/<pattern>" or "@^{/<pattern>}" revision notations
> - etc.
>
> ---
>
> Stepping back a moment and assuming that the important thing you want
> is the "squash" (and not necessarily the "rebase" moving commits onto
> a new base), I wonder about
>
> git history squash <range>
>
> which would squash all commits in the (now arbitrary!) range into the
> first. That makes it somewhat more versatile at selecting commits, I
> think, at the cost that re-basing is somewhat harder. That is, you
> could then do
>
> git history squash @~3..
>
> and things like
>
> git history squash @~5..@~2
>
> As a future extension, I think we could support merge commits: merges
> could be replayed as a merge into the final squash instead (creating
> an octopus merge if there are multiple merges to replay), though I'm
> hand-waving what we should do for conflicts. (We _do_ know what the
> final tree should look like—the same as the final commit in the
> range—so maybe we can actually avoid all conflicts?)
>
> Anyway, I've cc'd Patrick for his opinion about whether this fits in
> "git-history".
Yes, it does fit into git-history(1), and I do indeed already have plans
to implement such a command going forward. I wouldn't mind at all though
if somebody else beat me to it, I want to implement at least one more
command before I get to this.
Patrick
^ permalink raw reply [flat|nested] 23+ messages in thread
* Re: [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit
2026-06-15 8:37 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 2/2] rebase: add --squash to fold a range Harald Nordgren via GitGitGadget
@ 2026-06-16 10:10 ` Phillip Wood
2026-06-17 9:11 ` Harald Nordgren
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
3 siblings, 1 reply; 23+ messages in thread
From: Phillip Wood @ 2026-06-16 10:10 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Harald Nordgren, D. Ben Knoble, Patrick Steinhardt,
Junio C Hamano
Hi Harald
On 15/06/2026 09:37, Harald Nordgren via GitGitGadget wrote:
> Rename to rebase --squash.
Please include the original cover letter as well so people who have not
read the previous version know what the series is about.
I agree that git-history might be a better place for this but I'm not
opposed to adding a --squash option to git-rebase. However I do think we
need to think about the implementation - picking consecutive commits
when we want to squash them together is inefficient and if we're
changing the base risks the user having to stop to resolve conflicts
multiple times. Regardless of whether this ends up in git-rebase or
git-history I think the implementation should cherry-pick the whole
commit range by doing the equivalent of
git merge-tree --merge-base $(git merge-base HEAD @{upstream}) \
@{upstream} HEAD
We should also let the user edit the commit message to reflect the
changes that are being squashed in. We should think about what support
we want for "amend!" commits that replace the commit message when rebasing.
Thanks
Phillip
> Harald Nordgren (2):
> t3415: remove prepare-commit-msg hook after use
> rebase: add --squash to fold a range
>
> Documentation/git-rebase.adoc | 11 ++++
> builtin/rebase.c | 16 ++++-
> sequencer.c | 24 ++++++-
> sequencer.h | 2 +-
> t/t3415-rebase-autosquash.sh | 118 ++++++++++++++++++++++++++++++++++
> 5 files changed, 166 insertions(+), 5 deletions(-)
>
>
> base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v2
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v2
> Pull-Request: https://github.com/git/git/pull/2337
>
> Range-diff vs v1:
>
> 1: c55b9cd6f7 = 1: c55b9cd6f7 t3415: remove prepare-commit-msg hook after use
> 2: bd1bc62aa8 ! 2: 22d4276ff5 rebase: add --fixup-all to fold a range
> @@ Metadata
> Author: Harald Nordgren <haraldnordgren@gmail.com>
>
> ## Commit message ##
> - rebase: add --fixup-all to fold a range
> + rebase: add --squash to fold a range
>
> Folding a series of commits into one required either an interactive
> rebase where each commit after the first was hand-edited to "fixup", or
> a "git reset --soft" to the merge base followed by "git commit --amend".
>
> - Add "git rebase --autosquash --fixup-all [<upstream>]" to do this
> - directly. It keeps the first commit in the range as a "pick" and turns
> - every later commit into a "fixup", so the whole range collapses into a
> - single commit that reuses the first commit's message. With no <upstream>
> - argument the range is "@{upstream}..HEAD", folding all unpushed commits
> - into one.
> + Add "git rebase --squash [<upstream>]" to do this directly. It keeps
> + the first commit in the range as a "pick" and turns every later commit
> + into a "fixup", so the whole range collapses into a single commit that
> + reuses the first commit's message. With no <upstream> argument the range
> + is "@{upstream}..HEAD", folding all unpushed commits into one.
>
> - Fold the commits in their original order, so that any fixup!/squash!
> - commits already present in the range are folded in as well. Allow the
> - flag only together with --autosquash, and reject --rebase-merges since a
> - merge commit cannot be folded into another commit.
> + The option implies the merge backend, so it works on its own without
> + --autosquash. Fold the commits in their original order, so that any
> + fixup!/squash! commits already present in the range are folded in as
> + well. Reject --rebase-merges since a merge commit cannot be folded into
> + another commit.
>
> + Inspired-by: Sergey Chernov <serega.morph@gmail.com>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>
> ## Documentation/git-rebase.adoc ##
> @@ Documentation/git-rebase.adoc: option can be used to override that setting.
> +
> See also INCOMPATIBLE OPTIONS below.
>
> -+--fixup-all::
> -+ Valid only when used with `--autosquash`. Keep the first commit in
> -+ the range as a `pick` and change every later commit to a `fixup`, so
> -+ the whole range is folded into a single commit that reuses the first
> -+ commit's message. With no `<upstream>` argument this folds all commits
> -+ since `@{upstream}` into one. The commits are folded in their original
> -+ order, so any `fixup!`/`squash!` commits already in the range are folded
> -+ in as well. Cannot be combined with `--rebase-merges`, as a merge
> -+ commit cannot be folded into another commit.
> ++--squash::
> ++ Keep the first commit in the range as a `pick` and change every later
> ++ commit to a `fixup`, so the whole range is folded into a single commit
> ++ that reuses the first commit's message. With no `<upstream>` argument
> ++ this folds all commits since `@{upstream}` into one. The commits are
> ++ folded in their original order, so any `fixup!`/`squash!` commits
> ++ already in the range are folded in as well. Cannot be combined with
> ++ `--rebase-merges`, as a merge commit cannot be folded into another
> ++ commit.
> +
> --autostash::
> --no-autostash::
> @@ Documentation/git-rebase.adoc: are incompatible with the following options:
> * --strategy
> * --strategy-option
> * --autosquash
> -+ * --fixup-all
> ++ * --squash
> * --rebase-merges
> * --interactive
> * --exec
> @@ builtin/rebase.c: struct rebase_options {
> int allow_rerere_autoupdate;
> int keep_empty;
> int autosquash;
> -+ int fixup_all;
> ++ int squash;
> char *gpg_sign_opt;
> int autostash;
> int committer_date_is_author_date;
> @@ builtin/rebase.c: static int do_interactive_rebase(struct rebase_options *opts,
> shortrevisions, opts->onto_name, opts->onto,
> &opts->orig_head->object.oid, &opts->exec,
> - opts->autosquash, opts->update_refs, &todo_list);
> -+ opts->autosquash, opts->fixup_all, opts->update_refs,
> ++ opts->autosquash, opts->squash, opts->update_refs,
> + &todo_list);
>
> cleanup:
> @@ builtin/rebase.c: int cmd_rebase(int argc,
> OPT_BOOL(0, "autosquash", &options.autosquash,
> N_("move commits that begin with "
> "squash!/fixup! under -i")),
> -+ OPT_BOOL(0, "fixup-all", &options.fixup_all,
> ++ OPT_BOOL(0, "squash", &options.squash,
> + N_("fold all commits in the range into the first one")),
> OPT_BOOL(0, "update-refs", &options.update_refs,
> N_("update branches that point to commits "
> "that are being rebased")),
> +@@ builtin/rebase.c: int cmd_rebase(int argc,
> + if ((options.flags & REBASE_INTERACTIVE_EXPLICIT) ||
> + (options.action != ACTION_NONE) ||
> + (options.exec.nr > 0) ||
> +- options.autosquash == 1) {
> ++ options.autosquash == 1 ||
> ++ options.squash) {
> + allow_preemptive_ff = 0;
> + }
> + if (options.committer_date_is_author_date || options.ignore_date)
> @@ builtin/rebase.c: int cmd_rebase(int argc,
> options.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :
> ((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);
>
> -+ if (options.fixup_all && options.autosquash != 1)
> -+ die(_("--fixup-all requires --autosquash"));
> -+
> -+ if (options.fixup_all && options.rebase_merges)
> ++ if (options.squash && options.rebase_merges)
> + die(_("options '%s' and '%s' cannot be used together"),
> -+ "--fixup-all", "--rebase-merges");
> ++ "--squash", "--rebase-merges");
> ++
> ++ if (options.squash)
> ++ imply_merge(&options, "--squash");
> +
> if (options.autosquash == 1) {
> imply_merge(&options, "--autosquash");
> @@ sequencer.c: static int todo_list_add_update_ref_commands(struct todo_list *todo
> struct commit *onto, const struct object_id *orig_head,
> struct string_list *commands, unsigned autosquash,
> - unsigned update_refs,
> -+ unsigned fixup_all, unsigned update_refs,
> ++ unsigned squash, unsigned update_refs,
> struct todo_list *todo_list)
> {
> char shortonto[GIT_MAX_HEXSZ + 1];
> @@ sequencer.c: int complete_action(struct repository *r, struct replay_opts *opts,
> return -1;
>
> - if (autosquash && todo_list_rearrange_squash(todo_list))
> -+ if (fixup_all)
> ++ if (squash)
> + todo_list_fixup_all_but_first(todo_list);
> + else if (autosquash && todo_list_rearrange_squash(todo_list))
> return -1;
> @@ sequencer.h: int complete_action(struct repository *r, struct replay_opts *opts,
> struct commit *onto, const struct object_id *orig_head,
> struct string_list *commands, unsigned autosquash,
> - unsigned update_refs,
> -+ unsigned fixup_all, unsigned update_refs,
> ++ unsigned squash, unsigned update_refs,
> struct todo_list *todo_list);
> int todo_list_rearrange_squash(struct todo_list *todo_list);
>
> @@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit
> test_commit_message HEAD -m "something"
> '
>
> -+test_expect_success '--fixup-all folds the range into the first commit' '
> ++test_expect_success '--squash folds the range into the first commit' '
> + git reset --hard base &&
> + test_commit --no-tag fold1 file_fold a &&
> + test_commit --no-tag fold2 file_fold b &&
> + test_commit --no-tag fold3 file_fold c &&
> -+ git rebase --autosquash --fixup-all HEAD~3 &&
> ++ git rebase --squash HEAD~3 &&
> + test_cmp_rev base HEAD~1 &&
> + test_commit_message HEAD -m "fold1" &&
> + echo c >expect &&
> + test_cmp expect file_fold
> +'
> +
> -+test_expect_success '--fixup-all folds smoothly when a fixup! commit is in the series' '
> ++test_expect_success '--squash folds smoothly when a fixup! commit is in the series' '
> + git reset --hard base &&
> + test_commit --no-tag foldA file_fold a &&
> + test_commit --no-tag foldB file_fold b &&
> + git commit --allow-empty --fixup HEAD~1 &&
> -+ git rebase --autosquash --fixup-all HEAD~3 &&
> ++ git rebase --squash HEAD~3 &&
> + test_cmp_rev base HEAD~1 &&
> + test_commit_message HEAD -m "foldA" &&
> + echo b >expect &&
> + test_cmp expect file_fold
> +'
> +
> -+test_expect_success '--fixup-all picks the first commit even if it is a fixup!' '
> ++test_expect_success '--squash picks the first commit even if it is a fixup!' '
> + git reset --hard base &&
> + test_commit --no-tag fixupbase file_fix a &&
> + git commit --allow-empty --fixup HEAD &&
> + test_commit --no-tag fixuptail file_fix b &&
> -+ git rebase --autosquash --fixup-all HEAD~3 &&
> ++ git rebase --squash HEAD~3 &&
> + test_cmp_rev base HEAD~1 &&
> + echo b >expect &&
> + test_cmp expect file_fix
> +'
> +
> -+test_expect_success '--fixup-all with a single commit in range is a no-op' '
> ++test_expect_success '--squash with a single commit in range is a no-op' '
> + git reset --hard base &&
> + test_commit --no-tag solo file_solo a &&
> + git rev-parse HEAD >expect &&
> -+ git rebase --autosquash --fixup-all HEAD~1 &&
> ++ git rebase --squash HEAD~1 &&
> + git rev-parse HEAD >actual &&
> + test_cmp expect actual
> +'
> +
> -+test_expect_success '--fixup-all with an empty range succeeds' '
> ++test_expect_success '--squash with an empty range succeeds' '
> + git reset --hard base &&
> -+ git rebase --autosquash --fixup-all HEAD &&
> ++ git rebase --squash HEAD &&
> + test_cmp_rev base HEAD
> +'
> +
> -+test_expect_success '--fixup-all skips a dropped commit in the range' '
> ++test_expect_success '--squash skips a dropped commit in the range' '
> + git reset --hard base &&
> + test_commit --no-tag fixdrop1 file_drop a &&
> + git commit --allow-empty -m "empty in the middle" &&
> + test_commit --no-tag fixdrop3 file_drop b &&
> -+ git rebase --autosquash --empty=drop --fixup-all HEAD~3 &&
> ++ git rebase --squash --empty=drop HEAD~3 &&
> + test_cmp_rev base HEAD~1 &&
> + test_commit_message HEAD -m "fixdrop1" &&
> + echo b >expect &&
> + test_cmp expect file_drop
> +'
> +
> -+test_expect_success '--fixup-all folds a merge commit in the middle of the range' '
> ++test_expect_success '--squash folds a merge commit in the middle of the range' '
> + git reset --hard base &&
> + test_commit --no-tag mid-first &&
> + git checkout -b mid-side &&
> @@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit
> + git checkout - &&
> + git merge --no-ff -m "merge mid-side" mid-side &&
> + test_commit --no-tag mid-last &&
> -+ git rebase --autosquash --fixup-all base &&
> ++ git rebase --squash base &&
> + test_cmp_rev base HEAD~1 &&
> + test_commit_message HEAD -m "mid-first" &&
> + test_path_is_file mid-merged.t
> +'
> +
> -+test_expect_success '--fixup-all keeps the first flattened commit when a merge sorts first' '
> ++test_expect_success '--squash keeps the first flattened commit when a merge sorts first' '
> + git reset --hard base &&
> + git checkout -b head-side &&
> + test_commit --no-tag head-merged &&
> + git checkout - &&
> + git merge --no-ff -m "merge head-side" head-side &&
> + test_commit --no-tag head-last &&
> -+ git rebase --autosquash --fixup-all base &&
> ++ git rebase --squash base &&
> + test_cmp_rev base HEAD~1 &&
> + test_commit_message HEAD -m "head-merged" &&
> + test_path_is_file head-merged.t
> +'
> +
> -+test_expect_success '--fixup-all requires --autosquash' '
> ++test_expect_success '--squash takes precedence over --autosquash' '
> + git reset --hard base &&
> -+ test_must_fail git rebase --fixup-all HEAD~1 2>err &&
> -+ test_grep "fixup-all requires --autosquash" err &&
> -+ test_must_fail git rebase --no-autosquash --fixup-all HEAD~1 2>err &&
> -+ test_grep "fixup-all requires --autosquash" err
> ++ test_commit --no-tag combo-first &&
> ++ test_commit --no-tag combo-mid &&
> ++ git commit --allow-empty --fixup HEAD~1 &&
> ++ test_commit --no-tag combo-last &&
> ++ git rebase --autosquash --squash base &&
> ++ test_cmp_rev base HEAD~1 &&
> ++ test_commit_message HEAD -m "combo-first"
> ++'
> ++
> ++test_expect_success '--squash folds the range with rebase.autosquash set' '
> ++ test_config rebase.autosquash true &&
> ++ git reset --hard base &&
> ++ test_commit --no-tag cfg-first &&
> ++ test_commit --no-tag cfg-last &&
> ++ git rebase --squash base &&
> ++ test_cmp_rev base HEAD~1 &&
> ++ test_commit_message HEAD -m "cfg-first"
> +'
> +
> -+test_expect_success '--fixup-all and --rebase-merges cannot be combined' '
> ++test_expect_success '--squash and --rebase-merges cannot be combined' '
> + git reset --hard base &&
> -+ test_must_fail git rebase --autosquash --rebase-merges \
> -+ --fixup-all HEAD~1 2>err &&
> ++ test_must_fail git rebase --rebase-merges --squash HEAD~1 2>err &&
> + test_grep "cannot be used together" err &&
> + test_path_is_missing .git/rebase-merge
> +'
>
^ permalink raw reply [flat|nested] 23+ messages in thread
* Re: [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit
2026-06-16 10:10 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Phillip Wood
@ 2026-06-17 9:11 ` Harald Nordgren
2026-06-17 9:48 ` Phillip Wood
0 siblings, 1 reply; 23+ messages in thread
From: Harald Nordgren @ 2026-06-17 9:11 UTC (permalink / raw)
To: Phillip Wood
Cc: Harald Nordgren via GitGitGadget, git, D. Ben Knoble,
Patrick Steinhardt, Junio C Hamano
On Tue, Jun 16, 2026 at 12:10 PM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> Hi Harald
>
> On 15/06/2026 09:37, Harald Nordgren via GitGitGadget wrote:
> > Rename to rebase --squash.
>
> Please include the original cover letter as well so people who have not
> read the previous version know what the series is about.
So you mean this one, should that be included in each version, and
append each subsequent one:
```
Adds `git rebase --autosquash --fixup [<upstream>]` to fold a range of
commits into its oldest one, reusing that commit's message.
Related idea: https://github.com/gitgitgadget/git/issues/1135
```
Or make each message a full cover letter instead of just a diff?
Harald
^ permalink raw reply [flat|nested] 23+ messages in thread
* Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit
2026-06-16 8:34 ` Patrick Steinhardt
@ 2026-06-17 9:30 ` Harald Nordgren
0 siblings, 0 replies; 23+ messages in thread
From: Harald Nordgren @ 2026-06-17 9:30 UTC (permalink / raw)
To: Patrick Steinhardt
Cc: D. Ben Knoble, Junio C Hamano, Harald Nordgren via GitGitGadget,
git
> Yes, it does fit into git-history(1), and I do indeed already have plans
> to implement such a command going forward. I wouldn't mind at all though
> if somebody else beat me to it, I want to implement at least one more
> command before I get to this.
What I like about 'git rebase --squash' is that when upstream is set
up, it understands the commit range automatically, whereas history
feels more removed from the current upstream. Maybe I'm wrong about
that.
Harald
^ permalink raw reply [flat|nested] 23+ messages in thread
* Re: [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit
2026-06-17 9:11 ` Harald Nordgren
@ 2026-06-17 9:48 ` Phillip Wood
0 siblings, 0 replies; 23+ messages in thread
From: Phillip Wood @ 2026-06-17 9:48 UTC (permalink / raw)
To: Harald Nordgren, Phillip Wood
Cc: Harald Nordgren via GitGitGadget, git, D. Ben Knoble,
Patrick Steinhardt, Junio C Hamano
On 17/06/2026 10:11, Harald Nordgren wrote:
> On Tue, Jun 16, 2026 at 12:10 PM Phillip Wood <phillip.wood123@gmail.com> wrote:
>> On 15/06/2026 09:37, Harald Nordgren via GitGitGadget wrote:
>>> Rename to rebase --squash.
>>
>> Please include the original cover letter as well so people who have not
>> read the previous version know what the series is about.
>
> So you mean this one, should that be included in each version, and
> append each subsequent one:
>
> ```
> Adds `git rebase --autosquash --fixup [<upstream>]` to fold a range of
> commits into its oldest one, reusing that commit's message.
Yes, see
https://lore.kernel.org/20260615-b4-pks-history-drop-v6-0-2e329e536d78@pks.im
for an example and the "Cover Letter" section of
Documentation/SubmittingPatches
Thanks
Phillip
>
> Related idea: https://github.com/gitgitgadget/git/issues/1135
> ```
>
> Or make each message a full cover letter instead of just a diff?
>
>
>
>
> Harald
>
^ permalink raw reply [flat|nested] 23+ messages in thread
* [PATCH v3 0/4] history: add squash subcommand to fold a range
2026-06-15 8:37 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-16 10:10 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Phillip Wood
@ 2026-06-18 19:17 ` Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 1/4] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
` (4 more replies)
3 siblings, 5 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:17 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren
Adds git history squash <revision-range> to fold a range of commits into its
oldest one, reusing that commit's message and replaying any descendants on
top.
Changes in v3:
* Moved the feature out of git rebase and into a new git history squash
<revision-range> subcommand, per the list discussion. git rebase --squash
is dropped.
* Takes an arbitrary range (git history squash @~3.., git history squash
@~5..@~2), folding it into the oldest commit and replaying any
descendants on top.
* Implemented as a single tree operation rather than picking each commit,
so there are no repeated conflict stops (addresses Phillip's efficiency
point).
* A merge inside the range is folded fine, only a range with more than one
base is rejected.
* --reedit-message seeds the editor with every folded-in message, not just
the oldest.
Harald Nordgren (4):
history: extract helper for a commit's parent tree
history: give commit_tree_ext a message template
history: add squash subcommand to fold a range
history: re-edit a squash with every message
Documentation/git-history.adoc | 21 +++
builtin/history.c | 287 ++++++++++++++++++++++++++++-----
t/meson.build | 1 +
t/t3454-history-squash.sh | 250 ++++++++++++++++++++++++++++
4 files changed, 521 insertions(+), 38 deletions(-)
create mode 100755 t/t3454-history-squash.sh
base-commit: 95e20213faefeb95df29277c58ac1980ab68f701
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v3
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v3
Pull-Request: https://github.com/git/git/pull/2337
Range-diff vs v2:
1: c55b9cd6f7 < -: ---------- t3415: remove prepare-commit-msg hook after use
2: 22d4276ff5 < -: ---------- rebase: add --squash to fold a range
-: ---------- > 1: 1e31474ef6 history: extract helper for a commit's parent tree
-: ---------- > 2: 498da64046 history: give commit_tree_ext a message template
-: ---------- > 3: 66b2f49fb4 history: add squash subcommand to fold a range
-: ---------- > 4: 43e4270614 history: re-edit a squash with every message
--
gitgitgadget
^ permalink raw reply [flat|nested] 23+ messages in thread
* [PATCH v3 1/4] history: extract helper for a commit's parent tree
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-06-18 19:17 ` Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 2/4] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
4 siblings, 0 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:17 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Three places resolve the tree of a commit's first parent, falling back
to the empty tree for a root commit, each repeating the same parse and
oidcpy dance. Extract a first_parent_tree_oid() helper and route the
existing callers through it.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 58 +++++++++++++++++++++--------------------------
1 file changed, 26 insertions(+), 32 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 091465a59e..f95f26e684 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -157,6 +157,25 @@ out:
return ret;
}
+static int first_parent_tree_oid(struct repository *repo,
+ struct commit *commit,
+ struct object_id *out)
+{
+ struct commit *parent = commit->parents ? commit->parents->item : NULL;
+
+ if (!parent) {
+ oidcpy(out, repo->hash_algo->empty_tree);
+ return 0;
+ }
+
+ if (repo_parse_commit(repo, parent))
+ return error(_("unable to parse parent commit %s"),
+ oid_to_hex(&parent->object.oid));
+
+ oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
+ return 0;
+}
+
static int commit_tree_with_edited_message(struct repository *repo,
const char *action,
struct commit *original,
@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,
{
struct object_id parent_tree_oid;
const struct object_id *tree_oid;
- struct commit *parent;
tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- parent = original->parents ? original->parents->item : NULL;
- if (parent) {
- if (repo_parse_commit(repo, parent)) {
- return error(_("unable to parse parent commit %s"),
- oid_to_hex(&parent->object.oid));
- }
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return commit_tree_ext(repo, action, original, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,
struct commit *original,
struct tree *result)
{
- struct commit *parent = original->parents ? original->parents->item : NULL;
struct object_id parent_tree_oid;
- if (parent) {
- if (repo_parse_commit(repo, parent))
- return error(_("unable to parse parent of %s"),
- oid_to_hex(&original->object.oid));
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return oideq(&result->object.oid, &parent_tree_oid);
}
@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,
struct tree *split_tree;
int ret;
- if (original->parents) {
- if (repo_parse_commit(repo, original->parents->item)) {
- ret = error(_("unable to parse parent commit %s"),
- oid_to_hex(&original->parents->item->object.oid));
- goto out;
- }
-
- parent_tree_oid = *get_commit_tree_oid(original->parents->item);
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
+ ret = -1;
+ goto out;
}
original_commit_tree_oid = get_commit_tree_oid(original);
--
gitgitgadget
^ permalink raw reply related [flat|nested] 23+ messages in thread
* [PATCH v3 2/4] history: give commit_tree_ext a message template
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 1/4] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
@ 2026-06-18 19:17 ` Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 3/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
4 siblings, 0 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:17 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
commit_tree_ext() reuses the message of the commit it is handed. A
caller that folds several commits together wants to seed the message
from more than that single commit, so add an optional message_template
parameter. When NULL, the behavior is unchanged.
Pass NULL from the existing fixup and split callers.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index f95f26e684..305bde3102 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -101,6 +101,7 @@ enum commit_tree_flags {
static int commit_tree_ext(struct repository *repo,
const char *action,
struct commit *commit_with_message,
+ const char *message_template,
const struct commit_list *parents,
const struct object_id *old_tree,
const struct object_id *new_tree,
@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
+ if (!message_template)
+ message_template = original_body;
+
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
+ message_template, action, &commit_message);
if (ret < 0)
goto out;
} else {
- strbuf_addstr(&commit_message, original_body);
+ strbuf_addstr(&commit_message, message_template);
}
original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
return -1;
- return commit_tree_ext(repo, action, original, original->parents,
+ return commit_tree_ext(repo, action, original, NULL, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,
goto out;
if (!skip_commit) {
- ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
&original_tree->object.oid, &merge_result.tree->object.oid,
&rewritten, flags);
if (ret < 0) {
@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
--
gitgitgadget
^ permalink raw reply related [flat|nested] 23+ messages in thread
* [PATCH v3 3/4] history: add squash subcommand to fold a range
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 1/4] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 2/4] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
@ 2026-06-18 19:17 ` Harald Nordgren via GitGitGadget
2026-06-18 20:30 ` Junio C Hamano
2026-06-18 19:17 ` [PATCH v3 4/4] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
2026-06-18 21:23 ` [PATCH v3 0/4] history: add squash subcommand to fold a range D. Ben Knoble
4 siblings, 1 reply; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:17 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
Add "git history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
message and authorship and taking the tree of the newest commit, so the
range collapses into a single commit. Commits above the range are
replayed on top of the result.
The range is given as <base>..<tip>, so "git history squash @~3.."
folds the three most recent commits and "git history squash @~5..@~2"
squashes an interior range. A merge inside the range is folded like any
other commit, but the range must have a single base, so a range with
more than one entry point is rejected.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-history.adoc | 20 ++++
builtin/history.c | 154 ++++++++++++++++++++++++
t/meson.build | 1 +
t/t3454-history-squash.sh | 213 +++++++++++++++++++++++++++++++++
4 files changed, 388 insertions(+)
create mode 100755 t/t3454-history-squash.sh
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 2ba8121795..d3a5ad28a3 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
DESCRIPTION
-----------
@@ -97,6 +98,25 @@ linkgit:gitglossary[7].
It is invalid to select either all or no hunks, as that would lead to
one of the commits becoming empty.
+`squash <revision-range>`::
+ Fold all commits in _<revision-range>_ into the oldest commit of that
+ range. The resulting commit keeps the oldest commit's message and
+ authorship and takes the tree of the range's newest commit, so the
+ whole range collapses into a single commit. Commits above the range
+ are replayed on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash @~3..` folds the three most recent commits into one, and
+`git history squash @~5..@~2` squashes an interior range while leaving
+the two newest commits in place.
++
+The oldest commit's message and authorship are preserved by default,
+unless you specify `--reedit-message`. A merge commit inside the range is
+folded like any other, but the range must have a single base, so a range
+that reaches more than one entry point (for example a side branch that
+forked before the range and was later merged into it) is rejected.
+
OPTIONS
-------
diff --git a/builtin/history.c b/builtin/history.c
index 305bde3102..9d9416870f 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -30,6 +30,8 @@
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
+#define GIT_HISTORY_SQUASH_USAGE \
+ N_("git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]")
static void change_data_free(void *util, const char *str UNUSED)
{
@@ -973,6 +975,156 @@ out:
return ret;
}
+/*
+ * Resolve a "<base>..<tip>" revision range into the base commit just outside
+ * the range (which becomes the parent of the squashed commit), the oldest
+ * commit contained in the range (whose message the squash reuses), and the
+ * range tip (whose tree becomes the result). A merge inside the range is fine,
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+ const char *range,
+ struct commit **base_out,
+ struct commit **oldest_out,
+ struct commit **tip_out)
+{
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+ struct strvec args = STRVEC_INIT;
+ int ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_push(&args, "--boundary");
+ strvec_push(&args, range);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (args.nr != 1) {
+ ret = error(_("'%s' does not name a revision range"), range);
+ goto out;
+ }
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
+ if (base) {
+ ret = error(_("range '%s' has more than one base; "
+ "cannot squash"), range);
+ goto out;
+ }
+ base = commit;
+ continue;
+ }
+ if (!oldest)
+ oldest = commit;
+ tip = commit;
+ }
+
+ if (!oldest) {
+ ret = error(_("the range '%s' is empty"), range);
+ goto out;
+ }
+
+ if (!base) {
+ ret = error(_("cannot squash the root commit"));
+ goto out;
+ }
+
+ *base_out = base;
+ *oldest_out = oldest;
+ *tip_out = tip;
+ ret = 0;
+
+out:
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
+static int cmd_history_squash(int argc,
+ const char **argv,
+ const char *prefix,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_SQUASH_USAGE,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct commit *base, *oldest, *tip, *rewritten;
+ const struct object_id *base_tree_oid, *tip_tree_oid;
+ struct commit_list *parents = NULL;
+ struct rev_info revs = { 0 };
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
+ if (argc != 1) {
+ ret = error(_("command expects a single revision range"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ ret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip);
+ if (ret < 0)
+ goto out;
+
+ ret = setup_revwalk(repo, action, tip, &revs);
+ if (ret < 0)
+ goto out;
+
+ base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+ tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+ commit_list_append(base, &parents);
+
+ ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+ base_tree_oid, tip_tree_oid, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing squashed commit"));
+ goto out;
+ }
+
+ strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, tip, rewritten,
+ reflog_msg.buf, dry_run,
+ REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ strbuf_release(&reflog_msg);
+ commit_list_free(parents);
+ release_revisions(&revs);
+ return ret;
+}
+
int cmd_history(int argc,
const char **argv,
const char *prefix,
@@ -982,6 +1134,7 @@ int cmd_history(int argc,
GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
+ GIT_HISTORY_SQUASH_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
@@ -989,6 +1142,7 @@ int cmd_history(int argc,
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
+ OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
OPT_END(),
};
diff --git a/t/meson.build b/t/meson.build
index 3219264fe7..d7ae5a46ef 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -399,6 +399,7 @@ integration_tests = [
't3451-history-reword.sh',
't3452-history-split.sh',
't3453-history-fixup.sh',
+ 't3454-history-squash.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3454-history-squash.sh b/t/t3454-history-squash.sh
new file mode 100755
index 0000000000..6c6a75bf00
--- /dev/null
+++ b/t/t3454-history-squash.sh
@@ -0,0 +1,213 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'setup linear history touching two files' '
+ test_commit base file a &&
+ git tag start &&
+ test_commit one other x &&
+ test_commit two file c &&
+ test_commit three file d
+'
+
+test_expect_success 'errors on missing range argument' '
+ test_must_fail git history squash 2>err &&
+ test_grep "command expects a single revision range" err
+'
+
+test_expect_success 'errors on too many arguments' '
+ test_must_fail git history squash start.. HEAD 2>err &&
+ test_grep "command expects a single revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+ test_must_fail git history squash HEAD..HEAD 2>err &&
+ test_grep "the range .* is empty" err
+'
+
+test_expect_success 'errors when the range includes the root commit' '
+ test_must_fail git history squash HEAD 2>err &&
+ test_grep "cannot squash the root commit" err
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
+ git reset --hard three &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ git log --format="%s" -1 >subject &&
+ echo one >expect &&
+ test_cmp expect subject &&
+ git reflog >reflog &&
+ test_grep "squash: updating" reflog
+'
+
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+ git reset --hard three &&
+ final_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..@~1 &&
+
+ git log --format="%s" start..HEAD >actual &&
+ cat >expect <<-\EOF &&
+ three
+ one
+ EOF
+ test_cmp expect actual &&
+
+ test_cmp_rev start HEAD~2 &&
+ test "$final_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes when the base is the root commit' '
+ git reset --hard three &&
+ root=$(git rev-list --max-parents=0 HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash "$root.." &&
+
+ git rev-list --count "$root..HEAD" >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$root" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashing a single-commit range replays the rest' '
+ git reset --hard three &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..@~2 &&
+
+ git log --format="%s" start..HEAD >actual &&
+ cat >expect <<-\EOF &&
+ three
+ two
+ one
+ EOF
+ test_cmp expect actual &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'reuses the message of a fixup! commit in the range' '
+ git reset --hard start &&
+ test_commit reg1 file b &&
+ git commit --allow-empty -m "fixup! reg1" &&
+ test_commit reg2 file c &&
+
+ git history squash start.. &&
+
+ git log --format="%s" -1 >actual &&
+ echo reg1 >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success 'keeps the oldest message even if it is a fixup!' '
+ git reset --hard start &&
+ test_commit --no-tag "fixup! something" file b &&
+ test_commit tail file c &&
+
+ git history squash start.. &&
+
+ git log --format="%s" -1 >actual &&
+ echo "fixup! something" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success 'preserves authorship of the oldest commit' '
+ git reset --hard start &&
+ GIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \
+ test_commit oldest file b &&
+ test_commit newest file c &&
+
+ git history squash start.. &&
+
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Squasher <squash@example.com>" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--dry-run predicts the rewrite without performing it' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ git history squash --dry-run start.. >out &&
+ grep "^update refs/heads/" out >update &&
+ predicted=$(awk "{print \$3}" update) &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git history squash start.. &&
+ test "$predicted" = "$(git rev-parse HEAD)"
+'
+
+test_expect_success '--update-refs=head only moves HEAD' '
+ git reset --hard three &&
+ git branch -f other HEAD &&
+ other_before=$(git rev-parse other) &&
+
+ git history squash --update-refs=head start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$other_before" other
+'
+
+test_expect_success '--update-refs=branches moves a branch pointing into the range' '
+ git reset --hard three &&
+ git branch -f mid HEAD~2 &&
+ mid_before=$(git rev-parse mid) &&
+
+ git history squash start..@~1 &&
+
+ test_cmp_rev "$mid_before" mid &&
+ test_commit_message mid -m one
+'
+
+test_expect_success 'squashes a range whose internal merge has a single base' '
+ git reset --hard start &&
+ test_commit before-side file b &&
+ git checkout -b inner-side &&
+ test_commit on-inner-side inner x &&
+ git checkout - &&
+ test_commit after-side file c &&
+ git merge --no-ff -m merge inner-side &&
+ test_commit after-merge file d &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ git log --format="%s" -1 >subject &&
+ echo before-side >expect &&
+ test_cmp expect subject &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file inner
+'
+
+test_expect_success 'refuses to squash a range with more than one base' '
+ git reset --hard start &&
+ head_before=$(git rev-parse HEAD) &&
+ git checkout -b forked-before &&
+ test_commit forked-side fside x &&
+ git checkout - &&
+ test_commit forked-main file b &&
+ git merge --no-ff -m merge forked-before &&
+ merged=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash forked-main.. 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 23+ messages in thread
* [PATCH v3 4/4] history: re-edit a squash with every message
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-18 19:17 ` [PATCH v3 3/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-06-18 19:17 ` Harald Nordgren via GitGitGadget
2026-06-18 21:23 ` [PATCH v3 0/4] history: add squash subcommand to fold a range D. Ben Knoble
4 siblings, 0 replies; 23+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:17 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
By default "git history squash" reuses the oldest commit's message.
When --reedit-message is given it only reopened that one message, so the
messages of the folded-in commits were lost.
Gather the messages of every commit in the range, oldest first, and use
them as the editor template when re-editing, mirroring how "git rebase
-i" presents a squash. The combined message is built before the
descendant walk so it is not disturbed by the flags that walk leaves on
the commits.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-history.adoc | 5 +--
builtin/history.c | 61 +++++++++++++++++++++++++++++++++-
t/t3454-history-squash.sh | 37 +++++++++++++++++++++
3 files changed, 100 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index d3a5ad28a3..dd3544832d 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -111,8 +111,9 @@ history squash @~3..` folds the three most recent commits into one, and
`git history squash @~5..@~2` squashes an interior range while leaving
the two newest commits in place.
+
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
+The oldest commit's message and authorship are preserved by default. With
+`--reedit-message`, an editor opens pre-filled with the messages of all the
+folded commits so you can combine them. A merge commit inside the range is
folded like any other, but the range must have a single base, so a range
that reaches more than one entry point (for example a side branch that
forked before the range and was later merged into it) is rejected.
diff --git a/builtin/history.c b/builtin/history.c
index 9d9416870f..eb12a5d7e8 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1047,6 +1047,56 @@ out:
return ret;
}
+static int build_squash_message(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct strbuf *out)
+{
+ struct rev_info revs;
+ struct commit *commit;
+ struct strvec args = STRVEC_INIT;
+ int n = 0, ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs))) {
+ const char *message, *body;
+ struct strbuf one = STRBUF_INIT;
+
+ message = repo_logmsg_reencode(repo, commit, NULL, NULL);
+ find_commit_subject(message, &body);
+ strbuf_addstr(&one, body);
+ strbuf_trim_trailing_newline(&one);
+
+ if (n++)
+ strbuf_addch(out, '\n');
+ strbuf_addbuf(out, &one);
+ strbuf_addch(out, '\n');
+
+ strbuf_release(&one);
+ repo_unuse_commit_buffer(repo, commit, message);
+ }
+
+ ret = 0;
+
+out:
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
@@ -1071,6 +1121,7 @@ static int cmd_history_squash(int argc,
OPT_END(),
};
struct strbuf reflog_msg = STRBUF_INIT;
+ struct strbuf message = STRBUF_INIT;
struct commit *base, *oldest, *tip, *rewritten;
const struct object_id *base_tree_oid, *tip_tree_oid;
struct commit_list *parents = NULL;
@@ -1091,6 +1142,12 @@ static int cmd_history_squash(int argc,
if (ret < 0)
goto out;
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
+ }
+
ret = setup_revwalk(repo, action, tip, &revs);
if (ret < 0)
goto out;
@@ -1099,7 +1156,8 @@ static int cmd_history_squash(int argc,
tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
commit_list_append(base, &parents);
- ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+ ret = commit_tree_ext(repo, "squash", oldest,
+ message.len ? message.buf : NULL, parents,
base_tree_oid, tip_tree_oid, &rewritten, flags);
if (ret < 0) {
ret = error(_("failed writing squashed commit"));
@@ -1120,6 +1178,7 @@ static int cmd_history_squash(int argc,
out:
strbuf_release(&reflog_msg);
+ strbuf_release(&message);
commit_list_free(parents);
release_revisions(&revs);
return ret;
diff --git a/t/t3454-history-squash.sh b/t/t3454-history-squash.sh
index 6c6a75bf00..1edd148295 100755
--- a/t/t3454-history-squash.sh
+++ b/t/t3454-history-squash.sh
@@ -135,6 +135,43 @@ test_expect_success 'preserves authorship of the oldest commit' '
test_cmp expect actual
'
+test_expect_success '--reedit-message offers every folded-in message' '
+ git reset --hard start &&
+ echo b >file &&
+ git add file &&
+ git commit -m "re-one subject" -m "re-one body line" &&
+ test_commit re-two file c &&
+ test_commit re-three file d &&
+
+ write_script editor <<-\EOF &&
+ cp "$1" buffer &&
+ echo combined >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ grep "re-one subject" buffer &&
+ grep "re-one body line" buffer &&
+ grep re-two buffer &&
+ grep re-three buffer &&
+ git log --format="%s" -1 >actual &&
+ echo combined >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ write_script editor <<-\EOF &&
+ >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ test_must_fail git history squash --reedit-message start.. &&
+
+ test_cmp_rev "$head_before" HEAD
+'
+
test_expect_success '--dry-run predicts the rewrite without performing it' '
git reset --hard three &&
head_before=$(git rev-parse HEAD) &&
--
gitgitgadget
^ permalink raw reply related [flat|nested] 23+ messages in thread
* Re: [PATCH v3 3/4] history: add squash subcommand to fold a range
2026-06-18 19:17 ` [PATCH v3 3/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-06-18 20:30 ` Junio C Hamano
2026-06-18 21:24 ` Junio C Hamano
0 siblings, 1 reply; 23+ messages in thread
From: Junio C Hamano @ 2026-06-18 20:30 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> +static int cmd_history_squash(int argc,
> + const char **argv,
> + const char *prefix,
> + struct repository *repo)
> +{
> + base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
> + tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
> + commit_list_append(base, &parents);
> +
> + ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
> + base_tree_oid, tip_tree_oid, &rewritten, flags);
We use the tree object taken from the commit at the top end of the
range, and create a new commit directly on top of the boundary
commit beyond the bottom of the range, using the message from the
commit at the bottom of the range. No need to go through the
rigmarole of replaying commits in the range stepwise like sequencer
does, since we are not transplanting the history on top of a
different tree at all. Very nice.
When I do drunken-walk development to build many commits, making
detour to arrive at an ideal state, the key message is often not in
the bottommost commit but somewhere in the middle where I discovered
why my initial attempt were wrong and discovered a much better
solution, so using only the message from the oldest limits the
usefulness of this feature, but I guess for certain people the
bottommost commit would be a good default.
I see you have already an option to grab messages from all the
commits in the range (many of which may have useless "oops, that was
wrong" single-liner) in a way similar to how "git rebase --squash"
or "squash" insn in the "git rebase -i" todo list lets you use them
in the next step, which is workable. It is plausible that we would
later want to offer an option to name the single commit that may not
be the bottommost one and use the message only from that commit.
But we'd need to start from somewhere, and "use the bottommost
commit and nothing else" and "we will give you messages from all the
commits, just rearrange them in your editor" may be a good place to
start.
As t3454 is taken by another topic already in flight, I've queued a
trivial "rename it to t3455" patch on top before queuing the topic.
Thanks.
^ permalink raw reply [flat|nested] 23+ messages in thread
* Re: [PATCH v3 0/4] history: add squash subcommand to fold a range
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-06-18 19:17 ` [PATCH v3 4/4] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
@ 2026-06-18 21:23 ` D. Ben Knoble
4 siblings, 0 replies; 23+ messages in thread
From: D. Ben Knoble @ 2026-06-18 21:23 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
On Thu, Jun 18, 2026 at 3:17 PM Harald Nordgren via GitGitGadget
<gitgitgadget@gmail.com> wrote:
>
> Adds git history squash <revision-range> to fold a range of commits into its
> oldest one, reusing that commit's message and replaying any descendants on
> top.
>
> 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).
I think I mentioned this, too, albeit indirectly. I'm not concerned
about credit, though. Just excited to have this.
Thanks!
> * A merge inside the range is folded fine, only a range with more than one
> base is rejected.
> * --reedit-message seeds the editor with every folded-in message, not just
> the oldest.
>
> Harald Nordgren (4):
> history: extract helper for a commit's parent tree
> history: give commit_tree_ext a message template
> history: add squash subcommand to fold a range
> history: re-edit a squash with every message
^ permalink raw reply [flat|nested] 23+ messages in thread
* Re: [PATCH v3 3/4] history: add squash subcommand to fold a range
2026-06-18 20:30 ` Junio C Hamano
@ 2026-06-18 21:24 ` Junio C Hamano
2026-06-18 21:29 ` D. Ben Knoble
0 siblings, 1 reply; 23+ messages in thread
From: Junio C Hamano @ 2026-06-18 21:24 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
Junio C Hamano <gitster@pobox.com> writes:
> As t3454 is taken by another topic already in flight, I've queued a
> trivial "rename it to t3455" patch on top before queuing the topic.
Another tweak I had to make was to replace "grep" with "test_grep"
to avoid triggering test lint added by another topic in flight.
For the one in the second hunk, it may be much better to rewrite it
to process "out" directly with the awk script without preprocessing
it with "grep", as awk is a programming language capable enough to
recognize a line that matches a pattern and process only those
matching lines by itself.
--- >8 ---
Author: Junio C Hamano <gitster@pobox.com>
Date: Thu Jun 18 13:44:36 2026 -0700
SQUASH??? avoid test_grep lint triggering on uses of raw grep
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 1edd148295..20370c0136 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -150,10 +150,10 @@ test_expect_success '--reedit-message offers every folded-in message' '
test_set_editor "$(pwd)/editor" &&
git history squash --reedit-message start.. &&
- grep "re-one subject" buffer &&
- grep "re-one body line" buffer &&
- grep re-two buffer &&
- grep re-three buffer &&
+ test_grep "re-one subject" buffer &&
+ test_grep "re-one body line" buffer &&
+ test_grep re-two buffer &&
+ test_grep re-three buffer &&
git log --format="%s" -1 >actual &&
echo combined >expect &&
test_cmp expect actual
@@ -177,7 +177,7 @@ test_expect_success '--dry-run predicts the rewrite without performing it' '
head_before=$(git rev-parse HEAD) &&
git history squash --dry-run start.. >out &&
- grep "^update refs/heads/" out >update &&
+ test_grep "^update refs/heads/" out >update &&
predicted=$(awk "{print \$3}" update) &&
test_cmp_rev "$head_before" HEAD &&
^ permalink raw reply related [flat|nested] 23+ messages in thread
* Re: [PATCH v3 3/4] history: add squash subcommand to fold a range
2026-06-18 21:24 ` Junio C Hamano
@ 2026-06-18 21:29 ` D. Ben Knoble
0 siblings, 0 replies; 23+ messages in thread
From: D. Ben Knoble @ 2026-06-18 21:29 UTC (permalink / raw)
To: Junio C Hamano; +Cc: Harald Nordgren via GitGitGadget, git, Harald Nordgren
On Thu, Jun 18, 2026 at 5:25 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> Junio C Hamano <gitster@pobox.com> writes:
>
> > As t3454 is taken by another topic already in flight, I've queued a
> > trivial "rename it to t3455" patch on top before queuing the topic.
>
> Another tweak I had to make was to replace "grep" with "test_grep"
> to avoid triggering test lint added by another topic in flight.
>
> For the one in the second hunk, it may be much better to rewrite it
> to process "out" directly with the awk script without preprocessing
> it with "grep", as awk is a programming language capable enough to
> recognize a line that matches a pattern and process only those
> matching lines by itself.
>
> --- >8 ---
> Author: Junio C Hamano <gitster@pobox.com>
> Date: Thu Jun 18 13:44:36 2026 -0700
>
> SQUASH??? avoid test_grep lint triggering on uses of raw grep
>
> diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
> index 1edd148295..20370c0136 100755
> --- a/t/t3455-history-squash.sh
> +++ b/t/t3455-history-squash.sh
[snip]
> @@ -177,7 +177,7 @@ test_expect_success '--dry-run predicts the rewrite without performing it' '
> head_before=$(git rev-parse HEAD) &&
>
> git history squash --dry-run start.. >out &&
> - grep "^update refs/heads/" out >update &&
> + test_grep "^update refs/heads/" out >update &&
> predicted=$(awk "{print \$3}" update) &&
> test_cmp_rev "$head_before" HEAD &&
Odd: I thought the other topic acknowledged that bare grep as a filter
(here, with stdout redirected) was fine. My memory must not be right
:)
--
D. Ben Knoble
^ permalink raw reply [flat|nested] 23+ messages in thread
end of thread, other threads:[~2026-06-18 21:29 UTC | newest]
Thread overview: 23+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-14 19:25 [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 2/2] rebase: add --fixup-all to fold a range Harald Nordgren via GitGitGadget
2026-06-15 2:01 ` [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Junio C Hamano
2026-06-15 8:18 ` Harald Nordgren
2026-06-15 15:17 ` D. Ben Knoble
2026-06-16 8:34 ` Patrick Steinhardt
2026-06-17 9:30 ` Harald Nordgren
2026-06-15 8:37 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 2/2] rebase: add --squash to fold a range Harald Nordgren via GitGitGadget
2026-06-16 10:10 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Phillip Wood
2026-06-17 9:11 ` Harald Nordgren
2026-06-17 9:48 ` Phillip Wood
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 1/4] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 2/4] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 3/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-06-18 20:30 ` Junio C Hamano
2026-06-18 21:24 ` Junio C Hamano
2026-06-18 21:29 ` D. Ben Knoble
2026-06-18 19:17 ` [PATCH v3 4/4] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
2026-06-18 21:23 ` [PATCH v3 0/4] history: add squash subcommand to fold a range D. Ben Knoble
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox