{"thread":{"id":"65812","subject":"[PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit","startedAt":"2026-06-14T19:25:44Z","lastAt":"2026-09-08T20:57:09Z","messageCount":185,"participants":["Harald Nordgren via GitGitGadget","Junio C Hamano","Harald Nordgren","D. Ben Knoble","Patrick Steinhardt","Phillip Wood","Matt Hunter","Ben Knoble","Kristoffer Haugsbakk","Tuomas Ahola"],"isPatch":true,"patchVersion":1,"patchTotal":2},"messages":[{"id":"545502","messageId":"pull.2337.git.git.1781465141.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":null,"subject":"[PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-14T19:25:39Z","receivedAt":"2026-06-14T19:25:44Z","isPatch":true,"body":"Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits\ninto its oldest one, reusing that commit's message.\n\nRelated idea: https://github.com/gitgitgadget/git/issues/1135\n\nHarald Nordgren (2):\n  t3415: remove prepare-commit-msg hook after use\n  rebase: add --fixup-all to fold a range\n\n Documentation/git-rebase.adoc |  11 ++++\n builtin/rebase.c              |  13 ++++-\n sequencer.c                   |  24 +++++++-\n sequencer.h                   |   2 +-\n t/t3415-rebase-autosquash.sh  | 106 ++++++++++++++++++++++++++++++++++\n 5 files changed, 152 insertions(+), 4 deletions(-)\n\n\nbase-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v1\nPull-Request: https://github.com/git/git/pull/2337\n-- \ngitgitgadget\n"},{"id":"545503","messageId":"c55b9cd6f749bf1470dba46e6bfea4c0472d3490.1781465141.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.git.git.1781465141.gitgitgadget@gmail.com","subject":"[PATCH 1/2] t3415: remove prepare-commit-msg hook after use","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-14T19:25:40Z","receivedAt":"2026-06-14T19:25:45Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThe \"pick and fixup respect commit.cleanup\" test left its\nprepare-commit-msg hook in place, leaking it into later tests. Remove it\nwith test_when_finished.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n t/t3415-rebase-autosquash.sh | 1 +\n 1 file changed, 1 insertion(+)\n\ndiff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\nindex 5033411a43..8964d1cc88 100755\n--- a/t/t3415-rebase-autosquash.sh\n+++ b/t/t3415-rebase-autosquash.sh\n@@ -490,6 +490,7 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n \tgit reset --hard base &&\n \ttest_commit --no-tag \"fixup! second commit\" file1 fixup &&\n \ttest_commit something &&\n+\ttest_when_finished \"rm -f .git/hooks/prepare-commit-msg\" &&\n \twrite_script .git/hooks/prepare-commit-msg <<-\\EOF &&\n \tprintf \"\\n# Prepared\\n\" >> \"$1\"\n \tEOF\n-- \ngitgitgadget\n\n"},{"id":"545504","messageId":"bd1bc62aa81579e19180d06925940157d7f6f155.1781465141.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.git.git.1781465141.gitgitgadget@gmail.com","subject":"[PATCH 2/2] rebase: add --fixup-all to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-14T19:25:41Z","receivedAt":"2026-06-14T19:25:48Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git rebase --autosquash --fixup-all [<upstream>]\" to do this\ndirectly. It keeps the first commit in the range as a \"pick\" and turns\nevery later commit into a \"fixup\", so the whole range collapses into a\nsingle commit that reuses the first commit's message. With no <upstream>\nargument the range is \"@{upstream}..HEAD\", folding all unpushed commits\ninto one.\n\nFold the commits in their original order, so that any fixup!/squash!\ncommits already present in the range are folded in as well. Allow the\nflag only together with --autosquash, and reject --rebase-merges since a\nmerge commit cannot be folded into another commit.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-rebase.adoc |  11 ++++\n builtin/rebase.c              |  13 ++++-\n sequencer.c                   |  24 +++++++-\n sequencer.h                   |   2 +-\n t/t3415-rebase-autosquash.sh  | 105 ++++++++++++++++++++++++++++++++++\n 5 files changed, 151 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc\nindex f6c22d1598..d1a3e4ef64 100644\n--- a/Documentation/git-rebase.adoc\n+++ b/Documentation/git-rebase.adoc\n@@ -602,6 +602,16 @@ option can be used to override that setting.\n +\n See also INCOMPATIBLE OPTIONS below.\n \n+--fixup-all::\n+\tValid only when used with `--autosquash`.  Keep the first commit in\n+\tthe range as a `pick` and change every later commit to a `fixup`, so\n+\tthe whole range is folded into a single commit that reuses the first\n+\tcommit's message.  With no `<upstream>` argument this folds all commits\n+\tsince `@{upstream}` into one.  The commits are folded in their original\n+\torder, so any `fixup!`/`squash!` commits already in the range are folded\n+\tin as well.  Cannot be combined with `--rebase-merges`, as a merge\n+\tcommit cannot be folded into another commit.\n+\n --autostash::\n --no-autostash::\n \tAutomatically create a temporary stash entry before the operation\n@@ -652,6 +662,7 @@ are incompatible with the following options:\n  * --strategy\n  * --strategy-option\n  * --autosquash\n+ * --fixup-all\n  * --rebase-merges\n  * --interactive\n  * --exec\ndiff --git a/builtin/rebase.c b/builtin/rebase.c\nindex fa4f5d9306..a363fbc1f2 100644\n--- a/builtin/rebase.c\n+++ b/builtin/rebase.c\n@@ -118,6 +118,7 @@ struct rebase_options {\n \tint allow_rerere_autoupdate;\n \tint keep_empty;\n \tint autosquash;\n+\tint fixup_all;\n \tchar *gpg_sign_opt;\n \tint autostash;\n \tint committer_date_is_author_date;\n@@ -329,7 +330,8 @@ static int do_interactive_rebase(struct rebase_options *opts, unsigned flags)\n \tret = complete_action(the_repository, &replay, flags,\n \t\tshortrevisions, opts->onto_name, opts->onto,\n \t\t&opts->orig_head->object.oid, &opts->exec,\n-\t\topts->autosquash, opts->update_refs, &todo_list);\n+\t\topts->autosquash, opts->fixup_all, opts->update_refs,\n+\t\t&todo_list);\n \n cleanup:\n \treplay_opts_release(&replay);\n@@ -1205,6 +1207,8 @@ int cmd_rebase(int argc,\n \t\tOPT_BOOL(0, \"autosquash\", &options.autosquash,\n \t\t\t N_(\"move commits that begin with \"\n \t\t\t    \"squash!/fixup! under -i\")),\n+\t\tOPT_BOOL(0, \"fixup-all\", &options.fixup_all,\n+\t\t\t N_(\"fold all commits in the range into the first one\")),\n \t\tOPT_BOOL(0, \"update-refs\", &options.update_refs,\n \t\t\t N_(\"update branches that point to commits \"\n \t\t\t    \"that are being rebased\")),\n@@ -1594,6 +1598,13 @@ int cmd_rebase(int argc,\n \toptions.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :\n \t\t\t\t((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);\n \n+\tif (options.fixup_all && options.autosquash != 1)\n+\t\tdie(_(\"--fixup-all requires --autosquash\"));\n+\n+\tif (options.fixup_all && options.rebase_merges)\n+\t\tdie(_(\"options '%s' and '%s' cannot be used together\"),\n+\t\t    \"--fixup-all\", \"--rebase-merges\");\n+\n \tif (options.autosquash == 1) {\n \t\timply_merge(&options, \"--autosquash\");\n \t} else if (options.autosquash == -1) {\ndiff --git a/sequencer.c b/sequencer.c\nindex 57855b0066..eeaf6226fe 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -6554,11 +6554,29 @@ static int todo_list_add_update_ref_commands(struct todo_list *todo_list)\n \treturn 0;\n }\n \n+static void todo_list_fixup_all_but_first(struct todo_list *todo_list)\n+{\n+\tint i, seen_first = 0;\n+\n+\tfor (i = 0; i < todo_list->nr; i++) {\n+\t\tstruct todo_item *item = todo_list->items + i;\n+\n+\t\tif (!item->commit || item->command == TODO_DROP)\n+\t\t\tcontinue;\n+\t\tif (!seen_first) {\n+\t\t\tseen_first = 1;\n+\t\t\titem->command = TODO_PICK;\n+\t\t\tcontinue;\n+\t\t}\n+\t\titem->command = TODO_FIXUP;\n+\t}\n+}\n+\n int complete_action(struct repository *r, struct replay_opts *opts, unsigned flags,\n \t\t    const char *shortrevisions, const char *onto_name,\n \t\t    struct commit *onto, const struct object_id *orig_head,\n \t\t    struct string_list *commands, unsigned autosquash,\n-\t\t    unsigned update_refs,\n+\t\t    unsigned fixup_all, unsigned update_refs,\n \t\t    struct todo_list *todo_list)\n {\n \tchar shortonto[GIT_MAX_HEXSZ + 1];\n@@ -6581,7 +6599,9 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla\n \tif (update_refs && todo_list_add_update_ref_commands(todo_list))\n \t\treturn -1;\n \n-\tif (autosquash && todo_list_rearrange_squash(todo_list))\n+\tif (fixup_all)\n+\t\ttodo_list_fixup_all_but_first(todo_list);\n+\telse if (autosquash && todo_list_rearrange_squash(todo_list))\n \t\treturn -1;\n \n \tif (commands->nr)\ndiff --git a/sequencer.h b/sequencer.h\nindex 3164bd437d..9bb6b42c94 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -196,7 +196,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla\n \t\t    const char *shortrevisions, const char *onto_name,\n \t\t    struct commit *onto, const struct object_id *orig_head,\n \t\t    struct string_list *commands, unsigned autosquash,\n-\t\t    unsigned update_refs,\n+\t\t    unsigned fixup_all, unsigned update_refs,\n \t\t    struct todo_list *todo_list);\n int todo_list_rearrange_squash(struct todo_list *todo_list);\n \ndiff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\nindex 8964d1cc88..21d4159ebd 100755\n--- a/t/t3415-rebase-autosquash.sh\n+++ b/t/t3415-rebase-autosquash.sh\n@@ -511,4 +511,109 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n \ttest_commit_message HEAD -m \"something\"\n '\n \n+test_expect_success '--fixup-all folds the range into the first commit' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag fold1 file_fold a &&\n+\ttest_commit --no-tag fold2 file_fold b &&\n+\ttest_commit --no-tag fold3 file_fold c &&\n+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"fold1\" &&\n+\techo c >expect &&\n+\ttest_cmp expect file_fold\n+'\n+\n+test_expect_success '--fixup-all folds smoothly when a fixup! commit is in the series' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag foldA file_fold a &&\n+\ttest_commit --no-tag foldB file_fold b &&\n+\tgit commit --allow-empty --fixup HEAD~1 &&\n+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"foldA\" &&\n+\techo b >expect &&\n+\ttest_cmp expect file_fold\n+'\n+\n+test_expect_success '--fixup-all picks the first commit even if it is a fixup!' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag fixupbase file_fix a &&\n+\tgit commit --allow-empty --fixup HEAD &&\n+\ttest_commit --no-tag fixuptail file_fix b &&\n+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\techo b >expect &&\n+\ttest_cmp expect file_fix\n+'\n+\n+test_expect_success '--fixup-all with a single commit in range is a no-op' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag solo file_solo a &&\n+\tgit rev-parse HEAD >expect &&\n+\tgit rebase --autosquash --fixup-all HEAD~1 &&\n+\tgit rev-parse HEAD >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--fixup-all with an empty range succeeds' '\n+\tgit reset --hard base &&\n+\tgit rebase --autosquash --fixup-all HEAD &&\n+\ttest_cmp_rev base HEAD\n+'\n+\n+test_expect_success '--fixup-all skips a dropped commit in the range' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag fixdrop1 file_drop a &&\n+\tgit commit --allow-empty -m \"empty in the middle\" &&\n+\ttest_commit --no-tag fixdrop3 file_drop b &&\n+\tgit rebase --autosquash --empty=drop --fixup-all HEAD~3 &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"fixdrop1\" &&\n+\techo b >expect &&\n+\ttest_cmp expect file_drop\n+'\n+\n+test_expect_success '--fixup-all folds a merge commit in the middle of the range' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag mid-first &&\n+\tgit checkout -b mid-side &&\n+\ttest_commit --no-tag mid-merged &&\n+\tgit checkout - &&\n+\tgit merge --no-ff -m \"merge mid-side\" mid-side &&\n+\ttest_commit --no-tag mid-last &&\n+\tgit rebase --autosquash --fixup-all base &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"mid-first\" &&\n+\ttest_path_is_file mid-merged.t\n+'\n+\n+test_expect_success '--fixup-all keeps the first flattened commit when a merge sorts first' '\n+\tgit reset --hard base &&\n+\tgit checkout -b head-side &&\n+\ttest_commit --no-tag head-merged &&\n+\tgit checkout - &&\n+\tgit merge --no-ff -m \"merge head-side\" head-side &&\n+\ttest_commit --no-tag head-last &&\n+\tgit rebase --autosquash --fixup-all base &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"head-merged\" &&\n+\ttest_path_is_file head-merged.t\n+'\n+\n+test_expect_success '--fixup-all requires --autosquash' '\n+\tgit reset --hard base &&\n+\ttest_must_fail git rebase --fixup-all HEAD~1 2>err &&\n+\ttest_grep \"fixup-all requires --autosquash\" err &&\n+\ttest_must_fail git rebase --no-autosquash --fixup-all HEAD~1 2>err &&\n+\ttest_grep \"fixup-all requires --autosquash\" err\n+'\n+\n+test_expect_success '--fixup-all and --rebase-merges cannot be combined' '\n+\tgit reset --hard base &&\n+\ttest_must_fail git rebase --autosquash --rebase-merges \\\n+\t\t--fixup-all HEAD~1 2>err &&\n+\ttest_grep \"cannot be used together\" err &&\n+\ttest_path_is_missing .git/rebase-merge\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"545509","messageId":"xmqqqzm8d0j7.fsf@gitster.g","threadId":"65812","inReplyTo":"pull.2337.git.git.1781465141.gitgitgadget@gmail.com","subject":"Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-15T02:01:48Z","receivedAt":"2026-06-15T02:01:51Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits\n> into its oldest one, reusing that commit's message.\n\n[2/2] seems to add \"--fixup-all\" but I agree with the \"related idea\"\nthat naming it and modelling it after \"merge --squash\" would be\neasier to understand.\n\n> Related idea: https://github.com/gitgitgadget/git/issues/1135\n\nI also wonder if we can do something like this without adding any\nnew option or command.  E.g., if you have four patch series, where\nthe initial implementation HEAD~3 is followed by \"oops it was still\nwrong\" fix-up HEAD~2, HEAD~1 and HEAD, then\n\n    git reset --soft HEAD~3 && git commit --amend --no-edit\n\nis what the user wants to do, no?\n\n\n"},{"id":"545521","messageId":"CAHwyqnWa55xbTpzq-Nf6cMyvgR1yYgg8fhvgMFkquSEGPUwDmg@mail.gmail.com","threadId":"65812","inReplyTo":"xmqqqzm8d0j7.fsf@gitster.g","subject":"Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-15T08:18:38Z","receivedAt":"2026-06-15T08:19:16Z","isPatch":true,"body":"> > Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits\n> > into its oldest one, reusing that commit's message.\n>\n> [2/2] seems to add \"--fixup-all\" but I agree with the \"related idea\"\n> that naming it and modelling it after \"merge --squash\" would be\n> easier to understand.\n\nSounds reasonable.\n\n> I also wonder if we can do something like this without adding any\n> new option or command.  E.g., if you have four patch series, where\n> the initial implementation HEAD~3 is followed by \"oops it was still\n> wrong\" fix-up HEAD~2, HEAD~1 and HEAD, then\n>\n>     git reset --soft HEAD~3 && git commit --amend --no-edit\n>\n> is what the user wants to do, no?\n\nI don't think it's enough. First of all the user has to know the N for\nHEAD~N, and then 'git reset --soft HEAD~N && git commit --amend\n--no-edit' is still quite ugly.\n\n\nHarald\n"},{"id":"545527","messageId":"pull.2337.v2.git.git.1781512625.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.git.git.1781465141.gitgitgadget@gmail.com","subject":"[PATCH v2 0/2] rebase: add --squash to fold a range into its first commit","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T08:37:03Z","receivedAt":"2026-06-15T08:37:07Z","isPatch":true,"body":"Rename to rebase --squash.\n\nHarald Nordgren (2):\n  t3415: remove prepare-commit-msg hook after use\n  rebase: add --squash to fold a range\n\n Documentation/git-rebase.adoc |  11 ++++\n builtin/rebase.c              |  16 ++++-\n sequencer.c                   |  24 ++++++-\n sequencer.h                   |   2 +-\n t/t3415-rebase-autosquash.sh  | 118 ++++++++++++++++++++++++++++++++++\n 5 files changed, 166 insertions(+), 5 deletions(-)\n\n\nbase-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v2\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v1:\n\n 1:  c55b9cd6f7 = 1:  c55b9cd6f7 t3415: remove prepare-commit-msg hook after use\n 2:  bd1bc62aa8 ! 2:  22d4276ff5 rebase: add --fixup-all to fold a range\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    rebase: add --fixup-all to fold a range\n     +    rebase: add --squash to fold a range\n      \n          Folding a series of commits into one required either an interactive\n          rebase where each commit after the first was hand-edited to \"fixup\", or\n          a \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n      \n     -    Add \"git rebase --autosquash --fixup-all [<upstream>]\" to do this\n     -    directly. It keeps the first commit in the range as a \"pick\" and turns\n     -    every later commit into a \"fixup\", so the whole range collapses into a\n     -    single commit that reuses the first commit's message. With no <upstream>\n     -    argument the range is \"@{upstream}..HEAD\", folding all unpushed commits\n     -    into one.\n     +    Add \"git rebase --squash [<upstream>]\" to do this directly. It keeps\n     +    the first commit in the range as a \"pick\" and turns every later commit\n     +    into a \"fixup\", so the whole range collapses into a single commit that\n     +    reuses the first commit's message. With no <upstream> argument the range\n     +    is \"@{upstream}..HEAD\", folding all unpushed commits into one.\n      \n     -    Fold the commits in their original order, so that any fixup!/squash!\n     -    commits already present in the range are folded in as well. Allow the\n     -    flag only together with --autosquash, and reject --rebase-merges since a\n     -    merge commit cannot be folded into another commit.\n     +    The option implies the merge backend, so it works on its own without\n     +    --autosquash. Fold the commits in their original order, so that any\n     +    fixup!/squash! commits already present in the range are folded in as\n     +    well. Reject --rebase-merges since a merge commit cannot be folded into\n     +    another commit.\n      \n     +    Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-rebase.adoc ##\n     @@ Documentation/git-rebase.adoc: option can be used to override that setting.\n       +\n       See also INCOMPATIBLE OPTIONS below.\n       \n     -+--fixup-all::\n     -+\tValid only when used with `--autosquash`.  Keep the first commit in\n     -+\tthe range as a `pick` and change every later commit to a `fixup`, so\n     -+\tthe whole range is folded into a single commit that reuses the first\n     -+\tcommit's message.  With no `<upstream>` argument this folds all commits\n     -+\tsince `@{upstream}` into one.  The commits are folded in their original\n     -+\torder, so any `fixup!`/`squash!` commits already in the range are folded\n     -+\tin as well.  Cannot be combined with `--rebase-merges`, as a merge\n     -+\tcommit cannot be folded into another commit.\n     ++--squash::\n     ++\tKeep the first commit in the range as a `pick` and change every later\n     ++\tcommit to a `fixup`, so the whole range is folded into a single commit\n     ++\tthat reuses the first commit's message.  With no `<upstream>` argument\n     ++\tthis folds all commits since `@{upstream}` into one.  The commits are\n     ++\tfolded in their original order, so any `fixup!`/`squash!` commits\n     ++\talready in the range are folded in as well.  Cannot be combined with\n     ++\t`--rebase-merges`, as a merge commit cannot be folded into another\n     ++\tcommit.\n      +\n       --autostash::\n       --no-autostash::\n     @@ Documentation/git-rebase.adoc: are incompatible with the following options:\n        * --strategy\n        * --strategy-option\n        * --autosquash\n     -+ * --fixup-all\n     ++ * --squash\n        * --rebase-merges\n        * --interactive\n        * --exec\n     @@ builtin/rebase.c: struct rebase_options {\n       \tint allow_rerere_autoupdate;\n       \tint keep_empty;\n       \tint autosquash;\n     -+\tint fixup_all;\n     ++\tint squash;\n       \tchar *gpg_sign_opt;\n       \tint autostash;\n       \tint committer_date_is_author_date;\n     @@ builtin/rebase.c: static int do_interactive_rebase(struct rebase_options *opts,\n       \t\tshortrevisions, opts->onto_name, opts->onto,\n       \t\t&opts->orig_head->object.oid, &opts->exec,\n      -\t\topts->autosquash, opts->update_refs, &todo_list);\n     -+\t\topts->autosquash, opts->fixup_all, opts->update_refs,\n     ++\t\topts->autosquash, opts->squash, opts->update_refs,\n      +\t\t&todo_list);\n       \n       cleanup:\n     @@ builtin/rebase.c: int cmd_rebase(int argc,\n       \t\tOPT_BOOL(0, \"autosquash\", &options.autosquash,\n       \t\t\t N_(\"move commits that begin with \"\n       \t\t\t    \"squash!/fixup! under -i\")),\n     -+\t\tOPT_BOOL(0, \"fixup-all\", &options.fixup_all,\n     ++\t\tOPT_BOOL(0, \"squash\", &options.squash,\n      +\t\t\t N_(\"fold all commits in the range into the first one\")),\n       \t\tOPT_BOOL(0, \"update-refs\", &options.update_refs,\n       \t\t\t N_(\"update branches that point to commits \"\n       \t\t\t    \"that are being rebased\")),\n     +@@ builtin/rebase.c: int cmd_rebase(int argc,\n     + \tif ((options.flags & REBASE_INTERACTIVE_EXPLICIT) ||\n     + \t    (options.action != ACTION_NONE) ||\n     + \t    (options.exec.nr > 0) ||\n     +-\t    options.autosquash == 1) {\n     ++\t    options.autosquash == 1 ||\n     ++\t    options.squash) {\n     + \t\tallow_preemptive_ff = 0;\n     + \t}\n     + \tif (options.committer_date_is_author_date || options.ignore_date)\n      @@ builtin/rebase.c: int cmd_rebase(int argc,\n       \toptions.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :\n       \t\t\t\t((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);\n       \n     -+\tif (options.fixup_all && options.autosquash != 1)\n     -+\t\tdie(_(\"--fixup-all requires --autosquash\"));\n     -+\n     -+\tif (options.fixup_all && options.rebase_merges)\n     ++\tif (options.squash && options.rebase_merges)\n      +\t\tdie(_(\"options '%s' and '%s' cannot be used together\"),\n     -+\t\t    \"--fixup-all\", \"--rebase-merges\");\n     ++\t\t    \"--squash\", \"--rebase-merges\");\n     ++\n     ++\tif (options.squash)\n     ++\t\timply_merge(&options, \"--squash\");\n      +\n       \tif (options.autosquash == 1) {\n       \t\timply_merge(&options, \"--autosquash\");\n     @@ sequencer.c: static int todo_list_add_update_ref_commands(struct todo_list *todo\n       \t\t    struct commit *onto, const struct object_id *orig_head,\n       \t\t    struct string_list *commands, unsigned autosquash,\n      -\t\t    unsigned update_refs,\n     -+\t\t    unsigned fixup_all, unsigned update_refs,\n     ++\t\t    unsigned squash, unsigned update_refs,\n       \t\t    struct todo_list *todo_list)\n       {\n       \tchar shortonto[GIT_MAX_HEXSZ + 1];\n     @@ sequencer.c: int complete_action(struct repository *r, struct replay_opts *opts,\n       \t\treturn -1;\n       \n      -\tif (autosquash && todo_list_rearrange_squash(todo_list))\n     -+\tif (fixup_all)\n     ++\tif (squash)\n      +\t\ttodo_list_fixup_all_but_first(todo_list);\n      +\telse if (autosquash && todo_list_rearrange_squash(todo_list))\n       \t\treturn -1;\n     @@ sequencer.h: int complete_action(struct repository *r, struct replay_opts *opts,\n       \t\t    struct commit *onto, const struct object_id *orig_head,\n       \t\t    struct string_list *commands, unsigned autosquash,\n      -\t\t    unsigned update_refs,\n     -+\t\t    unsigned fixup_all, unsigned update_refs,\n     ++\t\t    unsigned squash, unsigned update_refs,\n       \t\t    struct todo_list *todo_list);\n       int todo_list_rearrange_squash(struct todo_list *todo_list);\n       \n     @@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit\n       \ttest_commit_message HEAD -m \"something\"\n       '\n       \n     -+test_expect_success '--fixup-all folds the range into the first commit' '\n     ++test_expect_success '--squash folds the range into the first commit' '\n      +\tgit reset --hard base &&\n      +\ttest_commit --no-tag fold1 file_fold a &&\n      +\ttest_commit --no-tag fold2 file_fold b &&\n      +\ttest_commit --no-tag fold3 file_fold c &&\n     -+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n     ++\tgit rebase --squash HEAD~3 &&\n      +\ttest_cmp_rev base HEAD~1 &&\n      +\ttest_commit_message HEAD -m \"fold1\" &&\n      +\techo c >expect &&\n      +\ttest_cmp expect file_fold\n      +'\n      +\n     -+test_expect_success '--fixup-all folds smoothly when a fixup! commit is in the series' '\n     ++test_expect_success '--squash folds smoothly when a fixup! commit is in the series' '\n      +\tgit reset --hard base &&\n      +\ttest_commit --no-tag foldA file_fold a &&\n      +\ttest_commit --no-tag foldB file_fold b &&\n      +\tgit commit --allow-empty --fixup HEAD~1 &&\n     -+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n     ++\tgit rebase --squash HEAD~3 &&\n      +\ttest_cmp_rev base HEAD~1 &&\n      +\ttest_commit_message HEAD -m \"foldA\" &&\n      +\techo b >expect &&\n      +\ttest_cmp expect file_fold\n      +'\n      +\n     -+test_expect_success '--fixup-all picks the first commit even if it is a fixup!' '\n     ++test_expect_success '--squash picks the first commit even if it is a fixup!' '\n      +\tgit reset --hard base &&\n      +\ttest_commit --no-tag fixupbase file_fix a &&\n      +\tgit commit --allow-empty --fixup HEAD &&\n      +\ttest_commit --no-tag fixuptail file_fix b &&\n     -+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n     ++\tgit rebase --squash HEAD~3 &&\n      +\ttest_cmp_rev base HEAD~1 &&\n      +\techo b >expect &&\n      +\ttest_cmp expect file_fix\n      +'\n      +\n     -+test_expect_success '--fixup-all with a single commit in range is a no-op' '\n     ++test_expect_success '--squash with a single commit in range is a no-op' '\n      +\tgit reset --hard base &&\n      +\ttest_commit --no-tag solo file_solo a &&\n      +\tgit rev-parse HEAD >expect &&\n     -+\tgit rebase --autosquash --fixup-all HEAD~1 &&\n     ++\tgit rebase --squash HEAD~1 &&\n      +\tgit rev-parse HEAD >actual &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--fixup-all with an empty range succeeds' '\n     ++test_expect_success '--squash with an empty range succeeds' '\n      +\tgit reset --hard base &&\n     -+\tgit rebase --autosquash --fixup-all HEAD &&\n     ++\tgit rebase --squash HEAD &&\n      +\ttest_cmp_rev base HEAD\n      +'\n      +\n     -+test_expect_success '--fixup-all skips a dropped commit in the range' '\n     ++test_expect_success '--squash skips a dropped commit in the range' '\n      +\tgit reset --hard base &&\n      +\ttest_commit --no-tag fixdrop1 file_drop a &&\n      +\tgit commit --allow-empty -m \"empty in the middle\" &&\n      +\ttest_commit --no-tag fixdrop3 file_drop b &&\n     -+\tgit rebase --autosquash --empty=drop --fixup-all HEAD~3 &&\n     ++\tgit rebase --squash --empty=drop HEAD~3 &&\n      +\ttest_cmp_rev base HEAD~1 &&\n      +\ttest_commit_message HEAD -m \"fixdrop1\" &&\n      +\techo b >expect &&\n      +\ttest_cmp expect file_drop\n      +'\n      +\n     -+test_expect_success '--fixup-all folds a merge commit in the middle of the range' '\n     ++test_expect_success '--squash folds a merge commit in the middle of the range' '\n      +\tgit reset --hard base &&\n      +\ttest_commit --no-tag mid-first &&\n      +\tgit checkout -b mid-side &&\n     @@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit\n      +\tgit checkout - &&\n      +\tgit merge --no-ff -m \"merge mid-side\" mid-side &&\n      +\ttest_commit --no-tag mid-last &&\n     -+\tgit rebase --autosquash --fixup-all base &&\n     ++\tgit rebase --squash base &&\n      +\ttest_cmp_rev base HEAD~1 &&\n      +\ttest_commit_message HEAD -m \"mid-first\" &&\n      +\ttest_path_is_file mid-merged.t\n      +'\n      +\n     -+test_expect_success '--fixup-all keeps the first flattened commit when a merge sorts first' '\n     ++test_expect_success '--squash keeps the first flattened commit when a merge sorts first' '\n      +\tgit reset --hard base &&\n      +\tgit checkout -b head-side &&\n      +\ttest_commit --no-tag head-merged &&\n      +\tgit checkout - &&\n      +\tgit merge --no-ff -m \"merge head-side\" head-side &&\n      +\ttest_commit --no-tag head-last &&\n     -+\tgit rebase --autosquash --fixup-all base &&\n     ++\tgit rebase --squash base &&\n      +\ttest_cmp_rev base HEAD~1 &&\n      +\ttest_commit_message HEAD -m \"head-merged\" &&\n      +\ttest_path_is_file head-merged.t\n      +'\n      +\n     -+test_expect_success '--fixup-all requires --autosquash' '\n     ++test_expect_success '--squash takes precedence over --autosquash' '\n      +\tgit reset --hard base &&\n     -+\ttest_must_fail git rebase --fixup-all HEAD~1 2>err &&\n     -+\ttest_grep \"fixup-all requires --autosquash\" err &&\n     -+\ttest_must_fail git rebase --no-autosquash --fixup-all HEAD~1 2>err &&\n     -+\ttest_grep \"fixup-all requires --autosquash\" err\n     ++\ttest_commit --no-tag combo-first &&\n     ++\ttest_commit --no-tag combo-mid &&\n     ++\tgit commit --allow-empty --fixup HEAD~1 &&\n     ++\ttest_commit --no-tag combo-last &&\n     ++\tgit rebase --autosquash --squash base &&\n     ++\ttest_cmp_rev base HEAD~1 &&\n     ++\ttest_commit_message HEAD -m \"combo-first\"\n     ++'\n     ++\n     ++test_expect_success '--squash folds the range with rebase.autosquash set' '\n     ++\ttest_config rebase.autosquash true &&\n     ++\tgit reset --hard base &&\n     ++\ttest_commit --no-tag cfg-first &&\n     ++\ttest_commit --no-tag cfg-last &&\n     ++\tgit rebase --squash base &&\n     ++\ttest_cmp_rev base HEAD~1 &&\n     ++\ttest_commit_message HEAD -m \"cfg-first\"\n      +'\n      +\n     -+test_expect_success '--fixup-all and --rebase-merges cannot be combined' '\n     ++test_expect_success '--squash and --rebase-merges cannot be combined' '\n      +\tgit reset --hard base &&\n     -+\ttest_must_fail git rebase --autosquash --rebase-merges \\\n     -+\t\t--fixup-all HEAD~1 2>err &&\n     ++\ttest_must_fail git rebase --rebase-merges --squash HEAD~1 2>err &&\n      +\ttest_grep \"cannot be used together\" err &&\n      +\ttest_path_is_missing .git/rebase-merge\n      +'\n\n-- \ngitgitgadget\n"},{"id":"545526","messageId":"c55b9cd6f749bf1470dba46e6bfea4c0472d3490.1781512625.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v2.git.git.1781512625.gitgitgadget@gmail.com","subject":"[PATCH v2 1/2] t3415: remove prepare-commit-msg hook after use","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T08:37:04Z","receivedAt":"2026-06-15T08:37:08Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThe \"pick and fixup respect commit.cleanup\" test left its\nprepare-commit-msg hook in place, leaking it into later tests. Remove it\nwith test_when_finished.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n t/t3415-rebase-autosquash.sh | 1 +\n 1 file changed, 1 insertion(+)\n\ndiff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\nindex 5033411a43..8964d1cc88 100755\n--- a/t/t3415-rebase-autosquash.sh\n+++ b/t/t3415-rebase-autosquash.sh\n@@ -490,6 +490,7 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n \tgit reset --hard base &&\n \ttest_commit --no-tag \"fixup! second commit\" file1 fixup &&\n \ttest_commit something &&\n+\ttest_when_finished \"rm -f .git/hooks/prepare-commit-msg\" &&\n \twrite_script .git/hooks/prepare-commit-msg <<-\\EOF &&\n \tprintf \"\\n# Prepared\\n\" >> \"$1\"\n \tEOF\n-- \ngitgitgadget\n\n"},{"id":"545528","messageId":"22d4276ff59629c820e721e042f0b02d93b0693a.1781512625.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v2.git.git.1781512625.gitgitgadget@gmail.com","subject":"[PATCH v2 2/2] rebase: add --squash to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T08:37:05Z","receivedAt":"2026-06-15T08:37:10Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git rebase --squash [<upstream>]\" to do this directly. It keeps\nthe first commit in the range as a \"pick\" and turns every later commit\ninto a \"fixup\", so the whole range collapses into a single commit that\nreuses the first commit's message. With no <upstream> argument the range\nis \"@{upstream}..HEAD\", folding all unpushed commits into one.\n\nThe option implies the merge backend, so it works on its own without\n--autosquash. Fold the commits in their original order, so that any\nfixup!/squash! commits already present in the range are folded in as\nwell. Reject --rebase-merges since a merge commit cannot be folded into\nanother commit.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-rebase.adoc |  11 ++++\n builtin/rebase.c              |  16 ++++-\n sequencer.c                   |  24 ++++++-\n sequencer.h                   |   2 +-\n t/t3415-rebase-autosquash.sh  | 117 ++++++++++++++++++++++++++++++++++\n 5 files changed, 165 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc\nindex f6c22d1598..4244288148 100644\n--- a/Documentation/git-rebase.adoc\n+++ b/Documentation/git-rebase.adoc\n@@ -602,6 +602,16 @@ option can be used to override that setting.\n +\n See also INCOMPATIBLE OPTIONS below.\n \n+--squash::\n+\tKeep the first commit in the range as a `pick` and change every later\n+\tcommit to a `fixup`, so the whole range is folded into a single commit\n+\tthat reuses the first commit's message.  With no `<upstream>` argument\n+\tthis folds all commits since `@{upstream}` into one.  The commits are\n+\tfolded in their original order, so any `fixup!`/`squash!` commits\n+\talready in the range are folded in as well.  Cannot be combined with\n+\t`--rebase-merges`, as a merge commit cannot be folded into another\n+\tcommit.\n+\n --autostash::\n --no-autostash::\n \tAutomatically create a temporary stash entry before the operation\n@@ -652,6 +662,7 @@ are incompatible with the following options:\n  * --strategy\n  * --strategy-option\n  * --autosquash\n+ * --squash\n  * --rebase-merges\n  * --interactive\n  * --exec\ndiff --git a/builtin/rebase.c b/builtin/rebase.c\nindex fa4f5d9306..2df9f04728 100644\n--- a/builtin/rebase.c\n+++ b/builtin/rebase.c\n@@ -118,6 +118,7 @@ struct rebase_options {\n \tint allow_rerere_autoupdate;\n \tint keep_empty;\n \tint autosquash;\n+\tint squash;\n \tchar *gpg_sign_opt;\n \tint autostash;\n \tint committer_date_is_author_date;\n@@ -329,7 +330,8 @@ static int do_interactive_rebase(struct rebase_options *opts, unsigned flags)\n \tret = complete_action(the_repository, &replay, flags,\n \t\tshortrevisions, opts->onto_name, opts->onto,\n \t\t&opts->orig_head->object.oid, &opts->exec,\n-\t\topts->autosquash, opts->update_refs, &todo_list);\n+\t\topts->autosquash, opts->squash, opts->update_refs,\n+\t\t&todo_list);\n \n cleanup:\n \treplay_opts_release(&replay);\n@@ -1205,6 +1207,8 @@ int cmd_rebase(int argc,\n \t\tOPT_BOOL(0, \"autosquash\", &options.autosquash,\n \t\t\t N_(\"move commits that begin with \"\n \t\t\t    \"squash!/fixup! under -i\")),\n+\t\tOPT_BOOL(0, \"squash\", &options.squash,\n+\t\t\t N_(\"fold all commits in the range into the first one\")),\n \t\tOPT_BOOL(0, \"update-refs\", &options.update_refs,\n \t\t\t N_(\"update branches that point to commits \"\n \t\t\t    \"that are being rebased\")),\n@@ -1471,7 +1475,8 @@ int cmd_rebase(int argc,\n \tif ((options.flags & REBASE_INTERACTIVE_EXPLICIT) ||\n \t    (options.action != ACTION_NONE) ||\n \t    (options.exec.nr > 0) ||\n-\t    options.autosquash == 1) {\n+\t    options.autosquash == 1 ||\n+\t    options.squash) {\n \t\tallow_preemptive_ff = 0;\n \t}\n \tif (options.committer_date_is_author_date || options.ignore_date)\n@@ -1594,6 +1599,13 @@ int cmd_rebase(int argc,\n \toptions.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :\n \t\t\t\t((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);\n \n+\tif (options.squash && options.rebase_merges)\n+\t\tdie(_(\"options '%s' and '%s' cannot be used together\"),\n+\t\t    \"--squash\", \"--rebase-merges\");\n+\n+\tif (options.squash)\n+\t\timply_merge(&options, \"--squash\");\n+\n \tif (options.autosquash == 1) {\n \t\timply_merge(&options, \"--autosquash\");\n \t} else if (options.autosquash == -1) {\ndiff --git a/sequencer.c b/sequencer.c\nindex 57855b0066..bb42b40796 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -6554,11 +6554,29 @@ static int todo_list_add_update_ref_commands(struct todo_list *todo_list)\n \treturn 0;\n }\n \n+static void todo_list_fixup_all_but_first(struct todo_list *todo_list)\n+{\n+\tint i, seen_first = 0;\n+\n+\tfor (i = 0; i < todo_list->nr; i++) {\n+\t\tstruct todo_item *item = todo_list->items + i;\n+\n+\t\tif (!item->commit || item->command == TODO_DROP)\n+\t\t\tcontinue;\n+\t\tif (!seen_first) {\n+\t\t\tseen_first = 1;\n+\t\t\titem->command = TODO_PICK;\n+\t\t\tcontinue;\n+\t\t}\n+\t\titem->command = TODO_FIXUP;\n+\t}\n+}\n+\n int complete_action(struct repository *r, struct replay_opts *opts, unsigned flags,\n \t\t    const char *shortrevisions, const char *onto_name,\n \t\t    struct commit *onto, const struct object_id *orig_head,\n \t\t    struct string_list *commands, unsigned autosquash,\n-\t\t    unsigned update_refs,\n+\t\t    unsigned squash, unsigned update_refs,\n \t\t    struct todo_list *todo_list)\n {\n \tchar shortonto[GIT_MAX_HEXSZ + 1];\n@@ -6581,7 +6599,9 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla\n \tif (update_refs && todo_list_add_update_ref_commands(todo_list))\n \t\treturn -1;\n \n-\tif (autosquash && todo_list_rearrange_squash(todo_list))\n+\tif (squash)\n+\t\ttodo_list_fixup_all_but_first(todo_list);\n+\telse if (autosquash && todo_list_rearrange_squash(todo_list))\n \t\treturn -1;\n \n \tif (commands->nr)\ndiff --git a/sequencer.h b/sequencer.h\nindex 3164bd437d..1d5a164f02 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -196,7 +196,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla\n \t\t    const char *shortrevisions, const char *onto_name,\n \t\t    struct commit *onto, const struct object_id *orig_head,\n \t\t    struct string_list *commands, unsigned autosquash,\n-\t\t    unsigned update_refs,\n+\t\t    unsigned squash, unsigned update_refs,\n \t\t    struct todo_list *todo_list);\n int todo_list_rearrange_squash(struct todo_list *todo_list);\n \ndiff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\nindex 8964d1cc88..ce9abe5147 100755\n--- a/t/t3415-rebase-autosquash.sh\n+++ b/t/t3415-rebase-autosquash.sh\n@@ -511,4 +511,121 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n \ttest_commit_message HEAD -m \"something\"\n '\n \n+test_expect_success '--squash folds the range into the first commit' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag fold1 file_fold a &&\n+\ttest_commit --no-tag fold2 file_fold b &&\n+\ttest_commit --no-tag fold3 file_fold c &&\n+\tgit rebase --squash HEAD~3 &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"fold1\" &&\n+\techo c >expect &&\n+\ttest_cmp expect file_fold\n+'\n+\n+test_expect_success '--squash folds smoothly when a fixup! commit is in the series' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag foldA file_fold a &&\n+\ttest_commit --no-tag foldB file_fold b &&\n+\tgit commit --allow-empty --fixup HEAD~1 &&\n+\tgit rebase --squash HEAD~3 &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"foldA\" &&\n+\techo b >expect &&\n+\ttest_cmp expect file_fold\n+'\n+\n+test_expect_success '--squash picks the first commit even if it is a fixup!' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag fixupbase file_fix a &&\n+\tgit commit --allow-empty --fixup HEAD &&\n+\ttest_commit --no-tag fixuptail file_fix b &&\n+\tgit rebase --squash HEAD~3 &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\techo b >expect &&\n+\ttest_cmp expect file_fix\n+'\n+\n+test_expect_success '--squash with a single commit in range is a no-op' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag solo file_solo a &&\n+\tgit rev-parse HEAD >expect &&\n+\tgit rebase --squash HEAD~1 &&\n+\tgit rev-parse HEAD >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--squash with an empty range succeeds' '\n+\tgit reset --hard base &&\n+\tgit rebase --squash HEAD &&\n+\ttest_cmp_rev base HEAD\n+'\n+\n+test_expect_success '--squash skips a dropped commit in the range' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag fixdrop1 file_drop a &&\n+\tgit commit --allow-empty -m \"empty in the middle\" &&\n+\ttest_commit --no-tag fixdrop3 file_drop b &&\n+\tgit rebase --squash --empty=drop HEAD~3 &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"fixdrop1\" &&\n+\techo b >expect &&\n+\ttest_cmp expect file_drop\n+'\n+\n+test_expect_success '--squash folds a merge commit in the middle of the range' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag mid-first &&\n+\tgit checkout -b mid-side &&\n+\ttest_commit --no-tag mid-merged &&\n+\tgit checkout - &&\n+\tgit merge --no-ff -m \"merge mid-side\" mid-side &&\n+\ttest_commit --no-tag mid-last &&\n+\tgit rebase --squash base &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"mid-first\" &&\n+\ttest_path_is_file mid-merged.t\n+'\n+\n+test_expect_success '--squash keeps the first flattened commit when a merge sorts first' '\n+\tgit reset --hard base &&\n+\tgit checkout -b head-side &&\n+\ttest_commit --no-tag head-merged &&\n+\tgit checkout - &&\n+\tgit merge --no-ff -m \"merge head-side\" head-side &&\n+\ttest_commit --no-tag head-last &&\n+\tgit rebase --squash base &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"head-merged\" &&\n+\ttest_path_is_file head-merged.t\n+'\n+\n+test_expect_success '--squash takes precedence over --autosquash' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag combo-first &&\n+\ttest_commit --no-tag combo-mid &&\n+\tgit commit --allow-empty --fixup HEAD~1 &&\n+\ttest_commit --no-tag combo-last &&\n+\tgit rebase --autosquash --squash base &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"combo-first\"\n+'\n+\n+test_expect_success '--squash folds the range with rebase.autosquash set' '\n+\ttest_config rebase.autosquash true &&\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag cfg-first &&\n+\ttest_commit --no-tag cfg-last &&\n+\tgit rebase --squash base &&\n+\ttest_cmp_rev base HEAD~1 &&\n+\ttest_commit_message HEAD -m \"cfg-first\"\n+'\n+\n+test_expect_success '--squash and --rebase-merges cannot be combined' '\n+\tgit reset --hard base &&\n+\ttest_must_fail git rebase --rebase-merges --squash HEAD~1 2>err &&\n+\ttest_grep \"cannot be used together\" err &&\n+\ttest_path_is_missing .git/rebase-merge\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"545587","messageId":"CALnO6CBgHz5d5BT5gCyqyhw_HpV733msWOnrxmu-TJ0QGHE9tA@mail.gmail.com","threadId":"65812","inReplyTo":"CAHwyqnWa55xbTpzq-Nf6cMyvgR1yYgg8fhvgMFkquSEGPUwDmg@mail.gmail.com","subject":"Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-06-15T15:17:21Z","receivedAt":"2026-06-15T15:17:33Z","isPatch":true,"body":"On Mon, Jun 15, 2026 at 4:22 AM Harald Nordgren\n<haraldnordgren@gmail.com> wrote:\n> > > Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits\n> > > into its oldest one, reusing that commit's message.\n[snip]\n> > I also wonder if we can do something like this without adding any\n> > new option or command.  E.g., if you have four patch series, where\n> > the initial implementation HEAD~3 is followed by \"oops it was still\n> > wrong\" fix-up HEAD~2, HEAD~1 and HEAD, then\n> >\n> >     git reset --soft HEAD~3 && git commit --amend --no-edit\n> >\n> > is what the user wants to do, no?\n>\n> I don't think it's enough. First of all the user has to know the N for\n> HEAD~N, and then 'git reset --soft HEAD~N && git commit --amend\n> --no-edit' is still quite ugly.\n\nWell, there are a few ways to get this more easily than counting; for example,\n\n- git rev-list @{u}.. | tail -n1\n- the lovely \":/<pattern>\" or \"@^{/<pattern>}\" revision notations\n- etc.\n\n---\n\nStepping back a moment and assuming that the important thing you want\nis the \"squash\" (and not necessarily the \"rebase\" moving commits onto\na new base), I wonder about\n\n     git history squash <range>\n\nwhich would squash all commits in the (now arbitrary!) range into the\nfirst. That makes it somewhat more versatile at selecting commits, I\nthink, at the cost that re-basing is somewhat harder. That is, you\ncould then do\n\n    git history squash @~3..\n\nand things like\n\n    git history squash @~5..@~2\n\nAs a future extension, I think we could support merge commits: merges\ncould be replayed as a merge into the final squash instead (creating\nan octopus merge if there are multiple merges to replay), though I'm\nhand-waving what we should do for conflicts. (We _do_ know what the\nfinal tree should look like—the same as the final commit in the\nrange—so maybe we can actually avoid all conflicts?)\n\nAnyway, I've cc'd Patrick for his opinion about whether this fits in\n\"git-history\".\n\n-- \nD. Ben Knoble\n"},{"id":"545630","messageId":"ajEKf-jCIDVPQCeO@pks.im","threadId":"65812","inReplyTo":"CALnO6CBgHz5d5BT5gCyqyhw_HpV733msWOnrxmu-TJ0QGHE9tA@mail.gmail.com","subject":"Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-16T08:34:07Z","receivedAt":"2026-06-16T08:34:18Z","isPatch":true,"body":"On Mon, Jun 15, 2026 at 11:17:21AM -0400, D. Ben Knoble wrote:\n> On Mon, Jun 15, 2026 at 4:22 AM Harald Nordgren\n> <haraldnordgren@gmail.com> wrote:\n> > > > Adds git rebase --autosquash --fixup [<upstream>] to fold a range of commits\n> > > > into its oldest one, reusing that commit's message.\n> [snip]\n> > > I also wonder if we can do something like this without adding any\n> > > new option or command.  E.g., if you have four patch series, where\n> > > the initial implementation HEAD~3 is followed by \"oops it was still\n> > > wrong\" fix-up HEAD~2, HEAD~1 and HEAD, then\n> > >\n> > >     git reset --soft HEAD~3 && git commit --amend --no-edit\n> > >\n> > > is what the user wants to do, no?\n> >\n> > I don't think it's enough. First of all the user has to know the N for\n> > HEAD~N, and then 'git reset --soft HEAD~N && git commit --amend\n> > --no-edit' is still quite ugly.\n> \n> Well, there are a few ways to get this more easily than counting; for example,\n> \n> - git rev-list @{u}.. | tail -n1\n> - the lovely \":/<pattern>\" or \"@^{/<pattern>}\" revision notations\n> - etc.\n> \n> ---\n> \n> Stepping back a moment and assuming that the important thing you want\n> is the \"squash\" (and not necessarily the \"rebase\" moving commits onto\n> a new base), I wonder about\n> \n>      git history squash <range>\n> \n> which would squash all commits in the (now arbitrary!) range into the\n> first. That makes it somewhat more versatile at selecting commits, I\n> think, at the cost that re-basing is somewhat harder. That is, you\n> could then do\n> \n>     git history squash @~3..\n> \n> and things like\n> \n>     git history squash @~5..@~2\n> \n> As a future extension, I think we could support merge commits: merges\n> could be replayed as a merge into the final squash instead (creating\n> an octopus merge if there are multiple merges to replay), though I'm\n> hand-waving what we should do for conflicts. (We _do_ know what the\n> final tree should look like—the same as the final commit in the\n> range—so maybe we can actually avoid all conflicts?)\n> \n> Anyway, I've cc'd Patrick for his opinion about whether this fits in\n> \"git-history\".\n\nYes, it does fit into git-history(1), and I do indeed already have plans\nto implement such a command going forward. I wouldn't mind at all though\nif somebody else beat me to it, I want to implement at least one more\ncommand before I get to this.\n\nPatrick\n"},{"id":"545645","messageId":"d55b6600-50f3-4e81-a6bf-d270cd7abd2d@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v2.git.git.1781512625.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-16T10:10:27Z","receivedAt":"2026-06-16T10:10:31Z","isPatch":true,"body":"Hi Harald\n\nOn 15/06/2026 09:37, Harald Nordgren via GitGitGadget wrote:\n> Rename to rebase --squash.\n\nPlease include the original cover letter as well so people who have not \nread the previous version know what the series is about.\n\nI agree that git-history might be a better place for this but I'm not \nopposed to adding a --squash option to git-rebase. However I do think we \nneed to think about the implementation - picking consecutive commits \nwhen we want to squash them together is inefficient and if we're \nchanging the base risks the user having to stop to resolve conflicts \nmultiple times. Regardless of whether this ends up in git-rebase or \ngit-history I think the implementation should cherry-pick the whole \ncommit range by doing the equivalent of\n\n   git merge-tree --merge-base $(git merge-base HEAD @{upstream}) \\\n                  @{upstream} HEAD\n\nWe should also let the user edit the commit message to reflect the \nchanges that are being squashed in. We should think about what support \nwe want for \"amend!\" commits that replace the commit message when rebasing.\n\nThanks\n\nPhillip\n\n> Harald Nordgren (2):\n>    t3415: remove prepare-commit-msg hook after use\n>    rebase: add --squash to fold a range\n> \n>   Documentation/git-rebase.adoc |  11 ++++\n>   builtin/rebase.c              |  16 ++++-\n>   sequencer.c                   |  24 ++++++-\n>   sequencer.h                   |   2 +-\n>   t/t3415-rebase-autosquash.sh  | 118 ++++++++++++++++++++++++++++++++++\n>   5 files changed, 166 insertions(+), 5 deletions(-)\n> \n> \n> base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c\n> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v2\n> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v2\n> Pull-Request: https://github.com/git/git/pull/2337\n> \n> Range-diff vs v1:\n> \n>   1:  c55b9cd6f7 = 1:  c55b9cd6f7 t3415: remove prepare-commit-msg hook after use\n>   2:  bd1bc62aa8 ! 2:  22d4276ff5 rebase: add --fixup-all to fold a range\n>       @@ Metadata\n>        Author: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>         ## Commit message ##\n>       -    rebase: add --fixup-all to fold a range\n>       +    rebase: add --squash to fold a range\n>        \n>            Folding a series of commits into one required either an interactive\n>            rebase where each commit after the first was hand-edited to \"fixup\", or\n>            a \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n>        \n>       -    Add \"git rebase --autosquash --fixup-all [<upstream>]\" to do this\n>       -    directly. It keeps the first commit in the range as a \"pick\" and turns\n>       -    every later commit into a \"fixup\", so the whole range collapses into a\n>       -    single commit that reuses the first commit's message. With no <upstream>\n>       -    argument the range is \"@{upstream}..HEAD\", folding all unpushed commits\n>       -    into one.\n>       +    Add \"git rebase --squash [<upstream>]\" to do this directly. It keeps\n>       +    the first commit in the range as a \"pick\" and turns every later commit\n>       +    into a \"fixup\", so the whole range collapses into a single commit that\n>       +    reuses the first commit's message. With no <upstream> argument the range\n>       +    is \"@{upstream}..HEAD\", folding all unpushed commits into one.\n>        \n>       -    Fold the commits in their original order, so that any fixup!/squash!\n>       -    commits already present in the range are folded in as well. Allow the\n>       -    flag only together with --autosquash, and reject --rebase-merges since a\n>       -    merge commit cannot be folded into another commit.\n>       +    The option implies the merge backend, so it works on its own without\n>       +    --autosquash. Fold the commits in their original order, so that any\n>       +    fixup!/squash! commits already present in the range are folded in as\n>       +    well. Reject --rebase-merges since a merge commit cannot be folded into\n>       +    another commit.\n>        \n>       +    Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>         ## Documentation/git-rebase.adoc ##\n>       @@ Documentation/git-rebase.adoc: option can be used to override that setting.\n>         +\n>         See also INCOMPATIBLE OPTIONS below.\n>         \n>       -+--fixup-all::\n>       -+\tValid only when used with `--autosquash`.  Keep the first commit in\n>       -+\tthe range as a `pick` and change every later commit to a `fixup`, so\n>       -+\tthe whole range is folded into a single commit that reuses the first\n>       -+\tcommit's message.  With no `<upstream>` argument this folds all commits\n>       -+\tsince `@{upstream}` into one.  The commits are folded in their original\n>       -+\torder, so any `fixup!`/`squash!` commits already in the range are folded\n>       -+\tin as well.  Cannot be combined with `--rebase-merges`, as a merge\n>       -+\tcommit cannot be folded into another commit.\n>       ++--squash::\n>       ++\tKeep the first commit in the range as a `pick` and change every later\n>       ++\tcommit to a `fixup`, so the whole range is folded into a single commit\n>       ++\tthat reuses the first commit's message.  With no `<upstream>` argument\n>       ++\tthis folds all commits since `@{upstream}` into one.  The commits are\n>       ++\tfolded in their original order, so any `fixup!`/`squash!` commits\n>       ++\talready in the range are folded in as well.  Cannot be combined with\n>       ++\t`--rebase-merges`, as a merge commit cannot be folded into another\n>       ++\tcommit.\n>        +\n>         --autostash::\n>         --no-autostash::\n>       @@ Documentation/git-rebase.adoc: are incompatible with the following options:\n>          * --strategy\n>          * --strategy-option\n>          * --autosquash\n>       -+ * --fixup-all\n>       ++ * --squash\n>          * --rebase-merges\n>          * --interactive\n>          * --exec\n>       @@ builtin/rebase.c: struct rebase_options {\n>         \tint allow_rerere_autoupdate;\n>         \tint keep_empty;\n>         \tint autosquash;\n>       -+\tint fixup_all;\n>       ++\tint squash;\n>         \tchar *gpg_sign_opt;\n>         \tint autostash;\n>         \tint committer_date_is_author_date;\n>       @@ builtin/rebase.c: static int do_interactive_rebase(struct rebase_options *opts,\n>         \t\tshortrevisions, opts->onto_name, opts->onto,\n>         \t\t&opts->orig_head->object.oid, &opts->exec,\n>        -\t\topts->autosquash, opts->update_refs, &todo_list);\n>       -+\t\topts->autosquash, opts->fixup_all, opts->update_refs,\n>       ++\t\topts->autosquash, opts->squash, opts->update_refs,\n>        +\t\t&todo_list);\n>         \n>         cleanup:\n>       @@ builtin/rebase.c: int cmd_rebase(int argc,\n>         \t\tOPT_BOOL(0, \"autosquash\", &options.autosquash,\n>         \t\t\t N_(\"move commits that begin with \"\n>         \t\t\t    \"squash!/fixup! under -i\")),\n>       -+\t\tOPT_BOOL(0, \"fixup-all\", &options.fixup_all,\n>       ++\t\tOPT_BOOL(0, \"squash\", &options.squash,\n>        +\t\t\t N_(\"fold all commits in the range into the first one\")),\n>         \t\tOPT_BOOL(0, \"update-refs\", &options.update_refs,\n>         \t\t\t N_(\"update branches that point to commits \"\n>         \t\t\t    \"that are being rebased\")),\n>       +@@ builtin/rebase.c: int cmd_rebase(int argc,\n>       + \tif ((options.flags & REBASE_INTERACTIVE_EXPLICIT) ||\n>       + \t    (options.action != ACTION_NONE) ||\n>       + \t    (options.exec.nr > 0) ||\n>       +-\t    options.autosquash == 1) {\n>       ++\t    options.autosquash == 1 ||\n>       ++\t    options.squash) {\n>       + \t\tallow_preemptive_ff = 0;\n>       + \t}\n>       + \tif (options.committer_date_is_author_date || options.ignore_date)\n>        @@ builtin/rebase.c: int cmd_rebase(int argc,\n>         \toptions.rebase_merges = (options.rebase_merges >= 0) ? options.rebase_merges :\n>         \t\t\t\t((options.config_rebase_merges >= 0) ? options.config_rebase_merges : 0);\n>         \n>       -+\tif (options.fixup_all && options.autosquash != 1)\n>       -+\t\tdie(_(\"--fixup-all requires --autosquash\"));\n>       -+\n>       -+\tif (options.fixup_all && options.rebase_merges)\n>       ++\tif (options.squash && options.rebase_merges)\n>        +\t\tdie(_(\"options '%s' and '%s' cannot be used together\"),\n>       -+\t\t    \"--fixup-all\", \"--rebase-merges\");\n>       ++\t\t    \"--squash\", \"--rebase-merges\");\n>       ++\n>       ++\tif (options.squash)\n>       ++\t\timply_merge(&options, \"--squash\");\n>        +\n>         \tif (options.autosquash == 1) {\n>         \t\timply_merge(&options, \"--autosquash\");\n>       @@ sequencer.c: static int todo_list_add_update_ref_commands(struct todo_list *todo\n>         \t\t    struct commit *onto, const struct object_id *orig_head,\n>         \t\t    struct string_list *commands, unsigned autosquash,\n>        -\t\t    unsigned update_refs,\n>       -+\t\t    unsigned fixup_all, unsigned update_refs,\n>       ++\t\t    unsigned squash, unsigned update_refs,\n>         \t\t    struct todo_list *todo_list)\n>         {\n>         \tchar shortonto[GIT_MAX_HEXSZ + 1];\n>       @@ sequencer.c: int complete_action(struct repository *r, struct replay_opts *opts,\n>         \t\treturn -1;\n>         \n>        -\tif (autosquash && todo_list_rearrange_squash(todo_list))\n>       -+\tif (fixup_all)\n>       ++\tif (squash)\n>        +\t\ttodo_list_fixup_all_but_first(todo_list);\n>        +\telse if (autosquash && todo_list_rearrange_squash(todo_list))\n>         \t\treturn -1;\n>       @@ sequencer.h: int complete_action(struct repository *r, struct replay_opts *opts,\n>         \t\t    struct commit *onto, const struct object_id *orig_head,\n>         \t\t    struct string_list *commands, unsigned autosquash,\n>        -\t\t    unsigned update_refs,\n>       -+\t\t    unsigned fixup_all, unsigned update_refs,\n>       ++\t\t    unsigned squash, unsigned update_refs,\n>         \t\t    struct todo_list *todo_list);\n>         int todo_list_rearrange_squash(struct todo_list *todo_list);\n>         \n>       @@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit\n>         \ttest_commit_message HEAD -m \"something\"\n>         '\n>         \n>       -+test_expect_success '--fixup-all folds the range into the first commit' '\n>       ++test_expect_success '--squash folds the range into the first commit' '\n>        +\tgit reset --hard base &&\n>        +\ttest_commit --no-tag fold1 file_fold a &&\n>        +\ttest_commit --no-tag fold2 file_fold b &&\n>        +\ttest_commit --no-tag fold3 file_fold c &&\n>       -+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n>       ++\tgit rebase --squash HEAD~3 &&\n>        +\ttest_cmp_rev base HEAD~1 &&\n>        +\ttest_commit_message HEAD -m \"fold1\" &&\n>        +\techo c >expect &&\n>        +\ttest_cmp expect file_fold\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all folds smoothly when a fixup! commit is in the series' '\n>       ++test_expect_success '--squash folds smoothly when a fixup! commit is in the series' '\n>        +\tgit reset --hard base &&\n>        +\ttest_commit --no-tag foldA file_fold a &&\n>        +\ttest_commit --no-tag foldB file_fold b &&\n>        +\tgit commit --allow-empty --fixup HEAD~1 &&\n>       -+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n>       ++\tgit rebase --squash HEAD~3 &&\n>        +\ttest_cmp_rev base HEAD~1 &&\n>        +\ttest_commit_message HEAD -m \"foldA\" &&\n>        +\techo b >expect &&\n>        +\ttest_cmp expect file_fold\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all picks the first commit even if it is a fixup!' '\n>       ++test_expect_success '--squash picks the first commit even if it is a fixup!' '\n>        +\tgit reset --hard base &&\n>        +\ttest_commit --no-tag fixupbase file_fix a &&\n>        +\tgit commit --allow-empty --fixup HEAD &&\n>        +\ttest_commit --no-tag fixuptail file_fix b &&\n>       -+\tgit rebase --autosquash --fixup-all HEAD~3 &&\n>       ++\tgit rebase --squash HEAD~3 &&\n>        +\ttest_cmp_rev base HEAD~1 &&\n>        +\techo b >expect &&\n>        +\ttest_cmp expect file_fix\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all with a single commit in range is a no-op' '\n>       ++test_expect_success '--squash with a single commit in range is a no-op' '\n>        +\tgit reset --hard base &&\n>        +\ttest_commit --no-tag solo file_solo a &&\n>        +\tgit rev-parse HEAD >expect &&\n>       -+\tgit rebase --autosquash --fixup-all HEAD~1 &&\n>       ++\tgit rebase --squash HEAD~1 &&\n>        +\tgit rev-parse HEAD >actual &&\n>        +\ttest_cmp expect actual\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all with an empty range succeeds' '\n>       ++test_expect_success '--squash with an empty range succeeds' '\n>        +\tgit reset --hard base &&\n>       -+\tgit rebase --autosquash --fixup-all HEAD &&\n>       ++\tgit rebase --squash HEAD &&\n>        +\ttest_cmp_rev base HEAD\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all skips a dropped commit in the range' '\n>       ++test_expect_success '--squash skips a dropped commit in the range' '\n>        +\tgit reset --hard base &&\n>        +\ttest_commit --no-tag fixdrop1 file_drop a &&\n>        +\tgit commit --allow-empty -m \"empty in the middle\" &&\n>        +\ttest_commit --no-tag fixdrop3 file_drop b &&\n>       -+\tgit rebase --autosquash --empty=drop --fixup-all HEAD~3 &&\n>       ++\tgit rebase --squash --empty=drop HEAD~3 &&\n>        +\ttest_cmp_rev base HEAD~1 &&\n>        +\ttest_commit_message HEAD -m \"fixdrop1\" &&\n>        +\techo b >expect &&\n>        +\ttest_cmp expect file_drop\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all folds a merge commit in the middle of the range' '\n>       ++test_expect_success '--squash folds a merge commit in the middle of the range' '\n>        +\tgit reset --hard base &&\n>        +\ttest_commit --no-tag mid-first &&\n>        +\tgit checkout -b mid-side &&\n>       @@ t/t3415-rebase-autosquash.sh: test_expect_success 'pick and fixup respect commit\n>        +\tgit checkout - &&\n>        +\tgit merge --no-ff -m \"merge mid-side\" mid-side &&\n>        +\ttest_commit --no-tag mid-last &&\n>       -+\tgit rebase --autosquash --fixup-all base &&\n>       ++\tgit rebase --squash base &&\n>        +\ttest_cmp_rev base HEAD~1 &&\n>        +\ttest_commit_message HEAD -m \"mid-first\" &&\n>        +\ttest_path_is_file mid-merged.t\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all keeps the first flattened commit when a merge sorts first' '\n>       ++test_expect_success '--squash keeps the first flattened commit when a merge sorts first' '\n>        +\tgit reset --hard base &&\n>        +\tgit checkout -b head-side &&\n>        +\ttest_commit --no-tag head-merged &&\n>        +\tgit checkout - &&\n>        +\tgit merge --no-ff -m \"merge head-side\" head-side &&\n>        +\ttest_commit --no-tag head-last &&\n>       -+\tgit rebase --autosquash --fixup-all base &&\n>       ++\tgit rebase --squash base &&\n>        +\ttest_cmp_rev base HEAD~1 &&\n>        +\ttest_commit_message HEAD -m \"head-merged\" &&\n>        +\ttest_path_is_file head-merged.t\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all requires --autosquash' '\n>       ++test_expect_success '--squash takes precedence over --autosquash' '\n>        +\tgit reset --hard base &&\n>       -+\ttest_must_fail git rebase --fixup-all HEAD~1 2>err &&\n>       -+\ttest_grep \"fixup-all requires --autosquash\" err &&\n>       -+\ttest_must_fail git rebase --no-autosquash --fixup-all HEAD~1 2>err &&\n>       -+\ttest_grep \"fixup-all requires --autosquash\" err\n>       ++\ttest_commit --no-tag combo-first &&\n>       ++\ttest_commit --no-tag combo-mid &&\n>       ++\tgit commit --allow-empty --fixup HEAD~1 &&\n>       ++\ttest_commit --no-tag combo-last &&\n>       ++\tgit rebase --autosquash --squash base &&\n>       ++\ttest_cmp_rev base HEAD~1 &&\n>       ++\ttest_commit_message HEAD -m \"combo-first\"\n>       ++'\n>       ++\n>       ++test_expect_success '--squash folds the range with rebase.autosquash set' '\n>       ++\ttest_config rebase.autosquash true &&\n>       ++\tgit reset --hard base &&\n>       ++\ttest_commit --no-tag cfg-first &&\n>       ++\ttest_commit --no-tag cfg-last &&\n>       ++\tgit rebase --squash base &&\n>       ++\ttest_cmp_rev base HEAD~1 &&\n>       ++\ttest_commit_message HEAD -m \"cfg-first\"\n>        +'\n>        +\n>       -+test_expect_success '--fixup-all and --rebase-merges cannot be combined' '\n>       ++test_expect_success '--squash and --rebase-merges cannot be combined' '\n>        +\tgit reset --hard base &&\n>       -+\ttest_must_fail git rebase --autosquash --rebase-merges \\\n>       -+\t\t--fixup-all HEAD~1 2>err &&\n>       ++\ttest_must_fail git rebase --rebase-merges --squash HEAD~1 2>err &&\n>        +\ttest_grep \"cannot be used together\" err &&\n>        +\ttest_path_is_missing .git/rebase-merge\n>        +'\n> \n\n"},{"id":"545740","messageId":"CAHwyqnV_pt1fEhUGPyGtXrJAwhjpQHOyX9juHRv_88T2md554Q@mail.gmail.com","threadId":"65812","inReplyTo":"d55b6600-50f3-4e81-a6bf-d270cd7abd2d@gmail.com","subject":"Re: [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-17T09:11:14Z","receivedAt":"2026-06-17T09:11:52Z","isPatch":true,"body":"On Tue, Jun 16, 2026 at 12:10 PM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>\n> Hi Harald\n>\n> On 15/06/2026 09:37, Harald Nordgren via GitGitGadget wrote:\n> > Rename to rebase --squash.\n>\n> Please include the original cover letter as well so people who have not\n> read the previous version know what the series is about.\n\nSo you mean this one, should that be included in each version, and\nappend each subsequent one:\n\n```\nAdds `git rebase --autosquash --fixup [<upstream>]` to fold a range of\ncommits into its oldest one, reusing that commit's message.\n\nRelated idea: https://github.com/gitgitgadget/git/issues/1135\n```\n\nOr make each message a full cover letter instead of just a diff?\n\n\n\n\nHarald\n"},{"id":"545741","messageId":"CAHwyqnXL8EWQ6iNPaH6b3e32vXXFwdJ5jhtkP8CnHfj9GLo32g@mail.gmail.com","threadId":"65812","inReplyTo":"ajEKf-jCIDVPQCeO@pks.im","subject":"Re: [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-17T09:30:01Z","receivedAt":"2026-06-17T09:30:40Z","isPatch":true,"body":"> Yes, it does fit into git-history(1), and I do indeed already have plans\n> to implement such a command going forward. I wouldn't mind at all though\n> if somebody else beat me to it, I want to implement at least one more\n> command before I get to this.\n\nWhat I like about 'git rebase --squash' is that when upstream is set\nup, it understands the commit range automatically, whereas history\nfeels more removed from the current upstream. Maybe I'm wrong about\nthat.\n\n\nHarald\n"},{"id":"545742","messageId":"fa4a6793-5114-4b12-a60b-ea79a7b3f632@gmail.com","threadId":"65812","inReplyTo":"CAHwyqnV_pt1fEhUGPyGtXrJAwhjpQHOyX9juHRv_88T2md554Q@mail.gmail.com","subject":"Re: [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-17T09:48:10Z","receivedAt":"2026-06-17T09:48:13Z","isPatch":true,"body":"On 17/06/2026 10:11, Harald Nordgren wrote:\n> On Tue, Jun 16, 2026 at 12:10 PM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>> On 15/06/2026 09:37, Harald Nordgren via GitGitGadget wrote:\n>>> Rename to rebase --squash.\n>>\n>> Please include the original cover letter as well so people who have not\n>> read the previous version know what the series is about.\n> \n> So you mean this one, should that be included in each version, and\n> append each subsequent one:\n> \n> ```\n> Adds `git rebase --autosquash --fixup [<upstream>]` to fold a range of\n> commits into its oldest one, reusing that commit's message.\n\nYes, see \nhttps://lore.kernel.org/20260615-b4-pks-history-drop-v6-0-2e329e536d78@pks.im \nfor an example and the \"Cover Letter\" section of \nDocumentation/SubmittingPatches\n\nThanks\n\nPhillip\n\n> \n> Related idea: https://github.com/gitgitgadget/git/issues/1135\n> ```\n> \n> Or make each message a full cover letter instead of just a diff?\n> \n> \n> \n> \n> Harald\n> \n\n"},{"id":"545887","messageId":"pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v2.git.git.1781512625.gitgitgadget@gmail.com","subject":"[PATCH v3 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:17:02Z","receivedAt":"2026-06-18T19:17:09Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits into its\noldest one, reusing that commit's message and replaying any descendants on\ntop.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (4):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  history: add squash subcommand to fold a range\n  history: re-edit a squash with every message\n\n Documentation/git-history.adoc |  21 +++\n builtin/history.c              | 287 ++++++++++++++++++++++++++++-----\n t/meson.build                  |   1 +\n t/t3454-history-squash.sh      | 250 ++++++++++++++++++++++++++++\n 4 files changed, 521 insertions(+), 38 deletions(-)\n create mode 100755 t/t3454-history-squash.sh\n\n\nbase-commit: 95e20213faefeb95df29277c58ac1980ab68f701\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v3\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v3\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v2:\n\n 1:  c55b9cd6f7 < -:  ---------- t3415: remove prepare-commit-msg hook after use\n 2:  22d4276ff5 < -:  ---------- rebase: add --squash to fold a range\n -:  ---------- > 1:  1e31474ef6 history: extract helper for a commit's parent tree\n -:  ---------- > 2:  498da64046 history: give commit_tree_ext a message template\n -:  ---------- > 3:  66b2f49fb4 history: add squash subcommand to fold a range\n -:  ---------- > 4:  43e4270614 history: re-edit a squash with every message\n\n-- \ngitgitgadget\n"},{"id":"545888","messageId":"1e31474ef6ba6e19091a0e82e58637e5bbe0c305.1781810227.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com","subject":"[PATCH v3 1/4] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:17:03Z","receivedAt":"2026-06-18T19:17:11Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 091465a59e..f95f26e684 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -157,6 +157,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"545889","messageId":"498da64046f676b57fdebb0b62a38adacd7eeb1b.1781810227.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com","subject":"[PATCH v3 2/4] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:17:04Z","receivedAt":"2026-06-18T19:17:12Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex f95f26e684..305bde3102 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -101,6 +101,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"545890","messageId":"66b2f49fb427c7328136b2d440dc7461b97fb4e0.1781810227.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com","subject":"[PATCH v3 3/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:17:05Z","receivedAt":"2026-06-18T19:17:13Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into the oldest one, keeping that commit's\nmessage and authorship and taking the tree of the newest commit, so the\nrange collapses into a single commit. Commits above the range are\nreplayed on top of the result.\n\nThe range is given as <base>..<tip>, so \"git history squash @~3..\"\nfolds the three most recent commits and \"git history squash @~5..@~2\"\nsquashes an interior range. A merge inside the range is folded like any\nother commit, but the range must have a single base, so a range with\nmore than one entry point is rejected.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  20 ++++\n builtin/history.c              | 154 ++++++++++++++++++++++++\n t/meson.build                  |   1 +\n t/t3454-history-squash.sh      | 213 +++++++++++++++++++++++++++++++++\n 4 files changed, 388 insertions(+)\n create mode 100755 t/t3454-history-squash.sh\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 2ba8121795..d3a5ad28a3 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -11,6 +11,7 @@ SYNOPSIS\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n \n DESCRIPTION\n -----------\n@@ -97,6 +98,25 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's message and\n+\tauthorship and takes the tree of the range's newest commit, so the\n+\twhole range collapses into a single commit. Commits above the range\n+\tare replayed on top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash @~3..` folds the three most recent commits into one, and\n+`git history squash @~5..@~2` squashes an interior range while leaving\n+the two newest commits in place.\n++\n+The oldest commit's message and authorship are preserved by default,\n+unless you specify `--reedit-message`. A merge commit inside the range is\n+folded like any other, but the range must have a single base, so a range\n+that reaches more than one entry point (for example a side branch that\n+forked before the range and was later merged into it) is rejected.\n+\n OPTIONS\n -------\n \ndiff --git a/builtin/history.c b/builtin/history.c\nindex 305bde3102..9d9416870f 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -30,6 +30,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -973,6 +975,156 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char *range,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct strvec args = STRVEC_INIT;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_push(&args, \"--boundary\");\n+\tstrvec_push(&args, range);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"'%s' does not name a revision range\"), range);\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tif (base) {\n+\t\t\t\tret = error(_(\"range '%s' has more than one base; \"\n+\t\t\t\t\t      \"cannot squash\"), range);\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tbase = commit;\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\ttip = commit;\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the range '%s' is empty\"), range);\n+\t\tgoto out;\n+\t}\n+\n+\tif (!base) {\n+\t\tret = error(_(\"cannot squash the root commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct commit *base, *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage, 0);\n+\tif (argc != 1) {\n+\t\tret = error(_(\"command expects a single revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n int cmd_history(int argc,\n \t\tconst char **argv,\n \t\tconst char *prefix,\n@@ -982,6 +1134,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -989,6 +1142,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex 3219264fe7..d7ae5a46ef 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -399,6 +399,7 @@ integration_tests = [\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n+  't3454-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3454-history-squash.sh b/t/t3454-history-squash.sh\nnew file mode 100755\nindex 0000000000..6c6a75bf00\n--- /dev/null\n+++ b/t/t3454-history-squash.sh\n@@ -0,0 +1,213 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\ttest_commit one other x &&\n+\ttest_commit two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"command expects a single revision range\" err\n+'\n+\n+test_expect_success 'errors on too many arguments' '\n+\ttest_must_fail git history squash start.. HEAD 2>err &&\n+\ttest_grep \"command expects a single revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the range .* is empty\" err\n+'\n+\n+test_expect_success 'errors when the range includes the root commit' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"cannot squash the root commit\" err\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo one >expect &&\n+\ttest_cmp expect subject &&\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$root..\" &&\n+\n+\tgit rev-list --count \"$root..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashing a single-commit range replays the rest' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~2 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'reuses the message of a fixup! commit in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit reg1 file b &&\n+\tgit commit --allow-empty -m \"fixup! reg1\" &&\n+\ttest_commit reg2 file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo reg1 >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'keeps the oldest message even if it is a fixup!' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag \"fixup! something\" file b &&\n+\ttest_commit tail file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo \"fixup! something\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'preserves authorship of the oldest commit' '\n+\tgit reset --hard start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit oldest file b &&\n+\ttest_commit newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--dry-run predicts the rewrite without performing it' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tgrep \"^update refs/heads/\" out >update &&\n+\tpredicted=$(awk \"{print \\$3}\" update) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\"\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success '--update-refs=branches moves a branch pointing into the range' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~2 &&\n+\tmid_before=$(git rev-parse mid) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\ttest_commit_message mid -m one\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\ttest_commit before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit on-inner-side inner x &&\n+\tgit checkout - &&\n+\ttest_commit after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\ttest_commit after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo before-side >expect &&\n+\ttest_cmp expect subject &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'refuses to squash a range with more than one base' '\n+\tgit reset --hard start &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b forked-before &&\n+\ttest_commit forked-side fside x &&\n+\tgit checkout - &&\n+\ttest_commit forked-main file b &&\n+\tgit merge --no-ff -m merge forked-before &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash forked-main.. 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"545891","messageId":"43e4270614adc26811ddb3fcd69dd90b17dfa5a7.1781810227.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com","subject":"[PATCH v3 4/4] history: re-edit a squash with every message","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:17:06Z","receivedAt":"2026-06-18T19:17:14Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBy default \"git history squash\" reuses the oldest commit's message.\nWhen --reedit-message is given it only reopened that one message, so the\nmessages of the folded-in commits were lost.\n\nGather the messages of every commit in the range, oldest first, and use\nthem as the editor template when re-editing, mirroring how \"git rebase\n-i\" presents a squash. The combined message is built before the\ndescendant walk so it is not disturbed by the flags that walk leaves on\nthe commits.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  5 +--\n builtin/history.c              | 61 +++++++++++++++++++++++++++++++++-\n t/t3454-history-squash.sh      | 37 +++++++++++++++++++++\n 3 files changed, 100 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex d3a5ad28a3..dd3544832d 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -111,8 +111,9 @@ history squash @~3..` folds the three most recent commits into one, and\n `git history squash @~5..@~2` squashes an interior range while leaving\n the two newest commits in place.\n +\n-The oldest commit's message and authorship are preserved by default,\n-unless you specify `--reedit-message`. A merge commit inside the range is\n+The oldest commit's message and authorship are preserved by default. With\n+`--reedit-message`, an editor opens pre-filled with the messages of all the\n+folded commits so you can combine them. A merge commit inside the range is\n folded like any other, but the range must have a single base, so a range\n that reaches more than one entry point (for example a side branch that\n forked before the range and was later merged into it) is rejected.\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 9d9416870f..eb12a5d7e8 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1047,6 +1047,56 @@ out:\n \treturn ret;\n }\n \n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tint n = 0, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tconst char *message, *body;\n+\t\tstruct strbuf one = STRBUF_INIT;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\t\tstrbuf_addstr(&one, body);\n+\t\tstrbuf_trim_trailing_newline(&one);\n+\n+\t\tif (n++)\n+\t\t\tstrbuf_addch(out, '\\n');\n+\t\tstrbuf_addbuf(out, &one);\n+\t\tstrbuf_addch(out, '\\n');\n+\n+\t\tstrbuf_release(&one);\n+\t\trepo_unuse_commit_buffer(repo, commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1071,6 +1121,7 @@ static int cmd_history_squash(int argc,\n \t\tOPT_END(),\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n \tstruct commit *base, *oldest, *tip, *rewritten;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n \tstruct commit_list *parents = NULL;\n@@ -1091,6 +1142,12 @@ static int cmd_history_squash(int argc,\n \tif (ret < 0)\n \t\tgoto out;\n \n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n+\n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1099,7 +1156,8 @@ static int cmd_history_squash(int argc,\n \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n \tcommit_list_append(base, &parents);\n \n-\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\tret = commit_tree_ext(repo, \"squash\", oldest,\n+\t\t\t      message.len ? message.buf : NULL, parents,\n \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n@@ -1120,6 +1178,7 @@ static int cmd_history_squash(int argc,\n \n out:\n \tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n \tcommit_list_free(parents);\n \trelease_revisions(&revs);\n \treturn ret;\ndiff --git a/t/t3454-history-squash.sh b/t/t3454-history-squash.sh\nindex 6c6a75bf00..1edd148295 100755\n--- a/t/t3454-history-squash.sh\n+++ b/t/t3454-history-squash.sh\n@@ -135,6 +135,43 @@ test_expect_success 'preserves authorship of the oldest commit' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--reedit-message offers every folded-in message' '\n+\tgit reset --hard start &&\n+\techo b >file &&\n+\tgit add file &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit re-two file c &&\n+\ttest_commit re-three file d &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcp \"$1\" buffer &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tgrep \"re-one subject\" buffer &&\n+\tgrep \"re-one body line\" buffer &&\n+\tgrep re-two buffer &&\n+\tgrep re-three buffer &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo combined >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--reedit-message aborts on an empty message' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\ttest_must_fail git history squash --reedit-message start.. &&\n+\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n test_expect_success '--dry-run predicts the rewrite without performing it' '\n \tgit reset --hard three &&\n \thead_before=$(git rev-parse HEAD) &&\n-- \ngitgitgadget\n"},{"id":"545900","messageId":"xmqq7bnvr3qb.fsf@gitster.g","threadId":"65812","inReplyTo":"66b2f49fb427c7328136b2d440dc7461b97fb4e0.1781810227.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v3 3/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-18T20:30:20Z","receivedAt":"2026-06-18T20:30:23Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> +static int cmd_history_squash(int argc,\n> +\t\t\t      const char **argv,\n> +\t\t\t      const char *prefix,\n> +\t\t\t      struct repository *repo)\n> +{\n> +\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n> +\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n> +\tcommit_list_append(base, &parents);\n> +\n> +\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n> +\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n\nWe use the tree object taken from the commit at the top end of the\nrange, and create a new commit directly on top of the boundary\ncommit beyond the bottom of the range, using the message from the\ncommit at the bottom of the range.  No need to go through the\nrigmarole of replaying commits in the range stepwise like sequencer\ndoes, since we are not transplanting the history on top of a\ndifferent tree at all.  Very nice.\n\nWhen I do drunken-walk development to build many commits, making\ndetour to arrive at an ideal state, the key message is often not in\nthe bottommost commit but somewhere in the middle where I discovered\nwhy my initial attempt were wrong and discovered a much better\nsolution, so using only the message from the oldest limits the\nusefulness of this feature, but I guess for certain people the\nbottommost commit would be a good default.\n\nI see you have already an option to grab messages from all the\ncommits in the range (many of which may have useless \"oops, that was\nwrong\" single-liner) in a way similar to how \"git rebase --squash\"\nor \"squash\" insn in the \"git rebase -i\" todo list lets you use them\nin the next step, which is workable.  It is plausible that we would\nlater want to offer an option to name the single commit that may not\nbe the bottommost one and use the message only from that commit.\n\nBut we'd need to start from somewhere, and \"use the bottommost\ncommit and nothing else\" and \"we will give you messages from all the\ncommits, just rearrange them in your editor\" may be a good place to\nstart.\n\nAs t3454 is taken by another topic already in flight, I've queued a\ntrivial \"rename it to t3455\" patch on top before queuing the topic.\n\nThanks.\n\n"},{"id":"545901","messageId":"CALnO6CAeSbjJs9Cb+C7hukBBy2KW0x3cjLTz5WMnG-BuaA0DGg@mail.gmail.com","threadId":"65812","inReplyTo":"pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com","subject":"Re: [PATCH v3 0/4] history: add squash subcommand to fold a range","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-06-18T21:23:37Z","receivedAt":"2026-06-18T21:23:49Z","isPatch":true,"body":"On Thu, Jun 18, 2026 at 3:17 PM Harald Nordgren via GitGitGadget\n<gitgitgadget@gmail.com> wrote:\n>\n> Adds git history squash <revision-range> to fold a range of commits into its\n> oldest one, reusing that commit's message and replaying any descendants on\n> top.\n>\n> Changes in v3:\n>\n>  * Moved the feature out of git rebase and into a new git history squash\n>    <revision-range> subcommand, per the list discussion. git rebase --squash\n>    is dropped.\n>  * Takes an arbitrary range (git history squash @~3.., git history squash\n>    @~5..@~2), folding it into the oldest commit and replaying any\n>    descendants on top.\n>  * Implemented as a single tree operation rather than picking each commit,\n>    so there are no repeated conflict stops (addresses Phillip's efficiency\n>    point).\n\nI think I mentioned this, too, albeit indirectly. I'm not concerned\nabout credit, though. Just excited to have this.\n\nThanks!\n\n>  * A merge inside the range is folded fine, only a range with more than one\n>    base is rejected.\n>  * --reedit-message seeds the editor with every folded-in message, not just\n>    the oldest.\n>\n> Harald Nordgren (4):\n>   history: extract helper for a commit's parent tree\n>   history: give commit_tree_ext a message template\n>   history: add squash subcommand to fold a range\n>   history: re-edit a squash with every message\n"},{"id":"545902","messageId":"xmqqzf0rpmn0.fsf@gitster.g","threadId":"65812","inReplyTo":"xmqq7bnvr3qb.fsf@gitster.g","subject":"Re: [PATCH v3 3/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-18T21:24:51Z","receivedAt":"2026-06-18T21:24:53Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> As t3454 is taken by another topic already in flight, I've queued a\n> trivial \"rename it to t3455\" patch on top before queuing the topic.\n\nAnother tweak I had to make was to replace \"grep\" with \"test_grep\"\nto avoid triggering test lint added by another topic in flight.\n\nFor the one in the second hunk, it may be much better to rewrite it\nto process \"out\" directly with the awk script without preprocessing\nit with \"grep\", as awk is a programming language capable enough to\nrecognize a line that matches a pattern and process only those\nmatching lines by itself.\n\n--- >8 ---\nAuthor: Junio C Hamano <gitster@pobox.com>\nDate:   Thu Jun 18 13:44:36 2026 -0700\n\n    SQUASH??? avoid test_grep lint triggering on uses of raw grep\n\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 1edd148295..20370c0136 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -150,10 +150,10 @@ test_expect_success '--reedit-message offers every folded-in message' '\n \ttest_set_editor \"$(pwd)/editor\" &&\n \tgit history squash --reedit-message start.. &&\n \n-\tgrep \"re-one subject\" buffer &&\n-\tgrep \"re-one body line\" buffer &&\n-\tgrep re-two buffer &&\n-\tgrep re-three buffer &&\n+\ttest_grep \"re-one subject\" buffer &&\n+\ttest_grep \"re-one body line\" buffer &&\n+\ttest_grep re-two buffer &&\n+\ttest_grep re-three buffer &&\n \tgit log --format=\"%s\" -1 >actual &&\n \techo combined >expect &&\n \ttest_cmp expect actual\n@@ -177,7 +177,7 @@ test_expect_success '--dry-run predicts the rewrite without performing it' '\n \thead_before=$(git rev-parse HEAD) &&\n \n \tgit history squash --dry-run start.. >out &&\n-\tgrep \"^update refs/heads/\" out >update &&\n+\ttest_grep \"^update refs/heads/\" out >update &&\n \tpredicted=$(awk \"{print \\$3}\" update) &&\n \ttest_cmp_rev \"$head_before\" HEAD &&\n \n"},{"id":"545903","messageId":"CALnO6CDK50QhiwfO3=gyrn0HNFC6=2FfjByAcLxtg0op1cpF+g@mail.gmail.com","threadId":"65812","inReplyTo":"xmqqzf0rpmn0.fsf@gitster.g","subject":"Re: [PATCH v3 3/4] history: add squash subcommand to fold a range","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-06-18T21:29:33Z","receivedAt":"2026-06-18T21:29:44Z","isPatch":true,"body":"On Thu, Jun 18, 2026 at 5:25 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Junio C Hamano <gitster@pobox.com> writes:\n>\n> > As t3454 is taken by another topic already in flight, I've queued a\n> > trivial \"rename it to t3455\" patch on top before queuing the topic.\n>\n> Another tweak I had to make was to replace \"grep\" with \"test_grep\"\n> to avoid triggering test lint added by another topic in flight.\n>\n> For the one in the second hunk, it may be much better to rewrite it\n> to process \"out\" directly with the awk script without preprocessing\n> it with \"grep\", as awk is a programming language capable enough to\n> recognize a line that matches a pattern and process only those\n> matching lines by itself.\n>\n> --- >8 ---\n> Author: Junio C Hamano <gitster@pobox.com>\n> Date:   Thu Jun 18 13:44:36 2026 -0700\n>\n>     SQUASH??? avoid test_grep lint triggering on uses of raw grep\n>\n> diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\n> index 1edd148295..20370c0136 100755\n> --- a/t/t3455-history-squash.sh\n> +++ b/t/t3455-history-squash.sh\n[snip]\n> @@ -177,7 +177,7 @@ test_expect_success '--dry-run predicts the rewrite without performing it' '\n>         head_before=$(git rev-parse HEAD) &&\n>\n>         git history squash --dry-run start.. >out &&\n> -       grep \"^update refs/heads/\" out >update &&\n> +       test_grep \"^update refs/heads/\" out >update &&\n>         predicted=$(awk \"{print \\$3}\" update) &&\n>         test_cmp_rev \"$head_before\" HEAD &&\n\nOdd: I thought the other topic acknowledged that bare grep as a filter\n(here, with stdout redirected) was fine. My memory must not be right\n:)\n\n-- \nD. Ben Knoble\n"},{"id":"545907","messageId":"xmqqo6h7nza3.fsf@gitster.g","threadId":"65812","inReplyTo":"pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com","subject":"Re: [PATCH v3 0/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-19T00:34:44Z","receivedAt":"2026-06-19T00:34:47Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> Adds git history squash <revision-range> to fold a range of commits into its\n> oldest one, reusing that commit's message and replaying any descendants on\n> top.\n\nOne thing that just occurred to me.\n\nWhen you have a linear history\n\n    o---A---B---C\n\nyou run \"git history squash A..C\" and come to\n\n    o---X\n\nwhere the tree of X is the same as C, with the log message of A\nreused for it.  That is simple, clean, and easy to explain.\n\nBut what should happen to refs (i.e., branch head) that point at A\nor B?\n\nI am adressing this message to Patrick as this question relates to\nthe grand vision for the \"git history\" command.  I think \"git\nreplay\" wants to rewrite all the refs that are involved in the\nrewrite operation, while \"git rebase\" (without \"--update-refs\")\nwants to leave all others refs intact and update only the branch it\nwas told to rewrite.  Is it the same design as \"rebase\" and\n\"--update-refs\" controls if we update _other_ refs that happened to\nbe in the range that are rewritten?\n\nNow, assuming that there do exist a mode where the command can\nupdate these refs that point into the history that got rewritten,\nthere probably are at least two possibilities.\n\nOn one hand, I think it is reasonable to _remove_ these refs that\nused to point at a section of history that disappeared (like the one\nthat were pointing at A or B).  Perhaps A and B were pointed at by\ntwo branches or tags that were used to mark \"up to this point things\nare broken\" and \"from here on things are fixed\" (i.e., imagine a\nmanual bisection).  After squashing all of the commits in this\nsection of history, the result no longer has such transition points.\n\nIt also is plausible that users may want these refs that used to\npoint at A or B to point at X, just like the ref that used to point\nat C would now point at X, even though I cannot offhand think of a\ngood story (like \"there used to be transtion points, now there\nisn't\" I said above to explain why these refs should disappear) to\nsupport such a behaviour.\n\nThoughts?\n"},{"id":"545947","messageId":"ajU4JYYUTz5r-Xgc@pks.im","threadId":"65812","inReplyTo":"xmqqo6h7nza3.fsf@gitster.g","subject":"Re: [PATCH v3 0/4] history: add squash subcommand to fold a range","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-19T12:37:57Z","receivedAt":"2026-06-19T12:38:06Z","isPatch":true,"body":"On Thu, Jun 18, 2026 at 05:34:44PM -0700, Junio C Hamano wrote:\n> \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n> \n> > Adds git history squash <revision-range> to fold a range of commits into its\n> > oldest one, reusing that commit's message and replaying any descendants on\n> > top.\n> \n> One thing that just occurred to me.\n> \n> When you have a linear history\n> \n>     o---A---B---C\n> \n> you run \"git history squash A..C\" and come to\n> \n>     o---X\n> \n> where the tree of X is the same as C, with the log message of A\n> reused for it.  That is simple, clean, and easy to explain.\n> \n> But what should happen to refs (i.e., branch head) that point at A\n> or B?\n\nIt's a very good question. I had `git history squash` in my backlog for\na while, and this very question made me defer that topic repeatedly.\n\n> I am adressing this message to Patrick as this question relates to\n> the grand vision for the \"git history\" command.  I think \"git\n> replay\" wants to rewrite all the refs that are involved in the\n> rewrite operation, while \"git rebase\" (without \"--update-refs\")\n> wants to leave all others refs intact and update only the branch it\n> was told to rewrite.  Is it the same design as \"rebase\" and\n> \"--update-refs\" controls if we update _other_ refs that happened to\n> be in the range that are rewritten?\n\nYeah.\n\n> Now, assuming that there do exist a mode where the command can\n> update these refs that point into the history that got rewritten,\n> there probably are at least two possibilities.\n> \n> On one hand, I think it is reasonable to _remove_ these refs that\n> used to point at a section of history that disappeared (like the one\n> that were pointing at A or B).  Perhaps A and B were pointed at by\n> two branches or tags that were used to mark \"up to this point things\n> are broken\" and \"from here on things are fixed\" (i.e., imagine a\n> manual bisection).  After squashing all of the commits in this\n> section of history, the result no longer has such transition points.\n\nI think just pruning references would be extremely surprising to our\nusers.\n\n> It also is plausible that users may want these refs that used to\n> point at A or B to point at X, just like the ref that used to point\n> at C would now point at X, even though I cannot offhand think of a\n> good story (like \"there used to be transtion points, now there\n> isn't\" I said above to explain why these refs should disappear) to\n> support such a behaviour.\n>\n> Thoughts?\n\nThere are two more modes:\n\n  - If a reference points at an intermediate commit then it stays there.\n\n  - We detect this case and reject the update. Optionally, we may ask\n    the user what they intend to do with those other refs.\n\nIt really is kind of ambiguous what is supposed to happen, and I can\nthink of different scenarios where each of the possibilities would be\nthe best choice. So ultimately, I think the last option is the best one,\nas it also gives us a way to iterate.\n\nIf so, a user would already be able to achieve that other refs keep\npointing at X by saying `git history squash --update-refs=head`. The\nother modes can then be added at a later point in time as the need\narises.\n\nPatrick\n"},{"id":"545948","messageId":"ajU8T2JFJTdk1hr2@pks.im","threadId":"65812","inReplyTo":"66b2f49fb427c7328136b2d440dc7461b97fb4e0.1781810227.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v3 3/4] history: add squash subcommand to fold a range","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-19T12:55:43Z","receivedAt":"2026-06-19T12:55:50Z","isPatch":true,"body":"On Thu, Jun 18, 2026 at 07:17:05PM +0000, Harald Nordgren via GitGitGadget wrote:\n> diff --git a/builtin/history.c b/builtin/history.c\n> index 305bde3102..9d9416870f 100644\n> --- a/builtin/history.c\n> +++ b/builtin/history.c\n> @@ -973,6 +975,156 @@ out:\n>  \treturn ret;\n>  }\n>  \n> +/*\n> + * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n> + * the range (which becomes the parent of the squashed commit), the oldest\n> + * commit contained in the range (whose message the squash reuses), and the\n> + * range tip (whose tree becomes the result). A merge inside the range is fine,\n> + * but the range must have a single base and must not reach a root commit.\n> + */\n> +static int resolve_squash_range(struct repository *repo,\n> +\t\t\t\tconst char *range,\n> +\t\t\t\tstruct commit **base_out,\n> +\t\t\t\tstruct commit **oldest_out,\n> +\t\t\t\tstruct commit **tip_out)\n> +{\n> +\tstruct rev_info revs;\n> +\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n> +\tstruct strvec args = STRVEC_INIT;\n> +\tint ret;\n> +\n> +\trepo_init_revisions(repo, &revs, NULL);\n> +\tstrvec_push(&args, \"ignored\");\n> +\tstrvec_push(&args, \"--reverse\");\n> +\tstrvec_push(&args, \"--topo-order\");\n> +\tstrvec_push(&args, \"--boundary\");\n> +\tstrvec_push(&args, range);\n\nWe don't have any kind of input verification for \"range\". So in theory,\nthe user could pass whatever string here, and this may or may not work.\n\nAlso, should we use \"--ancestry-path\" with the first commit of the range\nhere? Otherwise we may incldue commits that aren't descendants of A in a\nrange \"A..B\". If not I wonder whether we might see multiple boundaries\neven though we would be able to resolve the boundary unambiguously in\nsome cases.\n\n> +\tsetup_revisions_from_strvec(&args, &revs, NULL);\n> +\tif (args.nr != 1) {\n> +\t\tret = error(_(\"'%s' does not name a revision range\"), range);\n> +\t\tgoto out;\n> +\t}\n> +\n> +\tif (prepare_revision_walk(&revs) < 0) {\n> +\t\tret = error(_(\"error preparing revisions\"));\n> +\t\tgoto out;\n> +\t}\n> +\n> +\twhile ((commit = get_revision(&revs))) {\n> +\t\tif (commit->object.flags & BOUNDARY) {\n> +\t\t\tif (base) {\n> +\t\t\t\tret = error(_(\"range '%s' has more than one base; \"\n> +\t\t\t\t\t      \"cannot squash\"), range);\n> +\t\t\t\tgoto out;\n> +\t\t\t}\n> +\t\t\tbase = commit;\n> +\t\t\tcontinue;\n> +\t\t}\n> +\t\tif (!oldest)\n> +\t\t\toldest = commit;\n> +\t\ttip = commit;\n> +\t}\n\nHmm. I really wonder whether we should also restrict merges. It might be\nsomewhat obvious that intermediate merge commits should just be\ndiscarded. But is that equally obvious for HEAD and the base commit?\n\n> +\tif (!oldest) {\n> +\t\tret = error(_(\"the range '%s' is empty\"), range);\n> +\t\tgoto out;\n> +\t}\n> +\n> +\tif (!base) {\n> +\t\tret = error(_(\"cannot squash the root commit\"));\n> +\t\tgoto out;\n> +\t}\n\nIn theory we can by squashing onto an empty tree. But it's fine to not\ncare about this edge case, we can still address it at a later point in\ntime if we ever feel the need to.\n\n> +\t*base_out = base;\n> +\t*oldest_out = oldest;\n> +\t*tip_out = tip;\n> +\tret = 0;\n> +\n> +out:\n> +\treset_revision_walk();\n> +\trelease_revisions(&revs);\n> +\tstrvec_clear(&args);\n> +\treturn ret;\n> +}\n> +\n> +static int cmd_history_squash(int argc,\n> +\t\t\t      const char **argv,\n> +\t\t\t      const char *prefix,\n> +\t\t\t      struct repository *repo)\n> +{\n> +\tconst char * const usage[] = {\n> +\t\tGIT_HISTORY_SQUASH_USAGE,\n> +\t\tNULL,\n> +\t};\n> +\tenum ref_action action = REF_ACTION_DEFAULT;\n> +\tenum commit_tree_flags flags = 0;\n> +\tint dry_run = 0;\n> +\tstruct option options[] = {\n> +\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n> +\t\t\t       N_(\"control which refs should be updated\"),\n> +\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n> +\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n> +\t\t\t N_(\"perform a dry-run without updating any refs\")),\n> +\t\tOPT_BIT(0, \"reedit-message\", &flags,\n> +\t\t\tN_(\"open an editor to modify the commit message\"),\n> +\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n> +\t\tOPT_END(),\n> +\t};\n> +\tstruct strbuf reflog_msg = STRBUF_INIT;\n> +\tstruct commit *base, *oldest, *tip, *rewritten;\n> +\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n> +\tstruct commit_list *parents = NULL;\n> +\tstruct rev_info revs = { 0 };\n> +\tint ret;\n> +\n> +\targc = parse_options(argc, argv, prefix, options, usage, 0);\n> +\tif (argc != 1) {\n> +\t\tret = error(_(\"command expects a single revision range\"));\n> +\t\tgoto out;\n> +\t}\n> +\trepo_config(repo, git_default_config, NULL);\n> +\n> +\tif (action == REF_ACTION_DEFAULT)\n> +\t\taction = REF_ACTION_BRANCHES;\n> +\n> +\tret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip);\n> +\tif (ret < 0)\n> +\t\tgoto out;\n> +\n> +\tret = setup_revwalk(repo, action, tip, &revs);\n> +\tif (ret < 0)\n> +\t\tgoto out;\n\nOh, you already use `setup_revwalk()` here. Wouldn't that keep us from\naccepting merge commits?\n\nPatrick\n"},{"id":"545987","messageId":"xmqqv7beldbu.fsf@gitster.g","threadId":"65812","inReplyTo":"ajU4JYYUTz5r-Xgc@pks.im","subject":"Re: [PATCH v3 0/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-19T16:11:49Z","receivedAt":"2026-06-19T16:11:53Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> There are two more modes:\n>\n>   - If a reference points at an intermediate commit then it stays there.\n>\n>   - We detect this case and reject the update. Optionally, we may ask\n>     the user what they intend to do with those other refs.\n>\n> It really is kind of ambiguous what is supposed to happen, and I can\n> think of different scenarios where each of the possibilities would be\n> the best choice. So ultimately, I think the last option is the best one,\n> as it also gives us a way to iterate.\n>\n> If so, a user would already be able to achieve that other refs keep\n> pointing at X by saying `git history squash --update-refs=head`. The\n> other modes can then be added at a later point in time as the need\n> arises.\n\nYeah, sounds like we should detect and fail this case, with advice()\nto use --update-refs.\n\n"},{"id":"546053","messageId":"pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com","subject":"[PATCH v4 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-21T05:53:11Z","receivedAt":"2026-06-21T05:53:17Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (4):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  history: add squash subcommand to fold a range\n  history: re-edit a squash with every message\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  26 +++\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 328 +++++++++++++++++++++++++----\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 340 +++++++++++++++++++++++++++++++\n 7 files changed, 663 insertions(+), 38 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 8d96f09e9245ddf80c1981476fcbac8c4bb4125f\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v4\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v4\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v3:\n\n 1:  1e31474ef6 = 1:  fc2801c0b1 history: extract helper for a commit's parent tree\n 2:  498da64046 = 2:  ee591e83b4 history: give commit_tree_ext a message template\n 3:  66b2f49fb4 ! 3:  80bfea642e history: add squash subcommand to fold a range\n     @@ Commit message\n          other commit, but the range must have a single base, so a range with\n          more than one entry point is rejected.\n      \n     +    The folded commits leave the history, so by default the command refuses\n     +    when another ref points at one of them. Use \"--update-refs=head\" to\n     +    rewrite only the current branch and leave those refs untouched.\n     +\n          Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     + ## Documentation/config/advice.adoc ##\n     +@@ Documentation/config/advice.adoc: all advice messages.\n     + \tforceDeleteBranch::\n     + \t\tShown when the user tries to delete a not fully merged\n     + \t\tbranch without the force option set.\n     ++\thistoryUpdateRefs::\n     ++\t\tShown when `git history squash` refuses because a ref points\n     ++\t\tinto the range being folded, to tell the user about\n     ++\t\t`--update-refs=head`.\n     + \tignoredHook::\n     + \t\tShown when a hook is ignored because the hook is not\n     + \t\tset as executable.\n     +\n       ## Documentation/git-history.adoc ##\n      @@ Documentation/git-history.adoc: SYNOPSIS\n       git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +folded like any other, but the range must have a single base, so a range\n      +that reaches more than one entry point (for example a side branch that\n      +forked before the range and was later merged into it) is rejected.\n     +++\n     ++The folded commits disappear from the history, so with the default\n     ++`--update-refs=branches` the command refuses when another ref points at\n     ++one of them. Rerun with `--update-refs=head` to rewrite only the current\n     ++branch and leave those refs pointing at the old commits.\n      +\n       OPTIONS\n       -------\n       \n      \n     + ## advice.c ##\n     +@@ advice.c: static struct {\n     + \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n     + \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n     + \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n     ++\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n     + \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n     + \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n     + \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\n     +\n     + ## advice.h ##\n     +@@ advice.h: enum advice_type {\n     + \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n     + \tADVICE_FORCE_DELETE_BRANCH,\n     + \tADVICE_GRAFT_FILE_DEPRECATED,\n     ++\tADVICE_HISTORY_UPDATE_REFS,\n     + \tADVICE_IGNORED_HOOK,\n     + \tADVICE_IMPLICIT_IDENTITY,\n     + \tADVICE_MERGE_CONFLICT,\n     +\n       ## builtin/history.c ##\n     +@@\n     + #define USE_THE_REPOSITORY_VARIABLE\n     + \n     + #include \"builtin.h\"\n     ++#include \"advice.h\"\n     + #include \"cache-tree.h\"\n     + #include \"commit.h\"\n     + #include \"commit-reach.h\"\n      @@\n       \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n       #define GIT_HISTORY_SPLIT_USAGE \\\n     @@ builtin/history.c: out:\n      +\t\t\t\tconst char *range,\n      +\t\t\t\tstruct commit **base_out,\n      +\t\t\t\tstruct commit **oldest_out,\n     -+\t\t\t\tstruct commit **tip_out)\n     ++\t\t\t\tstruct commit **tip_out,\n     ++\t\t\t\tstruct oidset *interior_out)\n      +{\n      +\tstruct rev_info revs;\n      +\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n     @@ builtin/history.c: out:\n      +\t\t}\n      +\t\tif (!oldest)\n      +\t\t\toldest = commit;\n     ++\t\tif (tip)\n     ++\t\t\toidset_insert(interior_out, &tip->object.oid);\n      +\t\ttip = commit;\n      +\t}\n      +\n     @@ builtin/history.c: out:\n      +\treturn ret;\n      +}\n      +\n     ++struct interior_ref_cb {\n     ++\tconst struct oidset *interior;\n     ++\tconst char *name;\n     ++};\n     ++\n     ++static int find_interior_ref(const struct reference *ref, void *cb_data)\n     ++{\n     ++\tstruct interior_ref_cb *data = cb_data;\n     ++\n     ++\tif (oidset_contains(data->interior, ref->oid)) {\n     ++\t\tdata->name = xstrdup(ref->name);\n     ++\t\treturn 1;\n     ++\t}\n     ++\n     ++\treturn 0;\n     ++}\n     ++\n      +static int cmd_history_squash(int argc,\n      +\t\t\t      const char **argv,\n      +\t\t\t      const char *prefix,\n     @@ builtin/history.c: out:\n      +\t\tOPT_END(),\n      +\t};\n      +\tstruct strbuf reflog_msg = STRBUF_INIT;\n     ++\tstruct oidset interior = OIDSET_INIT;\n      +\tstruct commit *base, *oldest, *tip, *rewritten;\n      +\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n      +\tstruct commit_list *parents = NULL;\n     @@ builtin/history.c: out:\n      +\tif (action == REF_ACTION_DEFAULT)\n      +\t\taction = REF_ACTION_BRANCHES;\n      +\n     -+\tret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip);\n     ++\tret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip,\n     ++\t\t\t\t   &interior);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n      +\n     ++\tif (action == REF_ACTION_BRANCHES) {\n     ++\t\tstruct interior_ref_cb cb = { .interior = &interior };\n     ++\n     ++\t\trefs_for_each_ref(get_main_ref_store(repo),\n     ++\t\t\t\t  find_interior_ref, &cb);\n     ++\t\tif (cb.name) {\n     ++\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n     ++\t\t\t\t    cb.name);\n     ++\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n     ++\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n     ++\t\t\t\t\t    \"the current branch and leave such refs \"\n     ++\t\t\t\t\t    \"untouched.\"));\n     ++\t\t\tfree((char *)cb.name);\n     ++\t\t\tgoto out;\n     ++\t\t}\n     ++\t}\n     ++\n      +\tret = setup_revwalk(repo, action, tip, &revs);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n     @@ builtin/history.c: out:\n      +\n      +out:\n      +\tstrbuf_release(&reflog_msg);\n     ++\toidset_clear(&interior);\n      +\tcommit_list_free(parents);\n      +\trelease_revisions(&revs);\n      +\treturn ret;\n     @@ t/meson.build: integration_tests = [\n         't3451-history-reword.sh',\n         't3452-history-split.sh',\n         't3453-history-fixup.sh',\n     -+  't3454-history-squash.sh',\n     ++  't3455-history-squash.sh',\n         't3500-cherry.sh',\n         't3501-revert-cherry-pick.sh',\n         't3502-cherry-pick-merge.sh',\n      \n     - ## t/t3454-history-squash.sh (new) ##\n     + ## t/t3455-history-squash.sh (new) ##\n      @@\n      +#!/bin/sh\n      +\n     @@ t/t3454-history-squash.sh (new)\n      +test_expect_success 'setup linear history touching two files' '\n      +\ttest_commit base file a &&\n      +\tgit tag start &&\n     -+\ttest_commit one other x &&\n     -+\ttest_commit two file c &&\n     ++\ttest_commit --no-tag one other x &&\n     ++\ttest_commit --no-tag two file c &&\n      +\ttest_commit three file d\n      +'\n      +\n     @@ t/t3454-history-squash.sh (new)\n      +\n      +test_expect_success 'reuses the message of a fixup! commit in the range' '\n      +\tgit reset --hard start &&\n     -+\ttest_commit reg1 file b &&\n     ++\ttest_commit --no-tag reg1 file b &&\n      +\tgit commit --allow-empty -m \"fixup! reg1\" &&\n      +\ttest_commit reg2 file c &&\n      +\n     @@ t/t3454-history-squash.sh (new)\n      +test_expect_success 'preserves authorship of the oldest commit' '\n      +\tgit reset --hard start &&\n      +\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n     -+\t\ttest_commit oldest file b &&\n     ++\t\ttest_commit --no-tag oldest file b &&\n      +\ttest_commit newest file c &&\n      +\n      +\tgit history squash start.. &&\n     @@ t/t3454-history-squash.sh (new)\n      +test_expect_success '--dry-run predicts the rewrite without performing it' '\n      +\tgit reset --hard three &&\n      +\thead_before=$(git rev-parse HEAD) &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n      +\tgit history squash --dry-run start.. >out &&\n     -+\tgrep \"^update refs/heads/\" out >update &&\n     -+\tpredicted=$(awk \"{print \\$3}\" update) &&\n     ++\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n      +\tgit history squash start.. &&\n     -+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\"\n     ++\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\ttest_cmp_rev start HEAD^ &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n      +'\n      +\n      +test_expect_success '--update-refs=head only moves HEAD' '\n     @@ t/t3454-history-squash.sh (new)\n      +\ttest_cmp_rev \"$other_before\" other\n      +'\n      +\n     -+test_expect_success '--update-refs=branches moves a branch pointing into the range' '\n     ++test_expect_success 'refuses to fold a range a ref points into' '\n     ++\tgit reset --hard three &&\n     ++\tgit branch -f mid HEAD~1 &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash start.. 2>err &&\n     ++\ttest_grep \"error: .* points into the squashed range\" err &&\n     ++\ttest_grep \"hint: .*--update-refs=head\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\n     ++\tgit branch -D mid\n     ++'\n     ++\n     ++test_expect_success 'advice.historyUpdateRefs silences the hint' '\n     ++\tgit reset --hard three &&\n     ++\tgit branch -f mid HEAD~1 &&\n     ++\n     ++\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n     ++\t\thistory squash start.. 2>err &&\n     ++\ttest_grep \"points into the squashed range\" err &&\n     ++\ttest_grep ! \"hint:\" err &&\n     ++\n     ++\tgit branch -D mid\n     ++'\n     ++\n     ++test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n      +\tgit reset --hard three &&\n     -+\tgit branch -f mid HEAD~2 &&\n     ++\tgit branch -f mid HEAD~1 &&\n      +\tmid_before=$(git rev-parse mid) &&\n      +\n     -+\tgit history squash start..@~1 &&\n     ++\tgit history squash --update-refs=head start.. &&\n      +\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n      +\ttest_cmp_rev \"$mid_before\" mid &&\n     -+\ttest_commit_message mid -m one\n     ++\n     ++\tgit branch -D mid\n     ++'\n     ++\n     ++test_expect_success 'refuses to fold a range a tag points into' '\n     ++\tgit reset --hard three &&\n     ++\tgit tag -f mark HEAD~1 &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash start.. 2>err &&\n     ++\ttest_grep \"refs/tags/mark\" err &&\n     ++\ttest_grep \"points into the squashed range\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\n     ++\tgit tag -d mark\n      +'\n      +\n      +test_expect_success 'squashes a range whose internal merge has a single base' '\n      +\tgit reset --hard start &&\n     -+\ttest_commit before-side file b &&\n     ++\ttest_commit --no-tag before-side file b &&\n      +\tgit checkout -b inner-side &&\n     -+\ttest_commit on-inner-side inner x &&\n     ++\ttest_commit --no-tag on-inner-side inner x &&\n      +\tgit checkout - &&\n     -+\ttest_commit after-side file c &&\n     ++\ttest_commit --no-tag after-side file c &&\n      +\tgit merge --no-ff -m merge inner-side &&\n     -+\ttest_commit after-merge file d &&\n     ++\tgit branch -D inner-side &&\n     ++\ttest_commit --no-tag after-merge file d &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n      +\tgit history squash start.. &&\n     @@ t/t3454-history-squash.sh (new)\n      +\ttest_path_is_file inner\n      +'\n      +\n     ++test_expect_success 'folds a range whose tip is a merge commit' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag tipmerge-base file b &&\n     ++\tgit checkout -b tipmerge-side &&\n     ++\ttest_commit --no-tag tipmerge-side side x &&\n     ++\tgit checkout - &&\n     ++\ttest_commit --no-tag tipmerge-main file c &&\n     ++\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n     ++\tgit branch -D tipmerge-side &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash start.. &&\n     ++\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     ++\ttest_path_is_file side\n     ++'\n     ++\n     ++test_expect_success 'folds a range whose base is a merge commit' '\n     ++\tgit reset --hard start &&\n     ++\tgit checkout -b basemerge-side &&\n     ++\ttest_commit --no-tag basemerge-side side x &&\n     ++\tgit checkout - &&\n     ++\ttest_commit --no-tag basemerge-main file b &&\n     ++\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n     ++\tgit branch -D basemerge-side &&\n     ++\tbase=$(git rev-parse HEAD) &&\n     ++\ttest_commit --no-tag basemerge-one file c &&\n     ++\ttest_commit --no-tag basemerge-two file d &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash \"$base..\" &&\n     ++\n     ++\tgit rev-list --count \"$base..HEAD\" >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\ttest_cmp_rev \"$base\" HEAD^ &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n     ++'\n     ++\n      +test_expect_success 'refuses to squash a range with more than one base' '\n      +\tgit reset --hard start &&\n      +\thead_before=$(git rev-parse HEAD) &&\n 4:  43e4270614 ! 4:  85c7817d7e history: re-edit a squash with every message\n     @@ Documentation/git-history.adoc: history squash @~3..` folds the three most recen\n       forked before the range and was later merged into it) is rejected.\n      \n       ## builtin/history.c ##\n     -@@ builtin/history.c: out:\n     - \treturn ret;\n     +@@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data)\n     + \treturn 0;\n       }\n       \n      +static int build_squash_message(struct repository *repo,\n     @@ builtin/history.c: static int cmd_history_squash(int argc,\n       \t};\n       \tstruct strbuf reflog_msg = STRBUF_INIT;\n      +\tstruct strbuf message = STRBUF_INIT;\n     + \tstruct oidset interior = OIDSET_INIT;\n       \tstruct commit *base, *oldest, *tip, *rewritten;\n       \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n     - \tstruct commit_list *parents = NULL;\n      @@ builtin/history.c: static int cmd_history_squash(int argc,\n     - \tif (ret < 0)\n     - \t\tgoto out;\n     + \t\t}\n     + \t}\n       \n      +\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n      +\t\tret = build_squash_message(repo, base, tip, &message);\n     @@ builtin/history.c: static int cmd_history_squash(int argc,\n       out:\n       \tstrbuf_release(&reflog_msg);\n      +\tstrbuf_release(&message);\n     + \toidset_clear(&interior);\n       \tcommit_list_free(parents);\n       \trelease_revisions(&revs);\n     - \treturn ret;\n      \n     - ## t/t3454-history-squash.sh ##\n     -@@ t/t3454-history-squash.sh: test_expect_success 'preserves authorship of the oldest commit' '\n     + ## t/t3455-history-squash.sh ##\n     +@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the oldest commit' '\n       \ttest_cmp expect actual\n       '\n       \n     @@ t/t3454-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +\techo b >file &&\n      +\tgit add file &&\n      +\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n     -+\ttest_commit re-two file c &&\n     ++\ttest_commit --no-tag re-two file c &&\n      +\ttest_commit re-three file d &&\n      +\n      +\twrite_script editor <<-\\EOF &&\n     @@ t/t3454-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +\ttest_set_editor \"$(pwd)/editor\" &&\n      +\tgit history squash --reedit-message start.. &&\n      +\n     -+\tgrep \"re-one subject\" buffer &&\n     -+\tgrep \"re-one body line\" buffer &&\n     -+\tgrep re-two buffer &&\n     -+\tgrep re-three buffer &&\n     ++\ttest_grep \"re-one subject\" buffer &&\n     ++\ttest_grep \"re-one body line\" buffer &&\n     ++\ttest_grep re-two buffer &&\n     ++\ttest_grep re-three buffer &&\n      +\tgit log --format=\"%s\" -1 >actual &&\n      +\techo combined >expect &&\n      +\ttest_cmp expect actual\n\n-- \ngitgitgadget\n"},{"id":"546054","messageId":"fc2801c0b123c567120dcce45aa2dd58a97aac6a.1782021195.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com","subject":"[PATCH v4 1/4] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-21T05:53:12Z","receivedAt":"2026-06-21T05:53:18Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 091465a59e..f95f26e684 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -157,6 +157,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"546055","messageId":"ee591e83b461311a55514be7418639532cf9997c.1782021195.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com","subject":"[PATCH v4 2/4] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-21T05:53:13Z","receivedAt":"2026-06-21T05:53:19Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex f95f26e684..305bde3102 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -101,6 +101,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"546057","messageId":"80bfea642e947dfc2bb56a3774e4f0b5f22aa8f0.1782021195.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com","subject":"[PATCH v4 3/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-21T05:53:14Z","receivedAt":"2026-06-21T05:53:20Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into the oldest one, keeping that commit's\nmessage and authorship and taking the tree of the newest commit, so the\nrange collapses into a single commit. Commits above the range are\nreplayed on top of the result.\n\nThe range is given as <base>..<tip>, so \"git history squash @~3..\"\nfolds the three most recent commits and \"git history squash @~5..@~2\"\nsquashes an interior range. A merge inside the range is folded like any\nother commit, but the range must have a single base, so a range with\nmore than one entry point is rejected.\n\nThe folded commits leave the history, so by default the command refuses\nwhen another ref points at one of them. Use \"--update-refs=head\" to\nrewrite only the current branch and leave those refs untouched.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  25 +++\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 195 ++++++++++++++++++++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 303 +++++++++++++++++++++++++++++++\n 7 files changed, 530 insertions(+)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..f4d692d136 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -55,6 +55,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 2ba8121795..6716749cde 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -11,6 +11,7 @@ SYNOPSIS\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n \n DESCRIPTION\n -----------\n@@ -97,6 +98,30 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's message and\n+\tauthorship and takes the tree of the range's newest commit, so the\n+\twhole range collapses into a single commit. Commits above the range\n+\tare replayed on top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash @~3..` folds the three most recent commits into one, and\n+`git history squash @~5..@~2` squashes an interior range while leaving\n+the two newest commits in place.\n++\n+The oldest commit's message and authorship are preserved by default,\n+unless you specify `--reedit-message`. A merge commit inside the range is\n+folded like any other, but the range must have a single base, so a range\n+that reaches more than one entry point (for example a side branch that\n+forked before the range and was later merged into it) is rejected.\n++\n+The folded commits disappear from the history, so with the default\n+`--update-refs=branches` the command refuses when another ref points at\n+one of them. Rerun with `--update-refs=head` to rewrite only the current\n+branch and leave those refs pointing at the old commits.\n+\n OPTIONS\n -------\n \ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..5c6ff95e31 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 8def280688..911b4e4643 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 305bde3102..4f1baea56c 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -30,6 +31,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -973,6 +976,196 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char *range,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tstruct oidset *interior_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct strvec args = STRVEC_INIT;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_push(&args, \"--boundary\");\n+\tstrvec_push(&args, range);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"'%s' does not name a revision range\"), range);\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tif (base) {\n+\t\t\t\tret = error(_(\"range '%s' has more than one base; \"\n+\t\t\t\t\t      \"cannot squash\"), range);\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tbase = commit;\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\tif (tip)\n+\t\t\toidset_insert(interior_out, &tip->object.oid);\n+\t\ttip = commit;\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the range '%s' is empty\"), range);\n+\t\tgoto out;\n+\t}\n+\n+\tif (!base) {\n+\t\tret = error(_(\"cannot squash the root commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+struct interior_ref_cb {\n+\tconst struct oidset *interior;\n+\tconst char *name;\n+};\n+\n+static int find_interior_ref(const struct reference *ref, void *cb_data)\n+{\n+\tstruct interior_ref_cb *data = cb_data;\n+\n+\tif (oidset_contains(data->interior, ref->oid)) {\n+\t\tdata->name = xstrdup(ref->name);\n+\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct oidset interior = OIDSET_INIT;\n+\tstruct commit *base, *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage, 0);\n+\tif (argc != 1) {\n+\t\tret = error(_(\"command expects a single revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip,\n+\t\t\t\t   &interior);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tif (action == REF_ACTION_BRANCHES) {\n+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n+\n+\t\trefs_for_each_ref(get_main_ref_store(repo),\n+\t\t\t\t  find_interior_ref, &cb);\n+\t\tif (cb.name) {\n+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n+\t\t\t\t    cb.name);\n+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t\t    \"untouched.\"));\n+\t\t\tfree((char *)cb.name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\toidset_clear(&interior);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n int cmd_history(int argc,\n \t\tconst char **argv,\n \t\tconst char *prefix,\n@@ -982,6 +1175,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -989,6 +1183,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex 3219264fe7..63ea26b8ed 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -399,6 +399,7 @@ integration_tests = [\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..821c801153\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,303 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\ttest_commit --no-tag one other x &&\n+\ttest_commit --no-tag two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"command expects a single revision range\" err\n+'\n+\n+test_expect_success 'errors on too many arguments' '\n+\ttest_must_fail git history squash start.. HEAD 2>err &&\n+\ttest_grep \"command expects a single revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the range .* is empty\" err\n+'\n+\n+test_expect_success 'errors when the range includes the root commit' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"cannot squash the root commit\" err\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo one >expect &&\n+\ttest_cmp expect subject &&\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$root..\" &&\n+\n+\tgit rev-list --count \"$root..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashing a single-commit range replays the rest' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~2 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'reuses the message of a fixup! commit in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag reg1 file b &&\n+\tgit commit --allow-empty -m \"fixup! reg1\" &&\n+\ttest_commit reg2 file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo reg1 >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'keeps the oldest message even if it is a fixup!' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag \"fixup! something\" file b &&\n+\ttest_commit tail file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo \"fixup! something\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'preserves authorship of the oldest commit' '\n+\tgit reset --hard start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit --no-tag oldest file b &&\n+\ttest_commit newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--dry-run predicts the rewrite without performing it' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"error: .* points into the squashed range\" err &&\n+\ttest_grep \"hint: .*--update-refs=head\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\tmid_before=$(git rev-parse mid) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'refuses to fold a range a tag points into' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"refs/tags/mark\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit tag -d mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout - &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo before-side >expect &&\n+\ttest_cmp expect subject &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout - &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout - &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$base..\" &&\n+\n+\tgit rev-list --count \"$base..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'refuses to squash a range with more than one base' '\n+\tgit reset --hard start &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b forked-before &&\n+\ttest_commit forked-side fside x &&\n+\tgit checkout - &&\n+\ttest_commit forked-main file b &&\n+\tgit merge --no-ff -m merge forked-before &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash forked-main.. 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"546056","messageId":"85c7817d7eb2f2598940ae9dfa065aaedd9fe5a9.1782021195.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com","subject":"[PATCH v4 4/4] history: re-edit a squash with every message","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-21T05:53:15Z","receivedAt":"2026-06-21T05:53:22Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBy default \"git history squash\" reuses the oldest commit's message.\nWhen --reedit-message is given it only reopened that one message, so the\nmessages of the folded-in commits were lost.\n\nGather the messages of every commit in the range, oldest first, and use\nthem as the editor template when re-editing, mirroring how \"git rebase\n-i\" presents a squash. The combined message is built before the\ndescendant walk so it is not disturbed by the flags that walk leaves on\nthe commits.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  5 +--\n builtin/history.c              | 61 +++++++++++++++++++++++++++++++++-\n t/t3455-history-squash.sh      | 37 +++++++++++++++++++++\n 3 files changed, 100 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 6716749cde..df389015aa 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -111,8 +111,9 @@ history squash @~3..` folds the three most recent commits into one, and\n `git history squash @~5..@~2` squashes an interior range while leaving\n the two newest commits in place.\n +\n-The oldest commit's message and authorship are preserved by default,\n-unless you specify `--reedit-message`. A merge commit inside the range is\n+The oldest commit's message and authorship are preserved by default. With\n+`--reedit-message`, an editor opens pre-filled with the messages of all the\n+folded commits so you can combine them. A merge commit inside the range is\n folded like any other, but the range must have a single base, so a range\n that reaches more than one entry point (for example a side branch that\n forked before the range and was later merged into it) is rejected.\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 4f1baea56c..ff3bc9f945 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1068,6 +1068,56 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tint n = 0, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tconst char *message, *body;\n+\t\tstruct strbuf one = STRBUF_INIT;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\t\tstrbuf_addstr(&one, body);\n+\t\tstrbuf_trim_trailing_newline(&one);\n+\n+\t\tif (n++)\n+\t\t\tstrbuf_addch(out, '\\n');\n+\t\tstrbuf_addbuf(out, &one);\n+\t\tstrbuf_addch(out, '\\n');\n+\n+\t\tstrbuf_release(&one);\n+\t\trepo_unuse_commit_buffer(repo, commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1092,6 +1142,7 @@ static int cmd_history_squash(int argc,\n \t\tOPT_END(),\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n \tstruct oidset interior = OIDSET_INIT;\n \tstruct commit *base, *oldest, *tip, *rewritten;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n@@ -1131,6 +1182,12 @@ static int cmd_history_squash(int argc,\n \t\t}\n \t}\n \n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n+\n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1139,7 +1196,8 @@ static int cmd_history_squash(int argc,\n \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n \tcommit_list_append(base, &parents);\n \n-\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\tret = commit_tree_ext(repo, \"squash\", oldest,\n+\t\t\t      message.len ? message.buf : NULL, parents,\n \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n@@ -1160,6 +1218,7 @@ static int cmd_history_squash(int argc,\n \n out:\n \tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n \toidset_clear(&interior);\n \tcommit_list_free(parents);\n \trelease_revisions(&revs);\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 821c801153..1fb3b9b63e 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -135,6 +135,43 @@ test_expect_success 'preserves authorship of the oldest commit' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--reedit-message offers every folded-in message' '\n+\tgit reset --hard start &&\n+\techo b >file &&\n+\tgit add file &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcp \"$1\" buffer &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\ttest_grep \"re-one subject\" buffer &&\n+\ttest_grep \"re-one body line\" buffer &&\n+\ttest_grep re-two buffer &&\n+\ttest_grep re-three buffer &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo combined >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--reedit-message aborts on an empty message' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\ttest_must_fail git history squash --reedit-message start.. &&\n+\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n test_expect_success '--dry-run predicts the rewrite without performing it' '\n \tgit reset --hard three &&\n \thead_before=$(git rev-parse HEAD) &&\n-- \ngitgitgadget\n"},{"id":"546168","messageId":"ajkijomPo_kXSXul@pks.im","threadId":"65812","inReplyTo":"pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com","subject":"Re: [PATCH v4 0/4] history: add squash subcommand to fold a range","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-22T11:54:54Z","receivedAt":"2026-06-22T11:55:02Z","isPatch":true,"body":"On Sun, Jun 21, 2026 at 05:53:11AM +0000, Harald Nordgren via GitGitGadget wrote:\n> Adds git history squash <revision-range> to fold a range of commits.\n> \n> Changes in v4:\n> \n>  * git history squash now detects when another ref points at a commit inside\n>    the range being folded and refuses, with an advice.historyUpdateRefs hint\n>    to use --update-refs=head.\n>  * A merge inside the range is folded fine as long as the range has a single\n>    base; a range with merge commit at the tip or base also folds correctly.\n>    Only a range with more than one base is rejected.\n\nSome of the conceptual questions on v3 are still open, like for example\nwhether we should be using \"--ancestry=\". Please make sure that you are\nreplying to questions like that when rerolling, especially when you\ndon't plan to address them. Otherwise the reviewer will not know why you\ndidn't take feedback into account. See also the thread at [1] for\nguidance.\n\nI'll wait for open questions to be addressed before reviewing this (or\nany subsequent) version.\n\nThanks!\n\nPatrick\n\n[1]: <xmqqv7bhxiby.fsf@gitster.g>\n"},{"id":"546227","messageId":"CAHwyqnVV8NB3Njxat8shRi3K11O=Z-kP24rUxnTF3f9wK4BFmA@mail.gmail.com","threadId":"65812","inReplyTo":"ajkijomPo_kXSXul@pks.im","subject":"Re: [PATCH v4 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-23T10:41:05Z","receivedAt":"2026-06-23T10:41:44Z","isPatch":true,"body":"Sorry about that.\n\nI skipped the ancestry question because I felt the implementation\nwould get too complex and was hoping we could do without it. Probably\nwould have been better to be explicit about that instead of just\nskipping it. I don't want to balloon the code more than necessary, but\nI'll take a look at it now.\n\n\nHarald\n"},{"id":"546349","messageId":"pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com","subject":"[PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:54:58Z","receivedAt":"2026-06-24T21:55:04Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (4):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  history: add squash subcommand to fold a range\n  history: re-edit a squash with every message\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  26 ++\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 341 ++++++++++++++++++---\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 497 +++++++++++++++++++++++++++++++\n 7 files changed, 833 insertions(+), 38 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 26d8d94e94df5535eecd036f16627493506a0614\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v5\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v5\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v4:\n\n 1:  fc2801c0b1 = 1:  0f1ae9b05a history: extract helper for a commit's parent tree\n 2:  ee591e83b4 = 2:  a97ffab1e6 history: give commit_tree_ext a message template\n 3:  80bfea642e ! 3:  04e18ef979 history: add squash subcommand to fold a range\n     @@ builtin/history.c: out:\n      +\tstruct rev_info revs;\n      +\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n      +\tstruct strvec args = STRVEC_INIT;\n     ++\tsize_t i;\n      +\tint ret;\n      +\n      +\trepo_init_revisions(repo, &revs, NULL);\n     @@ builtin/history.c: out:\n      +\tstrvec_push(&args, \"--reverse\");\n      +\tstrvec_push(&args, \"--topo-order\");\n      +\tstrvec_push(&args, \"--boundary\");\n     ++\tstrvec_push(&args, \"--ancestry-path\");\n      +\tstrvec_push(&args, range);\n      +\tsetup_revisions_from_strvec(&args, &revs, NULL);\n      +\tif (args.nr != 1) {\n     @@ builtin/history.c: out:\n      +\t\tgoto out;\n      +\t}\n      +\n     ++\t/*\n     ++\t * A squash needs a base to reparent onto, so the argument has to\n     ++\t * exclude something, as in \"<base>..<tip>\". A single revision has no\n     ++\t * such bottom commit and cannot be squashed.\n     ++\t */\n     ++\tfor (i = 0; i < revs.cmdline.nr; i++)\n     ++\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n     ++\t\t\tbreak;\n     ++\tif (i == revs.cmdline.nr) {\n     ++\t\tret = error(_(\"'%s' is not a '<base>..<tip>' range\"), range);\n     ++\t\tgoto out;\n     ++\t}\n     ++\n      +\tif (prepare_revision_walk(&revs) < 0) {\n      +\t\tret = error(_(\"error preparing revisions\"));\n      +\t\tgoto out;\n     @@ builtin/history.c: out:\n      +\t\tgoto out;\n      +\t}\n      +\n     -+\tif (!base) {\n     -+\t\tret = error(_(\"cannot squash the root commit\"));\n     -+\t\tgoto out;\n     -+\t}\n     ++\tif (!base)\n     ++\t\tBUG(\"a non-empty range must have a boundary commit\");\n      +\n      +\t*base_out = base;\n      +\t*oldest_out = oldest;\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_grep \"the range .* is empty\" err\n      +'\n      +\n     -+test_expect_success 'errors when the range includes the root commit' '\n     ++test_expect_success 'errors on a single revision that is not a range' '\n      +\ttest_must_fail git history squash HEAD 2>err &&\n     -+\ttest_grep \"cannot squash the root commit\" err\n     ++\ttest_grep \"is not a .*range\" err &&\n     ++\ttest_must_fail git history squash HEAD~1 2>err &&\n     ++\ttest_grep \"is not a .*range\" err\n      +'\n      +\n      +test_expect_success 'squashes a range into a single commit without changing the tree' '\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_path_is_file inner\n      +'\n      +\n     ++test_expect_success 'folds a merge of a branch that forked at the base' '\n     ++\tgit reset --hard start &&\n     ++\tgit checkout -b base-fork-side &&\n     ++\ttest_commit --no-tag base-fork-side side x &&\n     ++\tgit checkout - &&\n     ++\ttest_commit --no-tag base-fork-main file b &&\n     ++\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n     ++\tgit branch -D base-fork-side &&\n     ++\ttest_commit --no-tag base-fork-tail file c &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash start.. &&\n     ++\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\ttest_cmp_rev start HEAD^ &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     ++\ttest_path_is_file side\n     ++'\n     ++\n      +test_expect_success 'folds a range whose tip is a merge commit' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag tipmerge-base file b &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp_rev \"$merged\" HEAD\n      +'\n      +\n     ++test_expect_success 'folds a range with two interior merges' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag two-merge-a file a1 &&\n     ++\tgit checkout -b two-merge-s1 &&\n     ++\ttest_commit --no-tag two-merge-s1 s1 x &&\n     ++\tgit checkout - &&\n     ++\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n     ++\ttest_commit --no-tag two-merge-b file b1 &&\n     ++\tgit checkout -b two-merge-s2 &&\n     ++\ttest_commit --no-tag two-merge-s2 s2 y &&\n     ++\tgit checkout - &&\n     ++\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n     ++\tgit branch -D two-merge-s1 two-merge-s2 &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash start.. &&\n     ++\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     ++\ttest_path_is_file s1 &&\n     ++\ttest_path_is_file s2\n     ++'\n     ++\n     ++test_expect_success 'folds a range with a nested merge' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\tgit checkout -b nested-outer &&\n     ++\ttest_commit --no-tag nested-outer outer x &&\n     ++\tgit checkout -b nested-inner &&\n     ++\ttest_commit --no-tag nested-inner inner y &&\n     ++\tgit checkout nested-outer &&\n     ++\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n     ++\tgit checkout \"$main\" &&\n     ++\ttest_commit --no-tag nested-main file b1 &&\n     ++\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n     ++\tgit branch -D nested-outer nested-inner &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash start.. &&\n     ++\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     ++\ttest_path_is_file outer &&\n     ++\ttest_path_is_file inner\n     ++'\n     ++\n     ++test_expect_success 'folds a range with an octopus merge' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\ttest_commit --no-tag octo-base file a1 &&\n     ++\tgit checkout -b octo-1 &&\n     ++\ttest_commit --no-tag octo-1 o1 x &&\n     ++\tgit checkout \"$main\" &&\n     ++\tgit checkout -b octo-2 &&\n     ++\ttest_commit --no-tag octo-2 o2 y &&\n     ++\tgit checkout \"$main\" &&\n     ++\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n     ++\tgit branch -D octo-1 octo-2 &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash start.. &&\n     ++\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     ++\ttest_path_is_file o1 &&\n     ++\ttest_path_is_file o2\n     ++'\n     ++\n     ++test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\tgit checkout -b octo-pre &&\n     ++\ttest_commit octo-pre-side pside x &&\n     ++\tgit checkout \"$main\" &&\n     ++\ttest_commit octo-pre-main file b1 &&\n     ++\tocto_base=$(git rev-parse HEAD) &&\n     ++\tgit checkout -b octo-within &&\n     ++\ttest_commit --no-tag octo-within wside y &&\n     ++\tgit checkout \"$main\" &&\n     ++\tgit merge --no-ff -m octopus octo-pre octo-within &&\n     ++\tmerged=$(git rev-parse HEAD) &&\n     ++\tgit branch -D octo-pre octo-within &&\n     ++\n     ++\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n     ++\ttest_grep \"more than one base\" err &&\n     ++\ttest_cmp_rev \"$merged\" HEAD\n     ++'\n     ++\n     ++test_expect_success 'refuses when a descendant above the range is a merge' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\ttest_commit --no-tag desc-base file b &&\n     ++\tgit tag desc-tip &&\n     ++\tgit checkout -b desc-above &&\n     ++\ttest_commit --no-tag desc-above above x &&\n     ++\tgit checkout \"$main\" &&\n     ++\ttest_commit --no-tag desc-main file c &&\n     ++\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n     ++\tgit branch -D desc-above &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash start..desc-tip 2>err &&\n     ++\ttest_grep \"merge commits is not supported\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD\n     ++'\n     ++\n     ++test_expect_success 'refuses to fold a range a ref points into at a merge' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\ttest_commit --no-tag refmerge-base file b &&\n     ++\tgit checkout -b refmerge-side &&\n     ++\ttest_commit --no-tag refmerge-side side x &&\n     ++\tgit checkout \"$main\" &&\n     ++\ttest_commit --no-tag refmerge-main file c &&\n     ++\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n     ++\tgit branch -D refmerge-side &&\n     ++\tgit branch at-merge HEAD &&\n     ++\ttest_commit --no-tag refmerge-tail file d &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash start.. 2>err &&\n     ++\ttest_grep \"at-merge\" err &&\n     ++\ttest_grep \"points into the squashed range\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\n     ++\tgit branch -D at-merge\n     ++'\n     ++\n      +test_done\n 4:  85c7817d7e = 4:  a758e1f084 history: re-edit a squash with every message\n\n-- \ngitgitgadget\n"},{"id":"546350","messageId":"0f1ae9b05abff4777edc5090c30f5cbd7af16a2e.1782338102.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com","subject":"[PATCH v5 1/4] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:54:59Z","receivedAt":"2026-06-24T21:55:05Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 091465a59e..f95f26e684 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -157,6 +157,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"546352","messageId":"a97ffab1e65ad59b07377a45a16d5c870361b8ad.1782338102.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com","subject":"[PATCH v5 2/4] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:00Z","receivedAt":"2026-06-24T21:55:06Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex f95f26e684..305bde3102 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -101,6 +101,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"546353","messageId":"04e18ef979f6113928b2aadfb1ebcf0dc23a6055.1782338102.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com","subject":"[PATCH v5 3/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:01Z","receivedAt":"2026-06-24T21:55:07Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into the oldest one, keeping that commit's\nmessage and authorship and taking the tree of the newest commit, so the\nrange collapses into a single commit. Commits above the range are\nreplayed on top of the result.\n\nThe range is given as <base>..<tip>, so \"git history squash @~3..\"\nfolds the three most recent commits and \"git history squash @~5..@~2\"\nsquashes an interior range. A merge inside the range is folded like any\nother commit, but the range must have a single base, so a range with\nmore than one entry point is rejected.\n\nThe folded commits leave the history, so by default the command refuses\nwhen another ref points at one of them. Use \"--update-refs=head\" to\nrewrite only the current branch and leave those refs untouched.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  25 ++\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 208 ++++++++++++++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 460 +++++++++++++++++++++++++++++++\n 7 files changed, 700 insertions(+)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..f4d692d136 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -55,6 +55,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 2ba8121795..6716749cde 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -11,6 +11,7 @@ SYNOPSIS\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n \n DESCRIPTION\n -----------\n@@ -97,6 +98,30 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's message and\n+\tauthorship and takes the tree of the range's newest commit, so the\n+\twhole range collapses into a single commit. Commits above the range\n+\tare replayed on top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash @~3..` folds the three most recent commits into one, and\n+`git history squash @~5..@~2` squashes an interior range while leaving\n+the two newest commits in place.\n++\n+The oldest commit's message and authorship are preserved by default,\n+unless you specify `--reedit-message`. A merge commit inside the range is\n+folded like any other, but the range must have a single base, so a range\n+that reaches more than one entry point (for example a side branch that\n+forked before the range and was later merged into it) is rejected.\n++\n+The folded commits disappear from the history, so with the default\n+`--update-refs=branches` the command refuses when another ref points at\n+one of them. Rerun with `--update-refs=head` to rewrite only the current\n+branch and leave those refs pointing at the old commits.\n+\n OPTIONS\n -------\n \ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..5c6ff95e31 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 8def280688..911b4e4643 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 305bde3102..0acfabed66 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -30,6 +31,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -973,6 +976,209 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char *range,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tstruct oidset *interior_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct strvec args = STRVEC_INIT;\n+\tsize_t i;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_push(&args, \"--boundary\");\n+\tstrvec_push(&args, \"--ancestry-path\");\n+\tstrvec_push(&args, range);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"'%s' does not name a revision range\"), range);\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the argument has to\n+\t * exclude something, as in \"<base>..<tip>\". A single revision has no\n+\t * such bottom commit and cannot be squashed.\n+\t */\n+\tfor (i = 0; i < revs.cmdline.nr; i++)\n+\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n+\t\t\tbreak;\n+\tif (i == revs.cmdline.nr) {\n+\t\tret = error(_(\"'%s' is not a '<base>..<tip>' range\"), range);\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tif (base) {\n+\t\t\t\tret = error(_(\"range '%s' has more than one base; \"\n+\t\t\t\t\t      \"cannot squash\"), range);\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tbase = commit;\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\tif (tip)\n+\t\t\toidset_insert(interior_out, &tip->object.oid);\n+\t\ttip = commit;\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the range '%s' is empty\"), range);\n+\t\tgoto out;\n+\t}\n+\n+\tif (!base)\n+\t\tBUG(\"a non-empty range must have a boundary commit\");\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+struct interior_ref_cb {\n+\tconst struct oidset *interior;\n+\tconst char *name;\n+};\n+\n+static int find_interior_ref(const struct reference *ref, void *cb_data)\n+{\n+\tstruct interior_ref_cb *data = cb_data;\n+\n+\tif (oidset_contains(data->interior, ref->oid)) {\n+\t\tdata->name = xstrdup(ref->name);\n+\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct oidset interior = OIDSET_INIT;\n+\tstruct commit *base, *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage, 0);\n+\tif (argc != 1) {\n+\t\tret = error(_(\"command expects a single revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip,\n+\t\t\t\t   &interior);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tif (action == REF_ACTION_BRANCHES) {\n+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n+\n+\t\trefs_for_each_ref(get_main_ref_store(repo),\n+\t\t\t\t  find_interior_ref, &cb);\n+\t\tif (cb.name) {\n+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n+\t\t\t\t    cb.name);\n+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t\t    \"untouched.\"));\n+\t\t\tfree((char *)cb.name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\toidset_clear(&interior);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n int cmd_history(int argc,\n \t\tconst char **argv,\n \t\tconst char *prefix,\n@@ -982,6 +1188,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -989,6 +1196,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex 3219264fe7..63ea26b8ed 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -399,6 +399,7 @@ integration_tests = [\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..7227c5c90f\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,460 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\ttest_commit --no-tag one other x &&\n+\ttest_commit --no-tag two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"command expects a single revision range\" err\n+'\n+\n+test_expect_success 'errors on too many arguments' '\n+\ttest_must_fail git history squash start.. HEAD 2>err &&\n+\ttest_grep \"command expects a single revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the range .* is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"is not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"is not a .*range\" err\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo one >expect &&\n+\ttest_cmp expect subject &&\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$root..\" &&\n+\n+\tgit rev-list --count \"$root..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashing a single-commit range replays the rest' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~2 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'reuses the message of a fixup! commit in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag reg1 file b &&\n+\tgit commit --allow-empty -m \"fixup! reg1\" &&\n+\ttest_commit reg2 file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo reg1 >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'keeps the oldest message even if it is a fixup!' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag \"fixup! something\" file b &&\n+\ttest_commit tail file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo \"fixup! something\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'preserves authorship of the oldest commit' '\n+\tgit reset --hard start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit --no-tag oldest file b &&\n+\ttest_commit newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--dry-run predicts the rewrite without performing it' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"error: .* points into the squashed range\" err &&\n+\ttest_grep \"hint: .*--update-refs=head\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\tmid_before=$(git rev-parse mid) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'refuses to fold a range a tag points into' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"refs/tags/mark\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit tag -d mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout - &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo before-side >expect &&\n+\ttest_cmp expect subject &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout - &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout - &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout - &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$base..\" &&\n+\n+\tgit rev-list --count \"$base..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'refuses to squash a range with more than one base' '\n+\tgit reset --hard start &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b forked-before &&\n+\ttest_commit forked-side fside x &&\n+\tgit checkout - &&\n+\ttest_commit forked-main file b &&\n+\tgit merge --no-ff -m merge forked-before &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash forked-main.. 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout - &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout - &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-base file b &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file c &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"at-merge\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"546354","messageId":"a758e1f08403893e72e606174d02278ecf9b829b.1782338102.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com","subject":"[PATCH v5 4/4] history: re-edit a squash with every message","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:02Z","receivedAt":"2026-06-24T21:55:08Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBy default \"git history squash\" reuses the oldest commit's message.\nWhen --reedit-message is given it only reopened that one message, so the\nmessages of the folded-in commits were lost.\n\nGather the messages of every commit in the range, oldest first, and use\nthem as the editor template when re-editing, mirroring how \"git rebase\n-i\" presents a squash. The combined message is built before the\ndescendant walk so it is not disturbed by the flags that walk leaves on\nthe commits.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  5 +--\n builtin/history.c              | 61 +++++++++++++++++++++++++++++++++-\n t/t3455-history-squash.sh      | 37 +++++++++++++++++++++\n 3 files changed, 100 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 6716749cde..df389015aa 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -111,8 +111,9 @@ history squash @~3..` folds the three most recent commits into one, and\n `git history squash @~5..@~2` squashes an interior range while leaving\n the two newest commits in place.\n +\n-The oldest commit's message and authorship are preserved by default,\n-unless you specify `--reedit-message`. A merge commit inside the range is\n+The oldest commit's message and authorship are preserved by default. With\n+`--reedit-message`, an editor opens pre-filled with the messages of all the\n+folded commits so you can combine them. A merge commit inside the range is\n folded like any other, but the range must have a single base, so a range\n that reaches more than one entry point (for example a side branch that\n forked before the range and was later merged into it) is rejected.\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 0acfabed66..e93f8398e6 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1081,6 +1081,56 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tint n = 0, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tconst char *message, *body;\n+\t\tstruct strbuf one = STRBUF_INIT;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\t\tstrbuf_addstr(&one, body);\n+\t\tstrbuf_trim_trailing_newline(&one);\n+\n+\t\tif (n++)\n+\t\t\tstrbuf_addch(out, '\\n');\n+\t\tstrbuf_addbuf(out, &one);\n+\t\tstrbuf_addch(out, '\\n');\n+\n+\t\tstrbuf_release(&one);\n+\t\trepo_unuse_commit_buffer(repo, commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1105,6 +1155,7 @@ static int cmd_history_squash(int argc,\n \t\tOPT_END(),\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n \tstruct oidset interior = OIDSET_INIT;\n \tstruct commit *base, *oldest, *tip, *rewritten;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n@@ -1144,6 +1195,12 @@ static int cmd_history_squash(int argc,\n \t\t}\n \t}\n \n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n+\n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1152,7 +1209,8 @@ static int cmd_history_squash(int argc,\n \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n \tcommit_list_append(base, &parents);\n \n-\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\tret = commit_tree_ext(repo, \"squash\", oldest,\n+\t\t\t      message.len ? message.buf : NULL, parents,\n \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n@@ -1173,6 +1231,7 @@ static int cmd_history_squash(int argc,\n \n out:\n \tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n \toidset_clear(&interior);\n \tcommit_list_free(parents);\n \trelease_revisions(&revs);\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 7227c5c90f..af59ddf6e3 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -137,6 +137,43 @@ test_expect_success 'preserves authorship of the oldest commit' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--reedit-message offers every folded-in message' '\n+\tgit reset --hard start &&\n+\techo b >file &&\n+\tgit add file &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcp \"$1\" buffer &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\ttest_grep \"re-one subject\" buffer &&\n+\ttest_grep \"re-one body line\" buffer &&\n+\ttest_grep re-two buffer &&\n+\ttest_grep re-three buffer &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo combined >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--reedit-message aborts on an empty message' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\ttest_must_fail git history squash --reedit-message start.. &&\n+\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n test_expect_success '--dry-run predicts the rewrite without performing it' '\n \tgit reset --hard three &&\n \thead_before=$(git rev-parse HEAD) &&\n-- \ngitgitgadget\n"},{"id":"546451","messageId":"d37e8f4f-d1f9-45aa-8c95-ebe676d54671@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-26T08:52:57Z","receivedAt":"2026-06-26T08:53:03Z","isPatch":true,"body":"Hi Harald\n\nOn 24/06/2026 22:54, Harald Nordgren via GitGitGadget wrote:\n> Adds git history squash <revision-range> to fold a range of commits.\n\nIt would be helpful to give a bit more detail here about the command so \nthat the reader has an overview of what is actually being implemented.\n\n  - what does it do with fixup!, squash! and amend! commits? Can it use\n    the message from amend! commits to reword the commit?\n  - can the user reword the commit message?\n  - what happens if a merge commit inside the range has a parent outside\n    the range?\n  - what happens to branches that point to commits inside the range?\n\nI had a quick play and found that it accepts ranges that containing a \nsingle commit (e.g. @^!) where there is nothing to squash. It also \naccepts ranges that are not ancestors of HEAD (e.g. checkout master and \nrun \"git history squash --dry-run origin/seen^2^!\") without printing an \nerror message. Only accepting a single argument is quite limiting as one \ncannot say\n\n\tgit history squash ^:/base :/tip\n\nThanks\n\nPhillip\n\n\n> Changes in v5:\n> \n>   * The range walk now uses --ancestry-path, so only commits descended from\n>     the base are folded; a single revision such as HEAD or HEAD~1 is now\n>     rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n>     down to the root.\n>   * This adopts the --ancestry-path suggestion; the multi-base rejection is\n>     unchanged, so a side branch that forked before the base and merged in is\n>     still refused.\n>   * Added tests covering more merge topologies: two interior merges, a nested\n>     merge, an octopus merge, an octopus arm forked before the base, a merge\n>     among the descendants replayed above the range, and a ref pointing at an\n>     interior merge commit.\n> \n> Changes in v4:\n> \n>   * git history squash now detects when another ref points at a commit inside\n>     the range being folded and refuses, with an advice.historyUpdateRefs hint\n>     to use --update-refs=head.\n>   * A merge inside the range is folded fine as long as the range has a single\n>     base; a range with merge commit at the tip or base also folds correctly.\n>     Only a range with more than one base is rejected.\n> \n> Changes in v3:\n> \n>   * Moved the feature out of git rebase and into a new git history squash\n>     <revision-range> subcommand, per the list discussion. git rebase --squash\n>     is dropped.\n>   * Takes an arbitrary range (git history squash @~3.., git history squash\n>     @~5..@~2), folding it into the oldest commit and replaying any\n>     descendants on top.\n>   * Implemented as a single tree operation rather than picking each commit,\n>     so there are no repeated conflict stops (addresses Phillip's efficiency\n>     point).\n>   * A merge inside the range is folded fine, only a range with more than one\n>     base is rejected.\n>   * --reedit-message seeds the editor with every folded-in message, not just\n>     the oldest.\n> \n> Harald Nordgren (4):\n>    history: extract helper for a commit's parent tree\n>    history: give commit_tree_ext a message template\n>    history: add squash subcommand to fold a range\n>    history: re-edit a squash with every message\n> \n>   Documentation/config/advice.adoc |   4 +\n>   Documentation/git-history.adoc   |  26 ++\n>   advice.c                         |   1 +\n>   advice.h                         |   1 +\n>   builtin/history.c                | 341 ++++++++++++++++++---\n>   t/meson.build                    |   1 +\n>   t/t3455-history-squash.sh        | 497 +++++++++++++++++++++++++++++++\n>   7 files changed, 833 insertions(+), 38 deletions(-)\n>   create mode 100755 t/t3455-history-squash.sh\n> \n> \n> base-commit: 26d8d94e94df5535eecd036f16627493506a0614\n> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v5\n> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v5\n> Pull-Request: https://github.com/git/git/pull/2337\n> \n> Range-diff vs v4:\n> \n>   1:  fc2801c0b1 = 1:  0f1ae9b05a history: extract helper for a commit's parent tree\n>   2:  ee591e83b4 = 2:  a97ffab1e6 history: give commit_tree_ext a message template\n>   3:  80bfea642e ! 3:  04e18ef979 history: add squash subcommand to fold a range\n>       @@ builtin/history.c: out:\n>        +\tstruct rev_info revs;\n>        +\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n>        +\tstruct strvec args = STRVEC_INIT;\n>       ++\tsize_t i;\n>        +\tint ret;\n>        +\n>        +\trepo_init_revisions(repo, &revs, NULL);\n>       @@ builtin/history.c: out:\n>        +\tstrvec_push(&args, \"--reverse\");\n>        +\tstrvec_push(&args, \"--topo-order\");\n>        +\tstrvec_push(&args, \"--boundary\");\n>       ++\tstrvec_push(&args, \"--ancestry-path\");\n>        +\tstrvec_push(&args, range);\n>        +\tsetup_revisions_from_strvec(&args, &revs, NULL);\n>        +\tif (args.nr != 1) {\n>       @@ builtin/history.c: out:\n>        +\t\tgoto out;\n>        +\t}\n>        +\n>       ++\t/*\n>       ++\t * A squash needs a base to reparent onto, so the argument has to\n>       ++\t * exclude something, as in \"<base>..<tip>\". A single revision has no\n>       ++\t * such bottom commit and cannot be squashed.\n>       ++\t */\n>       ++\tfor (i = 0; i < revs.cmdline.nr; i++)\n>       ++\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n>       ++\t\t\tbreak;\n>       ++\tif (i == revs.cmdline.nr) {\n>       ++\t\tret = error(_(\"'%s' is not a '<base>..<tip>' range\"), range);\n>       ++\t\tgoto out;\n>       ++\t}\n>       ++\n>        +\tif (prepare_revision_walk(&revs) < 0) {\n>        +\t\tret = error(_(\"error preparing revisions\"));\n>        +\t\tgoto out;\n>       @@ builtin/history.c: out:\n>        +\t\tgoto out;\n>        +\t}\n>        +\n>       -+\tif (!base) {\n>       -+\t\tret = error(_(\"cannot squash the root commit\"));\n>       -+\t\tgoto out;\n>       -+\t}\n>       ++\tif (!base)\n>       ++\t\tBUG(\"a non-empty range must have a boundary commit\");\n>        +\n>        +\t*base_out = base;\n>        +\t*oldest_out = oldest;\n>       @@ t/t3455-history-squash.sh (new)\n>        +\ttest_grep \"the range .* is empty\" err\n>        +'\n>        +\n>       -+test_expect_success 'errors when the range includes the root commit' '\n>       ++test_expect_success 'errors on a single revision that is not a range' '\n>        +\ttest_must_fail git history squash HEAD 2>err &&\n>       -+\ttest_grep \"cannot squash the root commit\" err\n>       ++\ttest_grep \"is not a .*range\" err &&\n>       ++\ttest_must_fail git history squash HEAD~1 2>err &&\n>       ++\ttest_grep \"is not a .*range\" err\n>        +'\n>        +\n>        +test_expect_success 'squashes a range into a single commit without changing the tree' '\n>       @@ t/t3455-history-squash.sh (new)\n>        +\ttest_path_is_file inner\n>        +'\n>        +\n>       ++test_expect_success 'folds a merge of a branch that forked at the base' '\n>       ++\tgit reset --hard start &&\n>       ++\tgit checkout -b base-fork-side &&\n>       ++\ttest_commit --no-tag base-fork-side side x &&\n>       ++\tgit checkout - &&\n>       ++\ttest_commit --no-tag base-fork-main file b &&\n>       ++\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n>       ++\tgit branch -D base-fork-side &&\n>       ++\ttest_commit --no-tag base-fork-tail file c &&\n>       ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n>       ++\n>       ++\tgit history squash start.. &&\n>       ++\n>       ++\tgit rev-list --count start..HEAD >count &&\n>       ++\techo 1 >expect &&\n>       ++\ttest_cmp expect count &&\n>       ++\ttest_cmp_rev start HEAD^ &&\n>       ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n>       ++\ttest_path_is_file side\n>       ++'\n>       ++\n>        +test_expect_success 'folds a range whose tip is a merge commit' '\n>        +\tgit reset --hard start &&\n>        +\ttest_commit --no-tag tipmerge-base file b &&\n>       @@ t/t3455-history-squash.sh (new)\n>        +\ttest_cmp_rev \"$merged\" HEAD\n>        +'\n>        +\n>       ++test_expect_success 'folds a range with two interior merges' '\n>       ++\tgit reset --hard start &&\n>       ++\ttest_commit --no-tag two-merge-a file a1 &&\n>       ++\tgit checkout -b two-merge-s1 &&\n>       ++\ttest_commit --no-tag two-merge-s1 s1 x &&\n>       ++\tgit checkout - &&\n>       ++\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n>       ++\ttest_commit --no-tag two-merge-b file b1 &&\n>       ++\tgit checkout -b two-merge-s2 &&\n>       ++\ttest_commit --no-tag two-merge-s2 s2 y &&\n>       ++\tgit checkout - &&\n>       ++\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n>       ++\tgit branch -D two-merge-s1 two-merge-s2 &&\n>       ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n>       ++\n>       ++\tgit history squash start.. &&\n>       ++\n>       ++\tgit rev-list --count start..HEAD >count &&\n>       ++\techo 1 >expect &&\n>       ++\ttest_cmp expect count &&\n>       ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n>       ++\ttest_path_is_file s1 &&\n>       ++\ttest_path_is_file s2\n>       ++'\n>       ++\n>       ++test_expect_success 'folds a range with a nested merge' '\n>       ++\tgit reset --hard start &&\n>       ++\tmain=$(git symbolic-ref --short HEAD) &&\n>       ++\tgit checkout -b nested-outer &&\n>       ++\ttest_commit --no-tag nested-outer outer x &&\n>       ++\tgit checkout -b nested-inner &&\n>       ++\ttest_commit --no-tag nested-inner inner y &&\n>       ++\tgit checkout nested-outer &&\n>       ++\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n>       ++\tgit checkout \"$main\" &&\n>       ++\ttest_commit --no-tag nested-main file b1 &&\n>       ++\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n>       ++\tgit branch -D nested-outer nested-inner &&\n>       ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n>       ++\n>       ++\tgit history squash start.. &&\n>       ++\n>       ++\tgit rev-list --count start..HEAD >count &&\n>       ++\techo 1 >expect &&\n>       ++\ttest_cmp expect count &&\n>       ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n>       ++\ttest_path_is_file outer &&\n>       ++\ttest_path_is_file inner\n>       ++'\n>       ++\n>       ++test_expect_success 'folds a range with an octopus merge' '\n>       ++\tgit reset --hard start &&\n>       ++\tmain=$(git symbolic-ref --short HEAD) &&\n>       ++\ttest_commit --no-tag octo-base file a1 &&\n>       ++\tgit checkout -b octo-1 &&\n>       ++\ttest_commit --no-tag octo-1 o1 x &&\n>       ++\tgit checkout \"$main\" &&\n>       ++\tgit checkout -b octo-2 &&\n>       ++\ttest_commit --no-tag octo-2 o2 y &&\n>       ++\tgit checkout \"$main\" &&\n>       ++\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n>       ++\tgit branch -D octo-1 octo-2 &&\n>       ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n>       ++\n>       ++\tgit history squash start.. &&\n>       ++\n>       ++\tgit rev-list --count start..HEAD >count &&\n>       ++\techo 1 >expect &&\n>       ++\ttest_cmp expect count &&\n>       ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n>       ++\ttest_path_is_file o1 &&\n>       ++\ttest_path_is_file o2\n>       ++'\n>       ++\n>       ++test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n>       ++\tgit reset --hard start &&\n>       ++\tmain=$(git symbolic-ref --short HEAD) &&\n>       ++\tgit checkout -b octo-pre &&\n>       ++\ttest_commit octo-pre-side pside x &&\n>       ++\tgit checkout \"$main\" &&\n>       ++\ttest_commit octo-pre-main file b1 &&\n>       ++\tocto_base=$(git rev-parse HEAD) &&\n>       ++\tgit checkout -b octo-within &&\n>       ++\ttest_commit --no-tag octo-within wside y &&\n>       ++\tgit checkout \"$main\" &&\n>       ++\tgit merge --no-ff -m octopus octo-pre octo-within &&\n>       ++\tmerged=$(git rev-parse HEAD) &&\n>       ++\tgit branch -D octo-pre octo-within &&\n>       ++\n>       ++\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n>       ++\ttest_grep \"more than one base\" err &&\n>       ++\ttest_cmp_rev \"$merged\" HEAD\n>       ++'\n>       ++\n>       ++test_expect_success 'refuses when a descendant above the range is a merge' '\n>       ++\tgit reset --hard start &&\n>       ++\tmain=$(git symbolic-ref --short HEAD) &&\n>       ++\ttest_commit --no-tag desc-base file b &&\n>       ++\tgit tag desc-tip &&\n>       ++\tgit checkout -b desc-above &&\n>       ++\ttest_commit --no-tag desc-above above x &&\n>       ++\tgit checkout \"$main\" &&\n>       ++\ttest_commit --no-tag desc-main file c &&\n>       ++\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n>       ++\tgit branch -D desc-above &&\n>       ++\thead_before=$(git rev-parse HEAD) &&\n>       ++\n>       ++\ttest_must_fail git history squash start..desc-tip 2>err &&\n>       ++\ttest_grep \"merge commits is not supported\" err &&\n>       ++\ttest_cmp_rev \"$head_before\" HEAD\n>       ++'\n>       ++\n>       ++test_expect_success 'refuses to fold a range a ref points into at a merge' '\n>       ++\tgit reset --hard start &&\n>       ++\tmain=$(git symbolic-ref --short HEAD) &&\n>       ++\ttest_commit --no-tag refmerge-base file b &&\n>       ++\tgit checkout -b refmerge-side &&\n>       ++\ttest_commit --no-tag refmerge-side side x &&\n>       ++\tgit checkout \"$main\" &&\n>       ++\ttest_commit --no-tag refmerge-main file c &&\n>       ++\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n>       ++\tgit branch -D refmerge-side &&\n>       ++\tgit branch at-merge HEAD &&\n>       ++\ttest_commit --no-tag refmerge-tail file d &&\n>       ++\thead_before=$(git rev-parse HEAD) &&\n>       ++\n>       ++\ttest_must_fail git history squash start.. 2>err &&\n>       ++\ttest_grep \"at-merge\" err &&\n>       ++\ttest_grep \"points into the squashed range\" err &&\n>       ++\ttest_cmp_rev \"$head_before\" HEAD &&\n>       ++\n>       ++\tgit branch -D at-merge\n>       ++'\n>       ++\n>        +test_done\n>   4:  85c7817d7e = 4:  a758e1f084 history: re-edit a squash with every message\n> \n\n"},{"id":"546452","messageId":"CAHwyqnWXaG1HGunztVgUdWnVogqCHRbxh8pcS5fGA6f3mB-nEA@mail.gmail.com","threadId":"65812","inReplyTo":"d37e8f4f-d1f9-45aa-8c95-ebe676d54671@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-26T09:57:49Z","receivedAt":"2026-06-26T09:58:27Z","isPatch":true,"body":"On Fri, Jun 26, 2026 at 10:53 AM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>\n> Hi Harald\n>\n> On 24/06/2026 22:54, Harald Nordgren via GitGitGadget wrote:\n> > Adds git history squash <revision-range> to fold a range of commits.\n>\n> It would be helpful to give a bit more detail here about the command so\n> that the reader has an overview of what is actually being implemented.\n>\n>   - what does it do with fixup!, squash! and amend! commits? Can it use\n>     the message from amend! commits to reword the commit?\n>   - can the user reword the commit message?\n>   - what happens if a merge commit inside the range has a parent outside\n>     the range?\n>   - what happens to branches that point to commits inside the range?\n>\n> I had a quick play and found that it accepts ranges that containing a\n> single commit (e.g. @^!) where there is nothing to squash. It also\n> accepts ranges that are not ancestors of HEAD (e.g. checkout master and\n> run \"git history squash --dry-run origin/seen^2^!\") without printing an\n> error message.\n\nGood points, I will take a look at clarifying or giving an error (like\nin the case of ancestor not in history of HEAD).\n\nOnly accepting a single argument is quite limiting as one\n> cannot say\n>\n>         git history squash ^:/base :/tip\n\nI don't understand why this is limiting? It thought it was clear that\nit should be one argument REF1..REF2 ? What does '^:/base :/tip'\nachieve that '^:/base..:/tip' cannot?\n\n\nHarald\n"},{"id":"546468","messageId":"4654a3f1-bf79-4c3f-b121-16bb3ab25f07@gmail.com","threadId":"65812","inReplyTo":"CAHwyqnWXaG1HGunztVgUdWnVogqCHRbxh8pcS5fGA6f3mB-nEA@mail.gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-26T13:12:41Z","receivedAt":"2026-06-26T13:12:48Z","isPatch":true,"body":"On 26/06/2026 10:57, Harald Nordgren wrote:\n> On Fri, Jun 26, 2026 at 10:53 AM Phillip Wood <phillip.wood123@gmail.com> wrote:>> >> Only accepting a single argument is quite limiting as one\n>> cannot say\n>>\n>>          git history squash ^:/base :/tip\n> \n> I don't understand why this is limiting? It thought it was clear that\n> it should be one argument REF1..REF2 ? What does '^:/base :/tip'\n> achieve that '^:/base..:/tip' cannot?\n\n'^/:base..:/tip' is not a range - everything after the first '/:' is \ntreated as a regular expression to search for.\n\nThanks\n\nPhillip\n"},{"id":"546470","messageId":"xmqqh5mpcsc0.fsf@gitster.g","threadId":"65812","inReplyTo":"4654a3f1-bf79-4c3f-b121-16bb3ab25f07@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-26T14:02:55Z","receivedAt":"2026-06-26T14:02:58Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> On 26/06/2026 10:57, Harald Nordgren wrote:\n>> On Fri, Jun 26, 2026 at 10:53 AM Phillip Wood <phillip.wood123@gmail.com> wrote:>> >> Only accepting a single argument is quite limiting as one\n>>> cannot say\n>>>\n>>>          git history squash ^:/base :/tip\n>> \n>> I don't understand why this is limiting? It thought it was clear that\n>> it should be one argument REF1..REF2 ? What does '^:/base :/tip'\n>> achieve that '^:/base..:/tip' cannot?\n>\n> '^/:base..:/tip' is not a range - everything after the first '/:' is \n> treated as a regular expression to search for.\n\nThis particular case you can do\n\n\tHEAD^{/base}..HEAD^{/tip}\n\n(or even go \"HEAD^{/tip}~43\" and fancier other forms, the point\nbeing with matching {} pair, you can do more than what the lazy\nshort-hand form can).\n\nBut I think your point still stands, I think, as\n\n\tgit history squash HEAD^{/base}..:/tip ^main\n\nmay be something you would want to do to express additional\nconstraints, like \"I want this range squashed, but I should never\never touch what is already in main\".\n"},{"id":"546496","messageId":"CAHwyqnWvCvtenp5_nOGc0Kf-_kOfLm5M4HzB5x2i-R9QQ4E9AA@mail.gmail.com","threadId":"65812","inReplyTo":"xmqqh5mpcsc0.fsf@gitster.g","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-26T18:36:38Z","receivedAt":"2026-06-26T18:37:17Z","isPatch":true,"body":"Interesting, I will take a look at handling multiple args.\n\n\nHarald\n"},{"id":"546588","messageId":"pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com","subject":"[PATCH v6 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-28T08:29:05Z","receivedAt":"2026-06-28T08:29:12Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (4):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  history: add squash subcommand to fold a range\n  history: re-edit a squash with every message\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  29 ++\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 357 +++++++++++++++++---\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 550 +++++++++++++++++++++++++++++++\n 7 files changed, 905 insertions(+), 38 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 6c3d7b73556db708feb3b16232fab1efc4353428\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v6\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v6\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v5:\n\n 1:  0f1ae9b05a = 1:  fea6b79e60 history: extract helper for a commit's parent tree\n 2:  a97ffab1e6 = 2:  e2674e0bc4 history: give commit_tree_ext a message template\n 3:  04e18ef979 ! 3:  811e393ab4 history: add squash subcommand to fold a range\n     @@ Commit message\n      \n          Add \"git history squash <revision-range>\" to do this directly. It folds\n          every commit in the range into the oldest one, keeping that commit's\n     -    message and authorship and taking the tree of the newest commit, so the\n     -    range collapses into a single commit. Commits above the range are\n     -    replayed on top of the result.\n     +    message and authorship and taking the tree of the newest commit, then\n     +    replays the commits above the range on top. fixup!, squash! and amend!\n     +    commits are folded like any other and are not interpreted, so the\n     +    squashed message comes from the oldest commit, or from an editor with\n     +    --reedit-message.\n      \n     -    The range is given as <base>..<tip>, so \"git history squash @~3..\"\n     -    folds the three most recent commits and \"git history squash @~5..@~2\"\n     -    squashes an interior range. A merge inside the range is folded like any\n     -    other commit, but the range must have a single base, so a range with\n     -    more than one entry point is rejected.\n     -\n     -    The folded commits leave the history, so by default the command refuses\n     -    when another ref points at one of them. Use \"--update-refs=head\" to\n     -    rewrite only the current branch and leave those refs untouched.\n     +    The range is read like the arguments to \"git rev-list\", so several\n     +    arguments such as \"@~3.. ^topic\" are allowed. A merge inside the range\n     +    is folded when its other parent is reachable from the base, otherwise\n     +    the range has more than one base and is rejected. By default the command\n     +    also refuses when a ref points at a commit that the fold would discard.\n     +    Use --update-refs=head to rewrite only the current branch instead.\n      \n          Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +the commit just below the oldest commit to squash. For example, `git\n      +history squash @~3..` folds the three most recent commits into one, and\n      +`git history squash @~5..@~2` squashes an interior range while leaving\n     -+the two newest commits in place.\n     ++the two newest commits in place. _<revision-range>_ is read like the\n     ++arguments to linkgit:git-rev-list[1], so several arguments may be given,\n     ++for example `@~3.. ^topic` to additionally exclude what is already on\n     ++`topic`.\n      ++\n      +The oldest commit's message and authorship are preserved by default,\n      +unless you specify `--reedit-message`. A merge commit inside the range is\n     @@ builtin/history.c: out:\n      + * but the range must have a single base and must not reach a root commit.\n      + */\n      +static int resolve_squash_range(struct repository *repo,\n     -+\t\t\t\tconst char *range,\n     ++\t\t\t\tconst char **argv,\n      +\t\t\t\tstruct commit **base_out,\n      +\t\t\t\tstruct commit **oldest_out,\n      +\t\t\t\tstruct commit **tip_out,\n     @@ builtin/history.c: out:\n      +{\n      +\tstruct rev_info revs;\n      +\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n     ++\tstruct commit_list *boundaries = NULL, *b;\n      +\tstruct strvec args = STRVEC_INIT;\n      +\tsize_t i;\n      +\tint ret;\n     @@ builtin/history.c: out:\n      +\tstrvec_push(&args, \"--topo-order\");\n      +\tstrvec_push(&args, \"--boundary\");\n      +\tstrvec_push(&args, \"--ancestry-path\");\n     -+\tstrvec_push(&args, range);\n     ++\tstrvec_pushv(&args, argv);\n      +\tsetup_revisions_from_strvec(&args, &revs, NULL);\n      +\tif (args.nr != 1) {\n     -+\t\tret = error(_(\"'%s' does not name a revision range\"), range);\n     ++\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n      +\t\tgoto out;\n      +\t}\n      +\n      +\t/*\n     -+\t * A squash needs a base to reparent onto, so the argument has to\n     -+\t * exclude something, as in \"<base>..<tip>\". A single revision has no\n     -+\t * such bottom commit and cannot be squashed.\n     ++\t * A squash needs a base to reparent onto, so the range has to exclude\n     ++\t * something, as in \"<base>..<tip>\". A revision range with no such\n     ++\t * bottom commit cannot be squashed.\n      +\t */\n      +\tfor (i = 0; i < revs.cmdline.nr; i++)\n      +\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n      +\t\t\tbreak;\n      +\tif (i == revs.cmdline.nr) {\n     -+\t\tret = error(_(\"'%s' is not a '<base>..<tip>' range\"), range);\n     ++\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n      +\t\tgoto out;\n      +\t}\n      +\n     @@ builtin/history.c: out:\n      +\n      +\twhile ((commit = get_revision(&revs))) {\n      +\t\tif (commit->object.flags & BOUNDARY) {\n     -+\t\t\tif (base) {\n     -+\t\t\t\tret = error(_(\"range '%s' has more than one base; \"\n     -+\t\t\t\t\t      \"cannot squash\"), range);\n     -+\t\t\t\tgoto out;\n     -+\t\t\t}\n     -+\t\t\tbase = commit;\n     ++\t\t\tcommit_list_insert(commit, &boundaries);\n      +\t\t\tcontinue;\n      +\t\t}\n      +\t\tif (!oldest)\n     @@ builtin/history.c: out:\n      +\t}\n      +\n      +\tif (!oldest) {\n     -+\t\tret = error(_(\"the range '%s' is empty\"), range);\n     ++\t\tret = error(_(\"the revision range is empty\"));\n     ++\t\tgoto out;\n     ++\t}\n     ++\n     ++\tif (oldest == tip) {\n     ++\t\tret = error(_(\"the revision range holds a single commit; \"\n     ++\t\t\t      \"nothing to squash\"));\n      +\t\tgoto out;\n      +\t}\n      +\n     -+\tif (!base)\n     -+\t\tBUG(\"a non-empty range must have a boundary commit\");\n     ++\tif (!oldest->parents)\n     ++\t\tBUG(\"an in-range commit must have a parent\");\n     ++\tbase = oldest->parents->item;\n     ++\n     ++\t/*\n     ++\t * A boundary other than the base is an in-range commit reaching a\n     ++\t * commit outside the range, so the range has more than one base.\n     ++\t */\n     ++\tfor (b = boundaries; b; b = b->next) {\n     ++\t\tif (b->item != base) {\n     ++\t\t\tret = error(_(\"the revision range has more than one base; \"\n     ++\t\t\t\t      \"cannot squash\"));\n     ++\t\t\tgoto out;\n     ++\t\t}\n     ++\t}\n      +\n      +\t*base_out = base;\n      +\t*oldest_out = oldest;\n     @@ builtin/history.c: out:\n      +\tret = 0;\n      +\n      +out:\n     ++\tcommit_list_free(boundaries);\n      +\treset_revision_walk();\n      +\trelease_revisions(&revs);\n      +\tstrvec_clear(&args);\n     @@ builtin/history.c: out:\n      +\tint ret;\n      +\n      +\targc = parse_options(argc, argv, prefix, options, usage, 0);\n     -+\tif (argc != 1) {\n     -+\t\tret = error(_(\"command expects a single revision range\"));\n     ++\tif (!argc) {\n     ++\t\tret = error(_(\"command expects a revision range\"));\n      +\t\tgoto out;\n      +\t}\n      +\trepo_config(repo, git_default_config, NULL);\n     @@ builtin/history.c: out:\n      +\tif (action == REF_ACTION_DEFAULT)\n      +\t\taction = REF_ACTION_BRANCHES;\n      +\n     -+\tret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip,\n     ++\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n      +\t\t\t\t   &interior);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +test_expect_success 'errors on missing range argument' '\n      +\ttest_must_fail git history squash 2>err &&\n     -+\ttest_grep \"command expects a single revision range\" err\n     -+'\n     -+\n     -+test_expect_success 'errors on too many arguments' '\n     -+\ttest_must_fail git history squash start.. HEAD 2>err &&\n     -+\ttest_grep \"command expects a single revision range\" err\n     ++\ttest_grep \"expects a revision range\" err\n      +'\n      +\n      +test_expect_success 'errors on an empty range' '\n      +\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n     -+\ttest_grep \"the range .* is empty\" err\n     ++\ttest_grep \"the revision range is empty\" err\n      +'\n      +\n      +test_expect_success 'errors on a single revision that is not a range' '\n      +\ttest_must_fail git history squash HEAD 2>err &&\n     -+\ttest_grep \"is not a .*range\" err &&\n     ++\ttest_grep \"not a .*range\" err &&\n      +\ttest_must_fail git history squash HEAD~1 2>err &&\n     -+\ttest_grep \"is not a .*range\" err\n     ++\ttest_grep \"not a .*range\" err\n     ++'\n     ++\n     ++test_expect_success 'errors on a range holding a single commit' '\n     ++\tgit reset --hard three &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n     ++\ttest_grep \"single commit; nothing to squash\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD\n     ++'\n     ++\n     ++test_expect_success 'accepts multiple revision arguments with an exclusion' '\n     ++\tgit reset --hard three &&\n     ++\tgit branch -f keep HEAD~2 &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash start..HEAD ^keep &&\n     ++\n     ++\tgit log --format=\"%s\" start..HEAD >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\ttwo\n     ++\tone\n     ++\tEOF\n     ++\ttest_cmp expect actual &&\n     ++\ttest_cmp_rev keep HEAD~1 &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     ++\n     ++\tgit branch -D keep\n     ++'\n     ++\n     ++test_expect_success 'squashes a branch the current branch is not on' '\n     ++\tgit reset --hard three &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\tgit checkout -b off-history start &&\n     ++\ttest_commit --no-tag off-one off a &&\n     ++\ttest_commit --no-tag off-two off b &&\n     ++\tgit checkout \"$main\" &&\n     ++\n     ++\tgit history squash start..off-history &&\n     ++\n     ++\tgit rev-list --count start..off-history >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\n     ++\tgit branch -D off-history\n      +'\n      +\n      +test_expect_success 'squashes a range into a single commit without changing the tree' '\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n      +'\n      +\n     -+test_expect_success 'squashing a single-commit range replays the rest' '\n     -+\tgit reset --hard three &&\n     -+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     -+\n     -+\tgit history squash start..@~2 &&\n     -+\n     -+\tgit log --format=\"%s\" start..HEAD >actual &&\n     -+\tcat >expect <<-\\EOF &&\n     -+\tthree\n     -+\ttwo\n     -+\tone\n     -+\tEOF\n     -+\ttest_cmp expect actual &&\n     -+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n     -+'\n      +\n      +test_expect_success 'reuses the message of a fixup! commit in the range' '\n      +\tgit reset --hard start &&\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +test_expect_success 'squashes a range whose internal merge has a single base' '\n      +\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n      +\ttest_commit --no-tag before-side file b &&\n      +\tgit checkout -b inner-side &&\n      +\ttest_commit --no-tag on-inner-side inner x &&\n     -+\tgit checkout - &&\n     ++\tgit checkout \"$main\" &&\n      +\ttest_commit --no-tag after-side file c &&\n      +\tgit merge --no-ff -m merge inner-side &&\n      +\tgit branch -D inner-side &&\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +test_expect_success 'folds a merge of a branch that forked at the base' '\n      +\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n      +\tgit checkout -b base-fork-side &&\n      +\ttest_commit --no-tag base-fork-side side x &&\n     -+\tgit checkout - &&\n     ++\tgit checkout \"$main\" &&\n      +\ttest_commit --no-tag base-fork-main file b &&\n      +\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n      +\tgit branch -D base-fork-side &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_path_is_file side\n      +'\n      +\n     ++test_expect_success 'refuses a merge whose other parent is outside the range' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\tgit checkout -b outside-parent &&\n     ++\ttest_commit --no-tag outside-parent outside x &&\n     ++\tgit checkout \"$main\" &&\n     ++\ttest_commit --no-tag outside-main file b &&\n     ++\tbase=$(git rev-parse HEAD) &&\n     ++\ttest_commit --no-tag outside-mid file c &&\n     ++\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n     ++\tgit branch -D outside-parent &&\n     ++\tmerged=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash \"$base..\" 2>err &&\n     ++\ttest_grep \"more than one base\" err &&\n     ++\ttest_cmp_rev \"$merged\" HEAD\n     ++'\n     ++\n      +test_expect_success 'folds a range whose tip is a merge commit' '\n      +\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n      +\ttest_commit --no-tag tipmerge-base file b &&\n      +\tgit checkout -b tipmerge-side &&\n      +\ttest_commit --no-tag tipmerge-side side x &&\n     -+\tgit checkout - &&\n     ++\tgit checkout \"$main\" &&\n      +\ttest_commit --no-tag tipmerge-main file c &&\n      +\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n      +\tgit branch -D tipmerge-side &&\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +test_expect_success 'folds a range whose base is a merge commit' '\n      +\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n      +\tgit checkout -b basemerge-side &&\n      +\ttest_commit --no-tag basemerge-side side x &&\n     -+\tgit checkout - &&\n     ++\tgit checkout \"$main\" &&\n      +\ttest_commit --no-tag basemerge-main file b &&\n      +\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n      +\tgit branch -D basemerge-side &&\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +test_expect_success 'refuses to squash a range with more than one base' '\n      +\tgit reset --hard start &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n      +\tgit checkout -b forked-before &&\n      +\ttest_commit forked-side fside x &&\n     -+\tgit checkout - &&\n     -+\ttest_commit forked-main file b &&\n     ++\tgit checkout \"$main\" &&\n     ++\ttest_commit forked-base file b &&\n     ++\tbase=$(git rev-parse HEAD) &&\n     ++\ttest_commit forked-main file c &&\n      +\tgit merge --no-ff -m merge forked-before &&\n      +\tmerged=$(git rev-parse HEAD) &&\n      +\n     -+\ttest_must_fail git history squash forked-main.. 2>err &&\n     ++\ttest_must_fail git history squash \"$base..\" 2>err &&\n      +\ttest_grep \"more than one base\" err &&\n      +\ttest_cmp_rev \"$merged\" HEAD\n      +'\n      +\n      +test_expect_success 'folds a range with two interior merges' '\n      +\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n      +\ttest_commit --no-tag two-merge-a file a1 &&\n      +\tgit checkout -b two-merge-s1 &&\n      +\ttest_commit --no-tag two-merge-s1 s1 x &&\n     -+\tgit checkout - &&\n     ++\tgit checkout \"$main\" &&\n      +\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n      +\ttest_commit --no-tag two-merge-b file b1 &&\n      +\tgit checkout -b two-merge-s2 &&\n      +\ttest_commit --no-tag two-merge-s2 s2 y &&\n     -+\tgit checkout - &&\n     ++\tgit checkout \"$main\" &&\n      +\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n      +\tgit branch -D two-merge-s1 two-merge-s2 &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     @@ t/t3455-history-squash.sh (new)\n      +test_expect_success 'refuses when a descendant above the range is a merge' '\n      +\tgit reset --hard start &&\n      +\tmain=$(git symbolic-ref --short HEAD) &&\n     -+\ttest_commit --no-tag desc-base file b &&\n     ++\ttest_commit --no-tag desc-one file b &&\n     ++\ttest_commit --no-tag desc-two file c &&\n      +\tgit tag desc-tip &&\n      +\tgit checkout -b desc-above &&\n      +\ttest_commit --no-tag desc-above above x &&\n      +\tgit checkout \"$main\" &&\n     -+\ttest_commit --no-tag desc-main file c &&\n     ++\ttest_commit --no-tag desc-main file d &&\n      +\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n      +\tgit branch -D desc-above &&\n      +\thead_before=$(git rev-parse HEAD) &&\n 4:  a758e1f084 ! 4:  4edf012b77 history: re-edit a squash with every message\n     @@ Commit message\n      \n          Gather the messages of every commit in the range, oldest first, and use\n          them as the editor template when re-editing, mirroring how \"git rebase\n     -    -i\" presents a squash. The combined message is built before the\n     -    descendant walk so it is not disturbed by the flags that walk leaves on\n     -    the commits.\n     +    -i\" presents a squash.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-history.adoc ##\n     -@@ Documentation/git-history.adoc: history squash @~3..` folds the three most recent commits into one, and\n     - `git history squash @~5..@~2` squashes an interior range while leaving\n     - the two newest commits in place.\n     +@@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several arguments may be given,\n     + for example `@~3.. ^topic` to additionally exclude what is already on\n     + `topic`.\n       +\n      -The oldest commit's message and authorship are preserved by default,\n      -unless you specify `--reedit-message`. A merge commit inside the range is\n\n-- \ngitgitgadget\n"},{"id":"546589","messageId":"fea6b79e601671308ea16bd65a0b6ea5e6a4f7e8.1782635349.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com","subject":"[PATCH v6 1/4] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-28T08:29:06Z","receivedAt":"2026-06-28T08:29:13Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 091465a59e..f95f26e684 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -157,6 +157,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"546590","messageId":"e2674e0bc41221af18ece51ad207b6e0561ad3c9.1782635349.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com","subject":"[PATCH v6 2/4] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-28T08:29:07Z","receivedAt":"2026-06-28T08:29:15Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex f95f26e684..305bde3102 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -101,6 +101,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"546591","messageId":"811e393ab48e2f79e6f8c78d883a5b92311791b3.1782635349.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com","subject":"[PATCH v6 3/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-28T08:29:08Z","receivedAt":"2026-06-28T08:29:17Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into the oldest one, keeping that commit's\nmessage and authorship and taking the tree of the newest commit, then\nreplays the commits above the range on top. fixup!, squash! and amend!\ncommits are folded like any other and are not interpreted, so the\nsquashed message comes from the oldest commit, or from an editor with\n--reedit-message.\n\nThe range is read like the arguments to \"git rev-list\", so several\narguments such as \"@~3.. ^topic\" are allowed. A merge inside the range\nis folded when its other parent is reachable from the base, otherwise\nthe range has more than one base and is rejected. By default the command\nalso refuses when a ref points at a commit that the fold would discard.\nUse --update-refs=head to rewrite only the current branch instead.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  28 ++\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 224 ++++++++++++++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 513 +++++++++++++++++++++++++++++++\n 7 files changed, 772 insertions(+)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..f4d692d136 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -55,6 +55,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 2ba8121795..123ad5d4bc 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -11,6 +11,7 @@ SYNOPSIS\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n \n DESCRIPTION\n -----------\n@@ -97,6 +98,33 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's message and\n+\tauthorship and takes the tree of the range's newest commit, so the\n+\twhole range collapses into a single commit. Commits above the range\n+\tare replayed on top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash @~3..` folds the three most recent commits into one, and\n+`git history squash @~5..@~2` squashes an interior range while leaving\n+the two newest commits in place. _<revision-range>_ is read like the\n+arguments to linkgit:git-rev-list[1], so several arguments may be given,\n+for example `@~3.. ^topic` to additionally exclude what is already on\n+`topic`.\n++\n+The oldest commit's message and authorship are preserved by default,\n+unless you specify `--reedit-message`. A merge commit inside the range is\n+folded like any other, but the range must have a single base, so a range\n+that reaches more than one entry point (for example a side branch that\n+forked before the range and was later merged into it) is rejected.\n++\n+The folded commits disappear from the history, so with the default\n+`--update-refs=branches` the command refuses when another ref points at\n+one of them. Rerun with `--update-refs=head` to rewrite only the current\n+branch and leave those refs pointing at the old commits.\n+\n OPTIONS\n -------\n \ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..5c6ff95e31 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 8def280688..911b4e4643 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 305bde3102..5a1b42c063 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -30,6 +31,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -973,6 +976,225 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char **argv,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tstruct oidset *interior_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct commit_list *boundaries = NULL, *b;\n+\tstruct strvec args = STRVEC_INIT;\n+\tsize_t i;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_push(&args, \"--boundary\");\n+\tstrvec_push(&args, \"--ancestry-path\");\n+\tstrvec_pushv(&args, argv);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (i = 0; i < revs.cmdline.nr; i++)\n+\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n+\t\t\tbreak;\n+\tif (i == revs.cmdline.nr) {\n+\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tcommit_list_insert(commit, &boundaries);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\tif (tip)\n+\t\t\toidset_insert(interior_out, &tip->object.oid);\n+\t\ttip = commit;\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (!oldest->parents)\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\tbase = oldest->parents->item;\n+\n+\t/*\n+\t * A boundary other than the base is an in-range commit reaching a\n+\t * commit outside the range, so the range has more than one base.\n+\t */\n+\tfor (b = boundaries; b; b = b->next) {\n+\t\tif (b->item != base) {\n+\t\t\tret = error(_(\"the revision range has more than one base; \"\n+\t\t\t\t      \"cannot squash\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tcommit_list_free(boundaries);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+struct interior_ref_cb {\n+\tconst struct oidset *interior;\n+\tconst char *name;\n+};\n+\n+static int find_interior_ref(const struct reference *ref, void *cb_data)\n+{\n+\tstruct interior_ref_cb *data = cb_data;\n+\n+\tif (oidset_contains(data->interior, ref->oid)) {\n+\t\tdata->name = xstrdup(ref->name);\n+\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct oidset interior = OIDSET_INIT;\n+\tstruct commit *base, *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage, 0);\n+\tif (!argc) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n+\t\t\t\t   &interior);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tif (action == REF_ACTION_BRANCHES) {\n+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n+\n+\t\trefs_for_each_ref(get_main_ref_store(repo),\n+\t\t\t\t  find_interior_ref, &cb);\n+\t\tif (cb.name) {\n+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n+\t\t\t\t    cb.name);\n+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t\t    \"untouched.\"));\n+\t\t\tfree((char *)cb.name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\toidset_clear(&interior);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n int cmd_history(int argc,\n \t\tconst char **argv,\n \t\tconst char *prefix,\n@@ -982,6 +1204,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -989,6 +1212,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex 3219264fe7..63ea26b8ed 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -399,6 +399,7 @@ integration_tests = [\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..94ee54eb24\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,513 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\ttest_commit --no-tag one other x &&\n+\ttest_commit --no-tag two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..HEAD ^keep &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n+\tgit checkout \"$main\" &&\n+\n+\tgit history squash start..off-history &&\n+\n+\tgit rev-list --count start..off-history >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo one >expect &&\n+\ttest_cmp expect subject &&\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$root..\" &&\n+\n+\tgit rev-list --count \"$root..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+\n+test_expect_success 'reuses the message of a fixup! commit in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag reg1 file b &&\n+\tgit commit --allow-empty -m \"fixup! reg1\" &&\n+\ttest_commit reg2 file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo reg1 >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'keeps the oldest message even if it is a fixup!' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag \"fixup! something\" file b &&\n+\ttest_commit tail file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo \"fixup! something\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'preserves authorship of the oldest commit' '\n+\tgit reset --hard start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit --no-tag oldest file b &&\n+\ttest_commit newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--dry-run predicts the rewrite without performing it' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"error: .* points into the squashed range\" err &&\n+\ttest_grep \"hint: .*--update-refs=head\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\tmid_before=$(git rev-parse mid) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'refuses to fold a range a tag points into' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"refs/tags/mark\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit tag -d mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo before-side >expect &&\n+\ttest_cmp expect subject &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$base..\" &&\n+\n+\tgit rev-list --count \"$base..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'refuses to squash a range with more than one base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b forked-before &&\n+\ttest_commit forked-side fside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit forked-base file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit forked-main file c &&\n+\tgit merge --no-ff -m merge forked-before &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"at-merge\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"546592","messageId":"4edf012b77fd2f2fb2a51eb10863bbf852fffa40.1782635349.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com","subject":"[PATCH v6 4/4] history: re-edit a squash with every message","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-28T08:29:09Z","receivedAt":"2026-06-28T08:29:18Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBy default \"git history squash\" reuses the oldest commit's message.\nWhen --reedit-message is given it only reopened that one message, so the\nmessages of the folded-in commits were lost.\n\nGather the messages of every commit in the range, oldest first, and use\nthem as the editor template when re-editing, mirroring how \"git rebase\n-i\" presents a squash.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  5 +--\n builtin/history.c              | 61 +++++++++++++++++++++++++++++++++-\n t/t3455-history-squash.sh      | 37 +++++++++++++++++++++\n 3 files changed, 100 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 123ad5d4bc..8d4398ab1b 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -114,8 +114,9 @@ arguments to linkgit:git-rev-list[1], so several arguments may be given,\n for example `@~3.. ^topic` to additionally exclude what is already on\n `topic`.\n +\n-The oldest commit's message and authorship are preserved by default,\n-unless you specify `--reedit-message`. A merge commit inside the range is\n+The oldest commit's message and authorship are preserved by default. With\n+`--reedit-message`, an editor opens pre-filled with the messages of all the\n+folded commits so you can combine them. A merge commit inside the range is\n folded like any other, but the range must have a single base, so a range\n that reaches more than one entry point (for example a side branch that\n forked before the range and was later merged into it) is rejected.\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 5a1b42c063..1c31ea9118 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1097,6 +1097,56 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tint n = 0, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tconst char *message, *body;\n+\t\tstruct strbuf one = STRBUF_INIT;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\t\tstrbuf_addstr(&one, body);\n+\t\tstrbuf_trim_trailing_newline(&one);\n+\n+\t\tif (n++)\n+\t\t\tstrbuf_addch(out, '\\n');\n+\t\tstrbuf_addbuf(out, &one);\n+\t\tstrbuf_addch(out, '\\n');\n+\n+\t\tstrbuf_release(&one);\n+\t\trepo_unuse_commit_buffer(repo, commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1121,6 +1171,7 @@ static int cmd_history_squash(int argc,\n \t\tOPT_END(),\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n \tstruct oidset interior = OIDSET_INIT;\n \tstruct commit *base, *oldest, *tip, *rewritten;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n@@ -1160,6 +1211,12 @@ static int cmd_history_squash(int argc,\n \t\t}\n \t}\n \n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n+\n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1168,7 +1225,8 @@ static int cmd_history_squash(int argc,\n \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n \tcommit_list_append(base, &parents);\n \n-\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\tret = commit_tree_ext(repo, \"squash\", oldest,\n+\t\t\t      message.len ? message.buf : NULL, parents,\n \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n@@ -1189,6 +1247,7 @@ static int cmd_history_squash(int argc,\n \n out:\n \tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n \toidset_clear(&interior);\n \tcommit_list_free(parents);\n \trelease_revisions(&revs);\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 94ee54eb24..5ef6768826 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -164,6 +164,43 @@ test_expect_success 'preserves authorship of the oldest commit' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--reedit-message offers every folded-in message' '\n+\tgit reset --hard start &&\n+\techo b >file &&\n+\tgit add file &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcp \"$1\" buffer &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\ttest_grep \"re-one subject\" buffer &&\n+\ttest_grep \"re-one body line\" buffer &&\n+\ttest_grep re-two buffer &&\n+\ttest_grep re-three buffer &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo combined >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--reedit-message aborts on an empty message' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\ttest_must_fail git history squash --reedit-message start.. &&\n+\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n test_expect_success '--dry-run predicts the rewrite without performing it' '\n \tgit reset --hard three &&\n \thead_before=$(git rev-parse HEAD) &&\n-- \ngitgitgadget\n"},{"id":"546620","messageId":"xmqqwlvhzyhz.fsf@gitster.g","threadId":"65812","inReplyTo":"4edf012b77fd2f2fb2a51eb10863bbf852fffa40.1782635349.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v6 4/4] history: re-edit a squash with every message","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-29T05:50:16Z","receivedAt":"2026-06-29T05:50:19Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n>\n> By default \"git history squash\" reuses the oldest commit's message.\n> When --reedit-message is given it only reopened that one message, so the\n> messages of the folded-in commits were lost.\n>\n> Gather the messages of every commit in the range, oldest first, and use\n> them as the editor template when re-editing, mirroring how \"git rebase\n> -i\" presents a squash.\n\nI doubt it would make practical difference, but one thing I notice\nis that unlike \"git rebase -i\", this one does not intersperse\nmarkers like \"# This is the 1st commit message\" in between the\nmessages taken from the squashed commits, so it is not exactly\n\"mirroring\".\n"},{"id":"546621","messageId":"xmqqse65zyhw.fsf@gitster.g","threadId":"65812","inReplyTo":"811e393ab48e2f79e6f8c78d883a5b92311791b3.1782635349.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v6 3/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-29T05:50:19Z","receivedAt":"2026-06-29T05:50:21Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> @@ -11,6 +11,7 @@ SYNOPSIS\n>  git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n>  git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n>  git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n> +git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n\nNot your fault at all, as there are existing violators but the\ngitcli tells us that the canonical order of arguments on the command\nline is to have dashed options early before any real arguments.\n\nSeeing <commit> followed by --dry-run, --update-refs, etc. with the\nexisting subcommands should also be corrected and this new command\nshould not spread the existing violation by mimicking.  The revision\nrange (which is not just a single token A..B, but the usual range\nnotation that rev-list takes, like ^A B..C) should come after all\nthese dashed options.\n\n> +The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n> +the commit just below the oldest commit to squash. For example, `git\n> +history squash @~3..` folds the three most recent commits into one, and\n\nIt is OK to use in your personal development, but in the official\ndocumentation, avoid cryptic @ and always spell HEAD (except for a\nsingle place that explain that @ can stand in for HEAD, for obvious\nreasons).  It is extremely annoying and confusing to read as these\nlook so similar to reflog notation for the current branch, e.g., @~1\nvs @{1}.\n\nSo \"squash HEAD~3..HEAD\" specifies a range of commits HEAD~2, HEAD~1\nand HEAD (three commits), these are squashed into one commit on top\nof HEAD~3, creating a sibling to HEAD~2 whose tree matches HEAD.\n\nOK.  Looking good.\n\n\n"},{"id":"546628","messageId":"akIQLM6xZTHBudWT@pks.im","threadId":"65812","inReplyTo":"d37e8f4f-d1f9-45aa-8c95-ebe676d54671@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-06-29T06:26:52Z","receivedAt":"2026-06-29T06:26:58Z","isPatch":true,"body":"On Fri, Jun 26, 2026 at 09:52:57AM +0100, Phillip Wood wrote:\n> Hi Harald\n> \n> On 24/06/2026 22:54, Harald Nordgren via GitGitGadget wrote:\n> > Adds git history squash <revision-range> to fold a range of commits.\n> \n> It would be helpful to give a bit more detail here about the command so that\n> the reader has an overview of what is actually being implemented.\n> \n>  - what does it do with fixup!, squash! and amend! commits? Can it use\n>    the message from amend! commits to reword the commit?\n>  - can the user reword the commit message?\n\nGood things to document/discuss.\n\n>  - what happens if a merge commit inside the range has a parent outside\n>    the range?\n\nYeah, I agree that we should punt on merge commits for now. They are a\ncan of worms, and I'm not sure that we should just squash them. I would\nat least like the user to ask a flag that tells us that it's fine\nsquashing those.\n\n>  - what happens to branches that point to commits inside the range?\n\nYeah, this should be documented indeed.\n\n> I had a quick play and found that it accepts ranges that containing a single\n> commit (e.g. @^!) where there is nothing to squash. It also accepts ranges\n> that are not ancestors of HEAD (e.g. checkout master and run \"git history\n> squash --dry-run origin/seen^2^!\") without printing an error message. Only\n> accepting a single argument is quite limiting as one cannot say\n> \n> \tgit history squash ^:/base :/tip\n\nNote that it is intentional that you can rewrite branches that are not\ncurrently checked out, and the other subcommands work the same. So I'd\nargue this should also be the case for \"squash\".\n\nPatrick\n"},{"id":"546668","messageId":"CAHwyqnXXFz4z_ULUq7Oqu0ykwpLJyEyW-uoF2bKfoYZQAjrNdQ@mail.gmail.com","threadId":"65812","inReplyTo":"xmqqwlvhzyhz.fsf@gitster.g","subject":"Re: [PATCH v6 4/4] history: re-edit a squash with every message","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-29T13:49:16Z","receivedAt":"2026-06-29T13:49:55Z","isPatch":true,"body":"> I doubt it would make practical difference, but one thing I notice\n> is that unlike \"git rebase -i\", this one does not intersperse\n> markers like \"# This is the 1st commit message\" in between the\n> messages taken from the squashed commits, so it is not exactly\n> \"mirroring\".\n\nI wouldn't mind extracting that logic from 'rebase -i' to show it\nhere. It would be nice to have.\n\n\nHarald\n"},{"id":"546673","messageId":"xmqq7bnhz9jr.fsf@gitster.g","threadId":"65812","inReplyTo":"CAHwyqnXXFz4z_ULUq7Oqu0ykwpLJyEyW-uoF2bKfoYZQAjrNdQ@mail.gmail.com","subject":"Re: [PATCH v6 4/4] history: re-edit a squash with every message","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-29T14:49:12Z","receivedAt":"2026-06-29T14:49:14Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> writes:\n\n>> I doubt it would make practical difference, but one thing I notice\n>> is that unlike \"git rebase -i\", this one does not intersperse\n>> markers like \"# This is the 1st commit message\" in between the\n>> messages taken from the squashed commits, so it is not exactly\n>> \"mirroring\".\n>\n> I wouldn't mind extracting that logic from 'rebase -i' to show it\n> here. It would be nice to have.\n\nIf we can share more code (not necessarily the exact existing\ncode---after cleaning it up if needed is perfectly fine and may even\nbe better) across codebaes that would be excellent.  Thanks.\n"},{"id":"546676","messageId":"3b3af3ef-a043-4af9-964e-429237789c97@gmail.com","threadId":"65812","inReplyTo":"akIQLM6xZTHBudWT@pks.im","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-29T15:51:19Z","receivedAt":"2026-06-29T15:51:22Z","isPatch":true,"body":"Hi Harald and Patrick\n\nOn 29/06/2026 07:26, Patrick Steinhardt wrote:\n> On Fri, Jun 26, 2026 at 09:52:57AM +0100, Phillip Wood wrote:\n>> Hi Harald\n>>\n>> On 24/06/2026 22:54, Harald Nordgren via GitGitGadget wrote:\n>>> Adds git history squash <revision-range> to fold a range of commits.\n>>\n>> It would be helpful to give a bit more detail here about the command so that\n>> the reader has an overview of what is actually being implemented.\n>>\n>>   - what does it do with fixup!, squash! and amend! commits? Can it use\n>>     the message from amend! commits to reword the commit?\n>>   - can the user reword the commit message?\n> \n> Good things to document/discuss.\n\nThanks, I was disappointed that these questions were not addressed in \nthe cover letter for v6 which contains no more detail than this one.\n\nWe should make rewording the commit as easy as possible to encourage \nusers to create useful commit histories. I think that means having some \nsupport for fixup! style commits. We could just do what rebase does and \ncomment out the fixup! style commit subjects and the commit messages \nreplaced by amend! commits but I think we have the opportunity to do \nsomething nicer (I find the commented-out messages annoying). We could \nhave a comment saying \"this is the combination of the following commits\" \nfollowed by a list of the subject lines and then in the template message \nwe'd simply omit the useless fixup! subjects when the commit body is \nempty and also omit the original commit messages that have been replaced \nby an amend! commit.\n\nSo instead of\n\n     # This is the combination of 4 commits\n     # This is the first commit message\n     Base subject\n\n     Base body\n\n     # This is the second commit message\n     # Another subject\n\n     # Another body\n\n     # This is the third commit message\n     # fixup! Base subject\n\n     # This is the fourth commit message\n     # amend! Another subject\n     A better subject\n\n     A better body\n\nWe'd have\n\n     # This is the combination of 4 commits\n     # 123 Base subject\n     # 456 Another subject\n     # 789 fixup! Base subject\n     # abc amend! Another subject\n\n     Base Subject\n\n     Base Body\n\n     Another subject\n\n     Another Body\n\nPossibly with a comment before each message saying where it came from.\n\nIt would be good to error out if the user tries squash a fixup! style \ncommit and range does not contain its target commit.\n\nIn the long run we should provide a way to squash an arbitrary list of \ncommits rather than just a range.\n\n> \n>>   - what happens if a merge commit inside the range has a parent outside\n>>     the range?\n> \n> Yeah, I agree that we should punt on merge commits for now. They are a\n> can of worms, and I'm not sure that we should just squash them. I would\n> at least like the user to ask a flag that tells us that it's fine\n> squashing those.\n\nThere does seem to be some support for merges in this patch series which \nI think behaves pretty sensibly. If we have\n\n           C - D\n          /     \\\n   - A - B - E - F - G\n\nThen squashing A..G should be fine because the parents of F are in the \nrange and it looks like we support that. Squashing should B..G without \n--ancestry-path should be safe as well because B ends up as the parent \nof the squashed commit but we don't have a way to disable \n--ancestry-path (and maybe we don't want to add one). Squashing F^@..G \nmight be useful to fixup a merge (though perhaps amending F rather than \ncreating G is a simpler way to fix a broken merge). Squashing E..G does \nnot make sense because the range does not include one of the merge parents.\n\n>>   - what happens to branches that point to commits inside the range?\n> \n> Yeah, this should be documented indeed.\n> \n>> I had a quick play and found that it accepts ranges that containing a single\n>> commit (e.g. @^!) where there is nothing to squash. It also accepts ranges\n>> that are not ancestors of HEAD (e.g. checkout master and run \"git history\n>> squash --dry-run origin/seen^2^!\") without printing an error message. Only\n>> accepting a single argument is quite limiting as one cannot say\n>>\n>> \tgit history squash ^:/base :/tip\n\nWe should sanitize what the user passes though - we do not want to \naccept arbitrary rev-list options. Off the top of my head \"--left-only\" \nand \"--right-only\" would allow the use of \"A...B\" and allowing \"--not\" \nseems reasonable.\n\n> Note that it is intentional that you can rewrite branches that are not\n> currently checked out, and the other subcommands work the same. So I'd\n> argue this should also be the case for \"squash\".\n\nAh, thanks for clarifying that - it does not seem to check that there is \na branch to be rewritten though if I do\n\n     ./git checkout origin/master\n     ./git history squash --dry-run origin/seen^2^^..origin/seen^2\n\nthere is no error message and it exits 0. If I create a branch pointing \nat origin/seen^2 then it does print a sensible ref-update.\n\nThanks\n\nPhillip\n"},{"id":"546680","messageId":"CAHwyqnV2J2d+ZHEOfMOe6xZPdfP54AfJ0GnyhfTwFmPVyQ9Y-Q@mail.gmail.com","threadId":"65812","inReplyTo":"akIQLM6xZTHBudWT@pks.im","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-29T16:09:38Z","receivedAt":"2026-06-29T16:10:16Z","isPatch":true,"body":"> Yeah, I agree that we should punt on merge commits for now. They are a\n> can of worms, and I'm not sure that we should just squash them. I would\n> at least like the user to ask a flag that tells us that it's fine\n> squashing those.\n\nDo you mean not even handling simple merges that are fully within the\nrange? I'm already handling those, so remove?\n\n\nHarald\n"},{"id":"546682","messageId":"xmqqzf0dwalx.fsf@gitster.g","threadId":"65812","inReplyTo":"3b3af3ef-a043-4af9-964e-429237789c97@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-29T16:54:34Z","receivedAt":"2026-06-29T16:54:37Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> It would be good to error out if the user tries squash a fixup! style \n> commit and range does not contain its target commit.\n\nThat's interesting.  It at least deserves a warning, even though the\nchoice to leave them out, with intentions to make it a separate fix\non top, may be deliberate.  If such a change-of-mind-in-the-middle\nis rare, then erroring out would be perfect.  The user can first\nreword the !fixup to make it a separate fix and then squash the\nrange in quesiton.\n\n> We should sanitize what the user passes though - we do not want to \n> accept arbitrary rev-list options. Off the top of my head \"--left-only\" \n> and \"--right-only\" would allow the use of \"A...B\" and allowing \"--not\" \n> seems reasonable.\n\nI would not recommend guessing what these rev-list \"expressions\"\nwould produce and blacklist some of the operations and notations.\nIt would be a more robust approach to let the machinery do its thing\nto determine the set of commits, *and* inspect the shape of the\nhistory these commits represent.  Are they connected?  Do they have\na single \"bottom\" that is just outside and below the range so that\nwe can replace it with the result of squashing everything together?\nDo they have a single \"top\" whose children can be rewritten to have\nthe resulting single commit as one of their parents?  Starting from\nthe acceptable shape of the history we want to deal with, rather\nthan trying to enumerate rev-list operations and notations that\nwould prevent the resulting set of commits to fall outside the\nacceptable shape of the history (and I am reasonably sure anybody\nwho attempts to do so would either end up with unusablly narrow\nsubset of what we can reasonably handle, or miss some cases that we\ndo not want to handle), would be a better approach.\n"},{"id":"546687","messageId":"xmqqpl19w8le.fsf@gitster.g","threadId":"65812","inReplyTo":"xmqq7bnhz9jr.fsf@gitster.g","subject":"Re: [PATCH v6 4/4] history: re-edit a squash with every message","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-29T17:38:05Z","receivedAt":"2026-06-29T17:38:07Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Harald Nordgren <haraldnordgren@gmail.com> writes:\n>\n>>> I doubt it would make practical difference, but one thing I notice\n>>> is that unlike \"git rebase -i\", this one does not intersperse\n>>> markers like \"# This is the 1st commit message\" in between the\n>>> messages taken from the squashed commits, so it is not exactly\n>>> \"mirroring\".\n>>\n>> I wouldn't mind extracting that logic from 'rebase -i' to show it\n>> here. It would be nice to have.\n>\n> If we can share more code (not necessarily the exact existing\n> code---after cleaning it up if needed is perfectly fine and may even\n> be better) across codebaes that would be excellent.  Thanks.\n\nAfter looking at what Phillip said in a side thread (look for \"So\ninstead of ... We'd have\") [*], I retract my \"I doubt it would make\npractical difference\".  Without boundary that shows where each\nmessage begins, the result is much harder to look at.\n\n\n[Reference]\n\n * https://lore.kernel.org/git/3b3af3ef-a043-4af9-964e-429237789c97@gmail.com/\n\n"},{"id":"546693","messageId":"CAHwyqnWQmObWr3N81_EU6F13iyKp3FfY8KSNFfoAjS4r_0qJrQ@mail.gmail.com","threadId":"65812","inReplyTo":"3b3af3ef-a043-4af9-964e-429237789c97@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-29T18:03:30Z","receivedAt":"2026-06-29T18:04:12Z","isPatch":true,"body":"> So instead of\n>\n>      # This is the combination of 4 commits\n>      # This is the first commit message\n>      Base subject\n>\n>      Base body\n>\n>      # This is the second commit message\n>      # Another subject\n>\n>      # Another body\n>\n>      # This is the third commit message\n>      # fixup! Base subject\n>\n>      # This is the fourth commit message\n>      # amend! Another subject\n>      A better subject\n>\n>      A better body\n>\n> We'd have\n>\n>      # This is the combination of 4 commits\n>      # 123 Base subject\n>      # 456 Another subject\n>      # 789 fixup! Base subject\n>      # abc amend! Another subject\n>\n>      Base Subject\n>\n>      Base Body\n>\n>      Another subject\n>\n>      Another Body\n\nI think this makes it a lot harder to read. If every commit body was\nalways just a single paragraph it could make sense, but it's generally\nnot. Look at the commits in this series, with no delimiter of where\none commit message ends and the next one starts, it would be very\nconfusing.\n\n> It would be good to error out if the user tries squash a fixup! style\n> commit and range does not contain its target commit.\n\nGood point, I don't see a good reason to allow this.\n\n> There does seem to be some support for merges in this patch series which\n> I think behaves pretty sensibly. If we have\n>\n>            C - D\n>           /     \\\n>    - A - B - E - F - G\n>\n> Then squashing A..G should be fine because the parents of F are in the\n> range and it looks like we support that. Squashing should B..G without\n> --ancestry-path should be safe as well because B ends up as the parent\n> of the squashed commit but we don't have a way to disable\n> --ancestry-path (and maybe we don't want to add one). Squashing F^@..G\n> might be useful to fixup a merge (though perhaps amending F rather than\n> creating G is a simpler way to fix a broken merge). Squashing E..G does\n> not make sense because the range does not include one of the merge parents.\n\nThanks for a good explanation here!\n\n\nHarald\n"},{"id":"546704","messageId":"dff9378a-267f-4b49-bee4-615b4bf75abb@gmail.com","threadId":"65812","inReplyTo":"CAHwyqnWQmObWr3N81_EU6F13iyKp3FfY8KSNFfoAjS4r_0qJrQ@mail.gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-29T19:48:22Z","receivedAt":"2026-06-29T19:48:25Z","isPatch":true,"body":"On 29/06/2026 19:03, Harald Nordgren wrote:\n>> So instead of\n>>\n>>       # This is the combination of 4 commits\n>>       # This is the first commit message\n>>       Base subject\n>>\n>>       Base body\n>>\n>>       # This is the second commit message\n>>       # Another subject\n>>\n>>       # Another body\n>>\n>>       # This is the third commit message\n>>       # fixup! Base subject\n>>\n>>       # This is the fourth commit message\n>>       # amend! Another subject\n>>       A better subject\n>>\n>>       A better body\n>>\n>> We'd have\n>>\n>>       # This is the combination of 4 commits\n>>       # 123 Base subject\n>>       # 456 Another subject\n>>       # 789 fixup! Base subject\n>>       # abc amend! Another subject\n>>\n>>       Base Subject\n>>\n>>       Base Body\n>>\n>>       Another subject\n>>\n>>       Another Body\n> \n> I think this makes it a lot harder to read. If every commit body was\n> always just a single paragraph it could make sense, but it's generally\n> not. Look at the commits in this series, with no delimiter of where\n> one commit message ends and the next one starts, it would be very\n> confusing.\n\nYou've trimmed the line where I said\n\n >> Possibly with a comment before each message saying where it came\n >> from.\n\nSo I'm not against adding a comment before each message, but I do think \nwe should omit any messages that would be commented out completely. If \nyou look at the rebase example above you can see there is a mass of \ncomments between the two pieces of text that make up the new message. \nThat makes it hard to see what is actually going to be included in the \nnew message.\n\nThanks\n\nPhillip\n\n>> It would be good to error out if the user tries squash a fixup! style\n>> commit and range does not contain its target commit.\n> \n> Good point, I don't see a good reason to allow this.\n> \n>> There does seem to be some support for merges in this patch series which\n>> I think behaves pretty sensibly. If we have\n>>\n>>             C - D\n>>            /     \\\n>>     - A - B - E - F - G\n>>\n>> Then squashing A..G should be fine because the parents of F are in the\n>> range and it looks like we support that. Squashing should B..G without\n>> --ancestry-path should be safe as well because B ends up as the parent\n>> of the squashed commit but we don't have a way to disable\n>> --ancestry-path (and maybe we don't want to add one). Squashing F^@..G\n>> might be useful to fixup a merge (though perhaps amending F rather than\n>> creating G is a simpler way to fix a broken merge). Squashing E..G does\n>> not make sense because the range does not include one of the merge parents.\n> \n> Thanks for a good explanation here!\n> \n> \n> Harald\n> \n\n"},{"id":"546714","messageId":"CAHwyqnVN=McZjtQGcPnoVOHAd0+VDNPXy_N949VMsqZty3RDjQ@mail.gmail.com","threadId":"65812","inReplyTo":"dff9378a-267f-4b49-bee4-615b4bf75abb@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-29T21:13:40Z","receivedAt":"2026-06-29T21:14:19Z","isPatch":true,"body":"> You've trimmed the line where I said\n>\n>  >> Possibly with a comment before each message saying where it came\n>  >> from.\n\nSorry about that! Can you show me the example of what it would look\nlike? It's much easier for me to reason about it then.\n\n\nHarald\n"},{"id":"546724","messageId":"DJM1N17VMUM5.3V5Y6YMFLIFQJ@lfurio.us","threadId":"65812","inReplyTo":"dff9378a-267f-4b49-bee4-615b4bf75abb@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Matt Hunter","fromEmail":"m@lfurio.us","sentAt":"2026-06-30T02:55:05Z","receivedAt":"2026-06-30T03:01:37Z","isPatch":true,"body":"Hi!\n\nI haven't experimented much with the new 'git history' commands, but\nthis discussion caught my eye and wanted to chime in.  (aka: please\nforgive my stupid questions)\n\nNote: I have built and am testing with v6 of this topic.\n\nOn Mon Jun 29, 2026 at 3:48 PM EDT, Phillip Wood wrote:\n> On 29/06/2026 19:03, Harald Nordgren wrote:\n>>> So instead of\n>>>\n>>>       # This is the combination of 4 commits\n>>>       # This is the first commit message\n>>>       Base subject\n>>>\n>>>       Base body\n>>>\n>>>       # This is the second commit message\n>>>       # Another subject\n>>>\n>>>       # Another body\n>>>\n>>>       # This is the third commit message\n>>>       # fixup! Base subject\n>>>\n>>>       # This is the fourth commit message\n>>>       # amend! Another subject\n>>>       A better subject\n>>>\n>>>       A better body\n>>>\n>>> We'd have\n>>>\n>>>       # This is the combination of 4 commits\n>>>       # 123 Base subject\n>>>       # 456 Another subject\n>>>       # 789 fixup! Base subject\n>>>       # abc amend! Another subject\n>>>\n>>>       Base Subject\n>>>\n>>>       Base Body\n>>>\n>>>       Another subject\n>>>\n>>>       Another Body\n>> \n>> I think this makes it a lot harder to read. If every commit body was\n>> always just a single paragraph it could make sense, but it's generally\n>> not. Look at the commits in this series, with no delimiter of where\n>> one commit message ends and the next one starts, it would be very\n>> confusing.\n>\n> You've trimmed the line where I said\n>\n>  >> Possibly with a comment before each message saying where it came\n>  >> from.\n>\n> So I'm not against adding a comment before each message, but I do think \n> we should omit any messages that would be commented out completely. If \n> you look at the rebase example above you can see there is a mass of \n> comments between the two pieces of text that make up the new message. \n> That makes it hard to see what is actually going to be included in the \n> new message.\n\nI agree with this idea as well.  And letting fixup! messages completely\nfall out makes more sense here than I want to say in git rebase.  The\ncommented list of commits at the top is a nice compromise for the todo\nlist you would have seen had you run 'git rebase -i' instead, and a\nglance at the list would confirm that the fixup!s you thought you\nincluded _actually are_ in the squash range.\n\nI assume the same treatment would _not_ be completely given to squash!\nmessages, since there is at least some expectation that they carry an\nadditional remark that the author might want to reword into the base\ncommit?\n\n>\n> Thanks\n> Phillip\n\nGiven the above, and how 'git rebase' usually works, I'm suprised that\n--reedit-message isn't the default behavior.  This may be my bias\ntowards rebase showing...  Perhaps the typical use of this command is to\njust work fast, and expect a follow up 'git history reword' if needed -\nwhen the original just needs a little extra context?\n\nThis is probably a larger question, since (according to the man page) it\naffects the other 'git history' commands as well.  When I run\n'git history ...' and discover that I made a mistake after inspecting\nthe results, is there a fool-proof way to undo the change and return to\nthe previous state?  My first thought was to run 'git reset --hard ...',\nbut the default behavior of --update-refs (moving other branches) can\nmake this more complicated.\n\nThanks\n"},{"id":"546737","messageId":"CAHwyqnVBEOm+FwD+i9Aa7edTvdnDPJom1zubcXgoExZnp--vWQ@mail.gmail.com","threadId":"65812","inReplyTo":"DJM1N17VMUM5.3V5Y6YMFLIFQJ@lfurio.us","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-30T07:19:59Z","receivedAt":"2026-06-30T07:20:38Z","isPatch":true,"body":"> This is probably a larger question, since (according to the man page) it\n> affects the other 'git history' commands as well.  When I run\n> 'git history ...' and discover that I made a mistake after inspecting\n> the results, is there a fool-proof way to undo the change and return to\n> the previous state?  My first thought was to run 'git reset --hard ...',\n> but the default behavior of --update-refs (moving other branches) can\n> make this more complicated.\n\nThis is a larger question: But I would love to have a reflog that is\nmore human-centered. When e.g. rebasing a series with N commits, it's\nvery tricky in the reflog to find what was the state before that.\n\nI feel like branch switching is given too much space in the reflog,\nsince it's not a destructive action, I don't care about it.\n\nAnd when handling multiple commits in on go (squashing, rebasing), I\nwould love to see a visual hierarchy (with indentation for sub-steps)\ninstead of treating each action as equally important when it isn't.\n\n\nHarald\n"},{"id":"546746","messageId":"DJM9WBO3EI9M.5ASUW8SQ6BP2@lfurio.us","threadId":"65812","inReplyTo":"CAHwyqnVBEOm+FwD+i9Aa7edTvdnDPJom1zubcXgoExZnp--vWQ@mail.gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Matt Hunter","fromEmail":"m@lfurio.us","sentAt":"2026-06-30T09:23:22Z","receivedAt":"2026-06-30T09:23:24Z","isPatch":true,"body":"On Tue Jun 30, 2026 at 3:19 AM EDT, Harald Nordgren wrote:\n>> This is probably a larger question, since (according to the man page) it\n>> affects the other 'git history' commands as well.  When I run\n>> 'git history ...' and discover that I made a mistake after inspecting\n>> the results, is there a fool-proof way to undo the change and return to\n>> the previous state?  My first thought was to run 'git reset --hard ...',\n>> but the default behavior of --update-refs (moving other branches) can\n>> make this more complicated.\n>\n> This is a larger question: But I would love to have a reflog that is\n> more human-centered. When e.g. rebasing a series with N commits, it's\n> very tricky in the reflog to find what was the state before that.\n>\n> I feel like branch switching is given too much space in the reflog,\n> since it's not a destructive action, I don't care about it.\n\nI share these headaches to an extent.  When dealing with the first\nproblem (seeing an atomic entry in the reflog), I usually look at the\nbranch's own reflog instead of HEAD's\n\nBut my question is about doing a comprehensive reset from a botched\noperation.  If any history operation updates branch refs besides the\ncurrent one.  I don't think there's an obvious way to see which other\nones were affected, and a naive 'git reset --hard my-branch@{1}' leaves\nthem pointed at unwanted commits.  Is this right?\n\ntangent:  I'm pretty sure that git-status relies on checkout / branch\nswitching reflog entries in order to know which tag your detached HEAD\nstarted from, eg: when it says something like\n\n    HEAD detached from v2.55.0-rc2\n    nothing to commit, working tree clean\n\n>\n> And when handling multiple commits in on go (squashing, rebasing), I\n> would love to see a visual hierarchy (with indentation for sub-steps)\n> instead of treating each action as equally important when it isn't.\n\nSounds compelling!\n"},{"id":"546781","messageId":"4b505228-4846-4a48-9255-e249f4e70a1f@gmail.com","threadId":"65812","inReplyTo":"CAHwyqnVN=McZjtQGcPnoVOHAd0+VDNPXy_N949VMsqZty3RDjQ@mail.gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-30T13:48:20Z","receivedAt":"2026-06-30T13:48:25Z","isPatch":true,"body":"On 29/06/2026 22:13, Harald Nordgren wrote:\n>> You've trimmed the line where I said\n>>\n>>   >> Possibly with a comment before each message saying where it came\n>>   >> from.\n> \n> Sorry about that! Can you show me the example of what it would look\n> like? It's much easier for me to reason about it then.\n\nFor the commits\n\n     123 Commit one\n     456 Commit two\n     789 fixup! Commit one\n     abc squash! Commit two\n     def amend! Commit one\n\nIt would look something like\n\n     # This is the combination of the following commits:\n     #   123 Commit one\n     #   789 fixup! Commit one\n     #   def amend! Commit one\n     #   456 Commit two\n     #   abc squash! Commit two\n\n     A better commit one subject\n\n     A better commit one body\n\n     # ------------------------------------\n\n     Commit two\n\n     Commit two body\n\n     # squash! Commit two\n\n     More text for commit two\n\nI've rearranged the commits in the summary to keep fixup commits with \ntheir targets instead of listing them in the same order of \"git log \n--reverse\". As we're dropping uninteresting messages I not sure there is \nmuch point in numbering them[1] so I've just used a plain separator to \nshow where a message ends. For squash! commits commenting out the \nsubject shows where the original message ends and the message from the \nsquash! commit begins. I'm on the fence as to whether it is worth having\n\n     # amend! Commit one\n\nabove the first message\n\nThanks\n\nPhillip\n\n[1] If we really want to number things we could number each commit in\n     the summary and then use that to label it in the message template\n     with separators like\n\n         # This is the message from commit <n>\n\n     instead of separating them with\n\n         # ----------------------------\n\n\n"},{"id":"546782","messageId":"3c35bd17-e884-432d-a400-36a89964ed89@gmail.com","threadId":"65812","inReplyTo":"DJM1N17VMUM5.3V5Y6YMFLIFQJ@lfurio.us","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-30T14:01:43Z","receivedAt":"2026-06-30T14:01:49Z","isPatch":true,"body":"Hi Matt\n\nOn 30/06/2026 03:55, Matt Hunter wrote:\n> I haven't experimented much with the new 'git history' commands, but\n> this discussion caught my eye and wanted to chime in.  (aka: please\n> forgive my stupid questions)\n> \n> Note: I have built and am testing with v6 of this topic.\n\nThanks for testing it, and also for your questions - it is always \nhelpful to get the perspective of someone using the command, especially \nwhile we're still designing it.\n\n> [...] \n> I agree with this idea as well.  And letting fixup! messages completely\n> fall out makes more sense here than I want to say in git rebase.  The\n> commented list of commits at the top is a nice compromise for the todo\n> list you would have seen had you run 'git rebase -i' instead, and a\n> glance at the list would confirm that the fixup!s you thought you\n> included _actually are_ in the squash range.\n> \n> I assume the same treatment would _not_ be completely given to squash!\n> messages, since there is at least some expectation that they carry an\n> additional remark that the author might want to reword into the base\n> commit?\n\nYes for \"squash! Some commit\" I think we'd want to have\n\n     Some commit\n\n     Some commit body\n\n     # squash! Some commit\n\n     The body of the squash! commit\n\n> Given the above, and how 'git rebase' usually works, I'm suprised that\n> --reedit-message isn't the default behavior.  This may be my bias\n> towards rebase showing...  Perhaps the typical use of this command is to\n> just work fast, and expect a follow up 'git history reword' if needed -\n> when the original just needs a little extra context?\n\nThat's a good point - if we want to encourage good commit histories then \nopening the editor by default gives the user an opportunity to double \ncheck which commits are being squashed and edit the message as appropriate.\n\n\"--reedit-message\" is a bit of a mouthful - maybe we should call it \n\"--edit\" (or \"--no-edit\" if we want to open the editor by default) like \n\"git commit\"\n\n> This is probably a larger question, since (according to the man page) it\n> affects the other 'git history' commands as well.  When I run\n> 'git history ...' and discover that I made a mistake after inspecting\n> the results, is there a fool-proof way to undo the change and return to\n> the previous state?  My first thought was to run 'git reset --hard ...',\n> but the default behavior of --update-refs (moving other branches) can\n> make this more complicated.\n\nYes this is a problem to which we don't have a good solution at the \nmoment. I believe Jujitsu and git-branchless both have some kind of \noperations log that lets you revert a whole operation rather than just a \nsingle ref-update. We'd need some way to tie all the ref updates from a \nsingle ref transaction together either by logging the separately or \nadding some form of transaction id to the reflog. That would be a big \nchange.\n\nThanks\n\nPhillip\n\n"},{"id":"546803","messageId":"CAHwyqnXoqZYHodWXHtwnk0_PiZcCYSVL+WgL3h5nWiYx_cSZLw@mail.gmail.com","threadId":"65812","inReplyTo":"4b505228-4846-4a48-9255-e249f4e70a1f@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-30T18:38:30Z","receivedAt":"2026-06-30T18:39:09Z","isPatch":true,"body":"I want to avoid creating drift between this and the format of 'git\nrebase -i', so if we want to change this, maybe better to change both\nat a later point instead?\n\n\nHarald\n"},{"id":"546868","messageId":"f15456d2-d8b2-4edc-80b4-3a9d8fc77da9@gmail.com","threadId":"65812","inReplyTo":"CAHwyqnXoqZYHodWXHtwnk0_PiZcCYSVL+WgL3h5nWiYx_cSZLw@mail.gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-01T10:31:23Z","receivedAt":"2026-07-01T10:31:27Z","isPatch":true,"body":"Hi Harald\n\nOn 30/06/2026 19:38, Harald Nordgren wrote:\n> I want to avoid creating drift between this and the format of 'git\n> rebase -i', so if we want to change this, maybe better to change both\n> at a later point instead?\n\nThe reason we're introducing the history command is to experiment with \nproviding a better user interface for rewriting history without being \nbound by the limitations of \"git rebase\". So I think it would entirely \nappropriate to try a different format for the squash message here. If it \nturns out to be a success then we can see if we want to use it in \"git \nrebase\" as well.\n\nThanks\n\nPhillip\n\n"},{"id":"546901","messageId":"f3fe7ff2-3ce9-4e90-95e7-8c620de5628a@gmail.com","threadId":"65812","inReplyTo":"xmqqzf0dwalx.fsf@gitster.g","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-01T13:45:24Z","receivedAt":"2026-07-01T13:45:27Z","isPatch":true,"body":"Hi Junio\n\nOn 29/06/2026 17:54, Junio C Hamano wrote:\n> Phillip Wood <phillip.wood123@gmail.com> writes:\n> \n>> We should sanitize what the user passes though - we do not want to\n>> accept arbitrary rev-list options. Off the top of my head \"--left-only\"\n>> and \"--right-only\" would allow the use of \"A...B\" and allowing \"--not\"\n>> seems reasonable.\n> \n> I would not recommend guessing what these rev-list \"expressions\"\n> would produce and blacklist some of the operations and notations.\n> It would be a more robust approach to let the machinery do its thing\n> to determine the set of commits, *and* inspect the shape of the\n> history these commits represent.  Are they connected?  Do they have\n> a single \"bottom\" that is just outside and below the range so that\n> we can replace it with the result of squashing everything together?\n> Do they have a single \"top\" whose children can be rewritten to have\n> the resulting single commit as one of their parents?  Starting from\n> the acceptable shape of the history we want to deal with, rather\n> than trying to enumerate rev-list operations and notations that\n> would prevent the resulting set of commits to fall outside the\n> acceptable shape of the history (and I am reasonably sure anybody\n> who attempts to do so would either end up with unusablly narrow\n> subset of what we can reasonably handle, or miss some cases that we\n> do not want to handle), would be a better approach.\n\nI think we still want some sanity checks similar to \"git replay\" though \nto ensure the user has not overridden \"--reverse\", \"--topo-order\", and \n\"--boundary\". It will be easier to sanity check the list of commits if \nwe can at least rely on those options being set as we know what order to \nexpect them in and can detect merge parents that are outside the range \nby looking for BOUNDARY commits.\n\nThanks\n\nPhillip\n"},{"id":"546902","messageId":"xmqq8q7urfe1.fsf@gitster.g","threadId":"65812","inReplyTo":"f15456d2-d8b2-4edc-80b4-3a9d8fc77da9@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-01T13:47:02Z","receivedAt":"2026-07-01T13:47:04Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> The reason we're introducing the history command is to experiment with \n> providing a better user interface for rewriting history without being \n> bound by the limitations of \"git rebase\". So I think it would entirely \n> appropriate to try a different format for the squash message here. If it \n> turns out to be a success then we can see if we want to use it in \"git \n> rebase\" as well.\n\nDo we know concretely things that are bad in the current way \"rebase\n-i\" works, so that we can experiment deviation from?  If not, the\nabove is going backwards, I'm afraind.\n\nThanks\n"},{"id":"546908","messageId":"c517bce2-9f39-46ec-8509-bb0893e26ae2@gmail.com","threadId":"65812","inReplyTo":"xmqq8q7urfe1.fsf@gitster.g","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-01T15:14:52Z","receivedAt":"2026-07-01T15:14:55Z","isPatch":true,"body":"On 01/07/2026 14:47, Junio C Hamano wrote:\n> Phillip Wood <phillip.wood123@gmail.com> writes:\n> \n>> The reason we're introducing the history command is to experiment with\n>> providing a better user interface for rewriting history without being\n>> bound by the limitations of \"git rebase\". So I think it would entirely\n>> appropriate to try a different format for the squash message here. If it\n>> turns out to be a success then we can see if we want to use it in \"git\n>> rebase\" as well.\n> \n> Do we know concretely things that are bad in the current way \"rebase\n> -i\" works, so that we can experiment deviation from? \n\nYes - if you apply the way \"rebase -i\" works to multiple commits you can \nend up with a message template that has a screen full of commented lines \nbetween uncommitted parts of the message. See the example below from \nearlier in the thread. It is not so much of a problem in \"rebase -i\" \nbecause it only fixes up a single commit at a time so all the commented \nmessages end up at the top of the buffer and at worst you have a few \"# \nfixup! ...\" or \"# squash! ...\" lines mixed in with the uncommitted text.\n\n     # This is the combination of 4 commits\n     # This is the first commit message\n     Base subject\n\n     Base body\n\n     # This is the second commit message\n     # Another subject\n\n     # Another body\n\n     # This is the third commit message\n     # fixup! Base subject\n\n     # This is the fourth commit message\n     # amend! Another subject\n     A better subject\n\n     A better body\n\nThanks\n\nPhillip\n\n"},{"id":"546929","messageId":"xmqqfr22obei.fsf@gitster.g","threadId":"65812","inReplyTo":"c517bce2-9f39-46ec-8509-bb0893e26ae2@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-01T17:41:25Z","receivedAt":"2026-07-01T17:41:28Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> Yes - if you apply the way \"rebase -i\" works to multiple commits you can \n> end up with a message template that has a screen full of commented lines \n> between uncommitted parts of the message. See the example below from \n> earlier in the thread. It is not so much of a problem in \"rebase -i\" \n> because it only fixes up a single commit at a time so all the commented \n> messages end up at the top of the buffer and at worst you have a few \"# \n> fixup! ...\" or \"# squash! ...\" lines mixed in with the uncommitted text.\n>\n>      # This is the combination of 4 commits\n>      # This is the first commit message\n>      Base subject\n>\n>      Base body\n>\n>      # This is the second commit message\n>      # Another subject\n>\n>      # Another body\n>\n>      # This is the third commit message\n>      # fixup! Base subject\n>\n>      # This is the fourth commit message\n>      # amend! Another subject\n>      A better subject\n>\n>      A better body\n\nIn the example, the second one becomes completely empty?\n\nIs the proposal not to show any messages that will be discarded\nanyway and not even show them in commented form?  I think that makes\nsense, and leaving only commit titles for these commits that would\nnot contribute to the text in the editor given to the user to edit\nwould indeed be an improvement.  For the same reason, as \"# amend!\"\nwill replace the message wholesale, it would also be a good idea for\nthe first commit to be hidden like all the other commits that would\nnot contribute to the text, so an improved version of the above may\nbe:\n\n    # This is the combination fo 4 commits\n    # 1. Base subject\n    # 2. Another subject\n    # 3. fixup! Base subject\n    # 4. amend! Another subject\n    A better subject\n\n    A better body.\n\nor something?  Is that the direction you want us to take?\n\nThanks.\n"},{"id":"546995","messageId":"akZfm-igZKeHaDST@pks.im","threadId":"65812","inReplyTo":"3c35bd17-e884-432d-a400-36a89964ed89@gmail.com","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-07-02T12:54:51Z","receivedAt":"2026-07-02T12:54:59Z","isPatch":true,"body":"On Tue, Jun 30, 2026 at 03:01:43PM +0100, Phillip Wood wrote:\n> On 30/06/2026 03:55, Matt Hunter wrote:\n[?nip]\n> > This is probably a larger question, since (according to the man page) it\n> > affects the other 'git history' commands as well.  When I run\n> > 'git history ...' and discover that I made a mistake after inspecting\n> > the results, is there a fool-proof way to undo the change and return to\n> > the previous state?  My first thought was to run 'git reset --hard ...',\n> > but the default behavior of --update-refs (moving other branches) can\n> > make this more complicated.\n> \n> Yes this is a problem to which we don't have a good solution at the moment.\n> I believe Jujitsu and git-branchless both have some kind of operations log\n> that lets you revert a whole operation rather than just a single ref-update.\n> We'd need some way to tie all the ref updates from a single ref transaction\n> together either by logging the separately or adding some form of transaction\n> id to the reflog. That would be a big change.\n\nYeah, agreed. I think that the reflog is insufficient for a lot of Git's\noperations and that it is way too hard to reason about. It's both too\ndetailed and not detailed enough at the same time:\n\n  - It provides way too much detail about individual reference updates\n    when all the user cares about is the high-level operation on the\n    logical level.\n\n  - It does not provide enough detail to give information about what the\n    high-level operation even was.\n\nI don't think that we can fix the reflog to work properly in this case.\nBut I certainly think that we should explore whether we can eventually\nintroduce something like an oplog, as well, so that we can easily have\nthe equivalent of `jj undo`.\n\nIt's something that I'd eventually want to get to, but it'll take a\nwhile. So if anybody else beats me to it then please go ahead :)\n\nPatrick\n"},{"id":"546997","messageId":"dd692c2d-87ca-4618-ae34-7e5e90ffa515@gmail.com","threadId":"65812","inReplyTo":"xmqqfr22obei.fsf@gitster.g","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-02T13:58:49Z","receivedAt":"2026-07-02T13:58:52Z","isPatch":true,"body":"On 01/07/2026 18:41, Junio C Hamano wrote:\n> Phillip Wood <phillip.wood123@gmail.com> writes:\n> \n>> Yes - if you apply the way \"rebase -i\" works to multiple commits you can\n>> end up with a message template that has a screen full of commented lines\n>> between uncommitted parts of the message. See the example below from\n>> earlier in the thread. It is not so much of a problem in \"rebase -i\"\n>> because it only fixes up a single commit at a time so all the commented\n>> messages end up at the top of the buffer and at worst you have a few \"#\n>> fixup! ...\" or \"# squash! ...\" lines mixed in with the uncommitted text.\n>>\n>>       # This is the combination of 4 commits\n>>       # This is the first commit message\n>>       Base subject\n>>\n>>       Base body\n>>\n>>       # This is the second commit message\n>>       # Another subject\n>>\n>>       # Another body\n>>\n>>       # This is the third commit message\n>>       # fixup! Base subject\n>>\n>>       # This is the fourth commit message\n>>       # amend! Another subject\n>>       A better subject\n>>\n>>       A better body\n> \n> In the example, the second one becomes completely empty?\n\nYes because there is an amend! commit that replaces its message\n> Is the proposal not to show any messages that will be discarded\n> anyway and not even show them in commented form?  I think that makes\n> sense, and leaving only commit titles for these commits that would\n> not contribute to the text in the editor given to the user to edit\n> would indeed be an improvement.  For the same reason, as \"# amend!\"\n> will replace the message wholesale, it would also be a good idea for\n> the first commit to be hidden like all the other commits that would\n> not contribute to the text,\n\nBut the amend! commit does not target the first commit - it replaces the \nmessage of the second commit so I think we should keep the first message \nas shown below. You can see a different example with a squash! message \nat [1]\n>      # This is the combination fo 4 commits\n>      # 1. Base subject\n>      # 2. Another subject\n>      # 3. fixup! Base subject\n>      # 4. amend! Another subject\n\n        Base subject\n\n        Base body\n\n        # -------------------------------------\n\t\n>      A better subject\n> \n>      A better body.\n\nThanks\n\nPhillip\n\n[1] \nhttps://lore.kernel.org/git/4b505228-4846-4a48-9255-e249f4e70a1f@gmail.com\n\n"},{"id":"547009","messageId":"xmqqcxx5kufb.fsf@gitster.g","threadId":"65812","inReplyTo":"akZfm-igZKeHaDST@pks.im","subject":"Re: [PATCH v5 0/4] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-02T20:28:40Z","receivedAt":"2026-07-02T20:28:44Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> Yeah, agreed. I think that the reflog is insufficient for a lot of Git's\n> operations and that it is way too hard to reason about.\n\nAs the reflog has never been about undoing, this is understandable.\n\nThings like @{-N} notation and \"git push --force-if-includes\" make\ngood use of the \"what was this ref pointing at historically?\"\ninformation, so they give us a proof that it is possible to\nprogrammatically go back and find the necessary state, though.\n"},{"id":"547202","messageId":"56ed8fadbb469f695182ca0fccf88833dda4835a.1783327849.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","subject":"[PATCH v7 1/5] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-06T08:50:45Z","receivedAt":"2026-07-06T08:50:58Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 091465a59e..f95f26e684 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -157,6 +157,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"547203","messageId":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com","subject":"[PATCH v7 0/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-06T08:50:44Z","receivedAt":"2026-07-06T08:50:58Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (5):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  history: add squash subcommand to fold a range\n  sequencer: extract helpers for the squash message markers\n  history: re-edit a squash with every message\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  49 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 390 +++++++++++++++++--\n sequencer.c                      |  64 ++--\n sequencer.h                      |  23 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 632 +++++++++++++++++++++++++++++++\n 9 files changed, 1099 insertions(+), 66 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v7\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v7\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v6:\n\n 1:  fea6b79e60 = 1:  56ed8fadbb history: extract helper for a commit's parent tree\n 2:  e2674e0bc4 = 2:  212e9c228f history: give commit_tree_ext a message template\n 3:  811e393ab4 ! 3:  cf3346a1cd history: add squash subcommand to fold a range\n     @@ Commit message\n          Add \"git history squash <revision-range>\" to do this directly. It folds\n          every commit in the range into the oldest one, keeping that commit's\n          message and authorship and taking the tree of the newest commit, then\n     -    replays the commits above the range on top. fixup!, squash! and amend!\n     -    commits are folded like any other and are not interpreted, so the\n     -    squashed message comes from the oldest commit, or from an editor with\n     -    --reedit-message.\n     +    replays the commits above the range on top. The squashed message comes\n     +    from the oldest commit, or from an editor with --reedit-message. As that\n     +    message is reused, a range whose oldest commit is a fixup!, squash! or\n     +    amend! is refused, since the marker's target cannot be in the range.\n      \n          The range is read like the arguments to \"git rev-list\", so several\n     -    arguments such as \"@~3.. ^topic\" are allowed. A merge inside the range\n     -    is folded when its other parent is reachable from the base, otherwise\n     -    the range has more than one base and is rejected. By default the command\n     -    also refuses when a ref points at a commit that the fold would discard.\n     -    Use --update-refs=head to rewrite only the current branch instead.\n     +    arguments such as \"HEAD~3..HEAD ^topic\" are allowed. A merge inside the\n     +    range is folded when its other parent is reachable from the base,\n     +    otherwise the range has more than one base and is rejected. By default\n     +    the command also refuses when a ref points at a commit that the fold\n     +    would discard. Use --update-refs=head to rewrite only the current branch\n     +    instead.\n      \n          Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n     @@ Documentation/git-history.adoc: SYNOPSIS\n       git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n       git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n       git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n     -+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n     ++git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\n       \n       DESCRIPTION\n       -----------\n     +@@ Documentation/git-history.adoc: at once.\n     + LIMITATIONS\n     + -----------\n     + \n     +-This command does not (yet) work with histories that contain merges. You\n     +-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n     ++This command does not (yet) replay merge commits onto the rewritten\n     ++history: if a commit that would be replayed is a merge, the operation is\n     ++rejected, and you should use linkgit:git-rebase[1] with the\n     ++`--rebase-merges` flag instead. The `squash` subcommand can still fold a\n     ++merge that lies inside the range, as long as the range has a single base.\n     + \n     + Furthermore, the command does not support operations that can result in merge\n     + conflicts. This limitation is by design as history rewrites are not intended to\n      @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n       It is invalid to select either all or no hunks, as that would lead to\n       one of the commits becoming empty.\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      ++\n      +The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n      +the commit just below the oldest commit to squash. For example, `git\n     -+history squash @~3..` folds the three most recent commits into one, and\n     -+`git history squash @~5..@~2` squashes an interior range while leaving\n     -+the two newest commits in place. _<revision-range>_ is read like the\n     -+arguments to linkgit:git-rev-list[1], so several arguments may be given,\n     -+for example `@~3.. ^topic` to additionally exclude what is already on\n     -+`topic`.\n     ++history squash HEAD~3..HEAD` folds the three most recent commits into\n     ++one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n     ++while leaving the two newest commits in place. _<revision-range>_ is read\n     ++like the arguments to linkgit:git-rev-list[1], so several arguments may be\n     ++given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n     ++already on `topic`.\n      ++\n      +The oldest commit's message and authorship are preserved by default,\n      +unless you specify `--reedit-message`. A merge commit inside the range is\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +that reaches more than one entry point (for example a side branch that\n      +forked before the range and was later merged into it) is rejected.\n      ++\n     -+The folded commits disappear from the history, so with the default\n     -+`--update-refs=branches` the command refuses when another ref points at\n     -+one of them. Rerun with `--update-refs=head` to rewrite only the current\n     -+branch and leave those refs pointing at the old commits.\n     ++Because the oldest commit's message is reused, the range may not begin\n     ++with a `fixup!`, `squash!`, or `amend!` commit, whose target is\n     ++necessarily outside the range.\n     +++\n     ++A branch or tag that points at a commit inside the range would be left\n     ++dangling once those commits are folded away, so with the default\n     ++`--update-refs=branches` the command refuses. Rerun with\n     ++`--update-refs=head` to rewrite only the current branch and leave such\n     ++refs pointing at the old commits.\n      +\n       OPTIONS\n       -------\n       \n     +@@ Documentation/git-history.adoc: OPTIONS\n     + \tref updates is generally safe.\n     + \n     + `--reedit-message`::\n     +-\tOpen an editor to modify the target commit's message.\n     ++\tOpen an editor to modify the rewritten commit's message. For `squash`\n     ++\tthe editor is pre-filled with the messages of all the folded commits.\n     + \n     + `--empty=(drop|keep|abort)`::\n     + \tControl what happens when a commit becomes empty as a result of the\n      \n       ## advice.c ##\n      @@ advice.c: static struct {\n     @@ builtin/history.c\n       #define GIT_HISTORY_SPLIT_USAGE \\\n       \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n      +#define GIT_HISTORY_SQUASH_USAGE \\\n     -+\tN_(\"git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\")\n     ++\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\")\n       \n       static void change_data_free(void *util, const char *str UNUSED)\n       {\n     @@ builtin/history.c: out:\n      +\treturn ret;\n      +}\n      +\n     ++static int reject_fixupish_oldest(struct repository *repo,\n     ++\t\t\t\t  struct commit *oldest)\n     ++{\n     ++\tconst char *message, *subject;\n     ++\tint ret = 0;\n     ++\n     ++\tmessage = repo_logmsg_reencode(repo, oldest, NULL, NULL);\n     ++\tfind_commit_subject(message, &subject);\n     ++\tif (starts_with(subject, \"fixup! \") ||\n     ++\t    starts_with(subject, \"squash! \") ||\n     ++\t    starts_with(subject, \"amend! \"))\n     ++\t\tret = error(_(\"the range begins with a fixup!, squash! or amend! \"\n     ++\t\t\t      \"commit whose target is not in the range\"));\n     ++\trepo_unuse_commit_buffer(repo, oldest, message);\n     ++\treturn ret;\n     ++}\n     ++\n      +struct interior_ref_cb {\n      +\tconst struct oidset *interior;\n      +\tconst char *name;\n     @@ builtin/history.c: out:\n      +\tif (ret < 0)\n      +\t\tgoto out;\n      +\n     ++\tret = reject_fixupish_oldest(repo, oldest);\n     ++\tif (ret < 0)\n     ++\t\tgoto out;\n     ++\n      +\tif (action == REF_ACTION_BRANCHES) {\n      +\t\tstruct interior_ref_cb cb = { .interior = &interior };\n      +\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +test_expect_success 'squashes a range into a single commit without changing the tree' '\n      +\tgit reset --hard three &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     ++\tgit history squash --dry-run start.. >out &&\n     ++\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\n      +\tgit history squash start.. &&\n      +\n     ++\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n      +\tgit rev-list --count start..HEAD >count &&\n      +\techo 1 >expect &&\n      +\ttest_cmp expect count &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success 'keeps the oldest message even if it is a fixup!' '\n     ++test_expect_success 'refuses a range whose oldest commit is a fixup!' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag \"fixup! something\" file b &&\n     -+\ttest_commit tail file c &&\n     ++\ttest_commit --no-tag tail file c &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash start.. 2>err &&\n     ++\ttest_grep \"target is not in the range\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD\n     ++'\n     ++\n     ++test_expect_success 'does not interpret squash! or amend! markers' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag marker-oldest file b &&\n     ++\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n     ++\tgit commit --allow-empty -m \"amend! marker-oldest\" &&\n     ++\ttest_commit --no-tag marker-newest file c &&\n      +\n      +\tgit history squash start.. &&\n      +\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n      +\tgit log --format=\"%s\" -1 >actual &&\n     -+\techo \"fixup! something\" >expect &&\n     ++\techo marker-oldest >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--dry-run predicts the rewrite without performing it' '\n     -+\tgit reset --hard three &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     -+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     -+\n     -+\tgit history squash --dry-run start.. >out &&\n     -+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD &&\n     -+\n     -+\tgit history squash start.. &&\n     -+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     -+\ttest_cmp_rev start HEAD^ &&\n     -+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n     -+'\n     -+\n      +test_expect_success '--update-refs=head only moves HEAD' '\n      +\tgit reset --hard three &&\n      +\tgit branch -f other HEAD &&\n -:  ---------- > 4:  001356db93 sequencer: extract helpers for the squash message markers\n 4:  4edf012b77 ! 5:  615fe4dd3f history: re-edit a squash with every message\n     @@ Commit message\n          When --reedit-message is given it only reopened that one message, so the\n          messages of the folded-in commits were lost.\n      \n     -    Gather the messages of every commit in the range, oldest first, and use\n     -    them as the editor template when re-editing, mirroring how \"git rebase\n     -    -i\" presents a squash.\n     +    Gather the messages of every commit in the range, oldest first, and build\n     +    the same editor template that \"git rebase -i\" shows for a squash, using\n     +    add_squash_combination_header(), add_squash_message_header() and\n     +    squash_subject_comment_len(). Only the message text differs, the changes\n     +    are always folded in. Following autosquash, a fixup!'s message is\n     +    commented out in full under a \"will be skipped\" header, while a squash! or\n     +    amend! keeps its body with only the marker subject commented.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-history.adoc ##\n     -@@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several arguments may be given,\n     - for example `@~3.. ^topic` to additionally exclude what is already on\n     - `topic`.\n     +@@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], so several arguments may be\n     + given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n     + already on `topic`.\n       +\n      -The oldest commit's message and authorship are preserved by default,\n      -unless you specify `--reedit-message`. A merge commit inside the range is\n     @@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several\n       folded like any other, but the range must have a single base, so a range\n       that reaches more than one entry point (for example a side branch that\n       forked before the range and was later merged into it) is rejected.\n     + +\n     + Because the oldest commit's message is reused, the range may not begin\n     + with a `fixup!`, `squash!`, or `amend!` commit, whose target is\n     +-necessarily outside the range.\n     ++necessarily outside the range. The changes from every commit in the range\n     ++are always folded in. Only the message text differs. With\n     ++`--reedit-message` the template mirrors `git rebase -i`: the message of a\n     ++`fixup!` elsewhere in the range is commented out in full, while a\n     ++`squash!` or `amend!` keeps its message body with only the marker subject\n     ++commented, so you can fold the remark into the result.\n     + +\n     + A branch or tag that points at a commit inside the range would be left\n     + dangling once those commits are folded away, so with the default\n      \n       ## builtin/history.c ##\n      @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data)\n     @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi\n      +\t\t\t\tstruct commit *tip,\n      +\t\t\t\tstruct strbuf *out)\n      +{\n     ++\tstruct commit_list *commits = NULL, **tail = &commits, *c;\n      +\tstruct rev_info revs;\n      +\tstruct commit *commit;\n      +\tstruct strvec args = STRVEC_INIT;\n     -+\tint n = 0, ret;\n     ++\tint n = 0, total, ret;\n      +\n      +\trepo_init_revisions(repo, &revs, NULL);\n      +\tstrvec_push(&args, \"ignored\");\n     @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi\n      +\t\tgoto out;\n      +\t}\n      +\n     -+\twhile ((commit = get_revision(&revs))) {\n     ++\twhile ((commit = get_revision(&revs)))\n     ++\t\ttail = &commit_list_insert(commit, tail)->next;\n     ++\ttotal = commit_list_count(commits);\n     ++\n     ++\tfor (c = commits; c; c = c->next) {\n      +\t\tconst char *message, *body;\n     -+\t\tstruct strbuf one = STRBUF_INIT;\n     ++\t\tsize_t commented_len;\n     ++\t\tint skip;\n      +\n     -+\t\tmessage = repo_logmsg_reencode(repo, commit, NULL, NULL);\n     ++\t\tmessage = repo_logmsg_reencode(repo, c->item, NULL, NULL);\n      +\t\tfind_commit_subject(message, &body);\n     -+\t\tstrbuf_addstr(&one, body);\n     -+\t\tstrbuf_trim_trailing_newline(&one);\n      +\n     -+\t\tif (n++)\n     -+\t\t\tstrbuf_addch(out, '\\n');\n     -+\t\tstrbuf_addbuf(out, &one);\n     ++\t\tskip = starts_with(body, \"fixup! \");\n     ++\t\tcommented_len = skip ? strlen(body) :\n     ++\t\t\tsquash_subject_comment_len(body, 1);\n     ++\n     ++\t\tif (!n)\n     ++\t\t\tadd_squash_combination_header(out, total);\n      +\t\tstrbuf_addch(out, '\\n');\n     ++\t\tadd_squash_message_header(out, ++n, skip);\n     ++\t\tstrbuf_addstr(out, \"\\n\\n\");\n     ++\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n     ++\t\tstrbuf_addstr(out, body + commented_len);\n     ++\t\tstrbuf_complete_line(out);\n      +\n     -+\t\tstrbuf_release(&one);\n     -+\t\trepo_unuse_commit_buffer(repo, commit, message);\n     ++\t\trepo_unuse_commit_buffer(repo, c->item, message);\n      +\t}\n      +\n      +\tret = 0;\n      +\n      +out:\n     ++\tcommit_list_free(commits);\n      +\treset_revision_walk();\n      +\trelease_revisions(&revs);\n      +\tstrvec_clear(&args);\n     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +\ttest_commit re-three file d &&\n      +\n      +\twrite_script editor <<-\\EOF &&\n     -+\tcp \"$1\" buffer &&\n     ++\tcat \"$1\" >edited &&\n      +\techo combined >\"$1\"\n      +\tEOF\n      +\ttest_set_editor \"$(pwd)/editor\" &&\n      +\tgit history squash --reedit-message start.. &&\n      +\n     -+\ttest_grep \"re-one subject\" buffer &&\n     -+\ttest_grep \"re-one body line\" buffer &&\n     -+\ttest_grep re-two buffer &&\n     -+\ttest_grep re-three buffer &&\n     -+\tgit log --format=\"%s\" -1 >actual &&\n     ++\tcat >expect <<-EOF &&\n     ++\t# This is a combination of 3 commits.\n     ++\t# This is the 1st commit message:\n     ++\n     ++\tre-one subject\n     ++\n     ++\tre-one body line\n     ++\n     ++\t# This is the commit message #2:\n     ++\n     ++\tre-two\n     ++\n     ++\t# This is the commit message #3:\n     ++\n     ++\tre-three\n     ++\n     ++\t# Please enter the commit message for the squash changes. Lines starting\n     ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     ++\t# Changes to be committed:\n     ++\t#\tmodified:   file\n     ++\t#\n     ++\tEOF\n     ++\ttest_cmp expect edited &&\n      +\techo combined >expect &&\n     ++\tgit log --format=\"%s\" -1 >actual &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     ++test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag mark-base file b &&\n     ++\tprintf \"fixup! mark-base\\n\\nfixup body\\n\" >msg &&\n     ++\techo c >file &&\n     ++\tgit add file &&\n     ++\tgit commit -qF msg &&\n     ++\tprintf \"squash! mark-base\\n\\nsquash remark\\n\" >msg &&\n     ++\techo d >file &&\n     ++\tgit add file &&\n     ++\tgit commit -qF msg &&\n     ++\tprintf \"amend! mark-base\\n\\namended message\\n\" >msg &&\n     ++\techo e >file &&\n     ++\tgit add file &&\n     ++\tgit commit -qF msg &&\n     ++\n     ++\twrite_script editor <<-\\EOF &&\n     ++\tcat \"$1\" >edited\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n     ++\tgit history squash --reedit-message start.. &&\n     ++\n     ++\tcat >expect <<-EOF &&\n     ++\t# This is a combination of 4 commits.\n     ++\t# This is the 1st commit message:\n     ++\n     ++\tmark-base\n     ++\n     ++\t# The commit message #2 will be skipped:\n     ++\n     ++\t# fixup! mark-base\n     ++\t#\n     ++\t# fixup body\n     ++\n     ++\t# This is the commit message #3:\n     ++\n     ++\t# squash! mark-base\n     ++\n     ++\tsquash remark\n     ++\n     ++\t# This is the commit message #4:\n     ++\n     ++\t# amend! mark-base\n     ++\n     ++\tamended message\n     ++\n     ++\t# Please enter the commit message for the squash changes. Lines starting\n     ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     ++\t# Changes to be committed:\n     ++\t#\tmodified:   file\n     ++\t#\n     ++\tEOF\n     ++\ttest_cmp expect edited &&\n     ++\tgit log -1 --format=\"%B\" >final &&\n     ++\ttest_grep ! \"fixup body\" final &&\n     ++\ttest_grep \"squash remark\" final &&\n     ++\ttest_grep \"amended message\" final\n     ++'\n     ++\n      +test_expect_success '--reedit-message aborts on an empty message' '\n      +\tgit reset --hard three &&\n      +\thead_before=$(git rev-parse HEAD) &&\n     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +\ttest_cmp_rev \"$head_before\" HEAD\n      +'\n      +\n     - test_expect_success '--dry-run predicts the rewrite without performing it' '\n     + test_expect_success '--update-refs=head only moves HEAD' '\n       \tgit reset --hard three &&\n     - \thead_before=$(git rev-parse HEAD) &&\n     + \tgit branch -f other HEAD &&\n\n-- \ngitgitgadget\n"},{"id":"547204","messageId":"212e9c228f03a10203a38ba3cf5e522e070c53ed.1783327849.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","subject":"[PATCH v7 2/5] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-06T08:50:46Z","receivedAt":"2026-07-06T08:51:01Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex f95f26e684..305bde3102 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -101,6 +101,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"547205","messageId":"cf3346a1cd308c15466a9c13e4c2ef35b3bc897c.1783327849.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","subject":"[PATCH v7 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-06T08:50:47Z","receivedAt":"2026-07-06T08:51:02Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into the oldest one, keeping that commit's\nmessage and authorship and taking the tree of the newest commit, then\nreplays the commits above the range on top. The squashed message comes\nfrom the oldest commit, or from an editor with --reedit-message. As that\nmessage is reused, a range whose oldest commit is a fixup!, squash! or\namend! is refused, since the marker's target cannot be in the range.\n\nThe range is read like the arguments to \"git rev-list\", so several\narguments such as \"HEAD~3..HEAD ^topic\" are allowed. A merge inside the\nrange is folded when its other parent is reachable from the base,\notherwise the range has more than one base and is rejected. By default\nthe command also refuses when a ref points at a commit that the fold\nwould discard. Use --update-refs=head to rewrite only the current branch\ninstead.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  43 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 245 +++++++++++++++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 517 +++++++++++++++++++++++++++++++\n 7 files changed, 809 insertions(+), 3 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 257db58918..f4d692d136 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -55,6 +55,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 2ba8121795..783ddf4a51 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -11,6 +11,7 @@ SYNOPSIS\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -42,8 +43,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold a\n+merge that lies inside the range, as long as the range has a single base.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -97,6 +101,38 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's message and\n+\tauthorship and takes the tree of the range's newest commit, so the\n+\twhole range collapses into a single commit. Commits above the range\n+\tare replayed on top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. _<revision-range>_ is read\n+like the arguments to linkgit:git-rev-list[1], so several arguments may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`.\n++\n+The oldest commit's message and authorship are preserved by default,\n+unless you specify `--reedit-message`. A merge commit inside the range is\n+folded like any other, but the range must have a single base, so a range\n+that reaches more than one entry point (for example a side branch that\n+forked before the range and was later merged into it) is rejected.\n++\n+Because the oldest commit's message is reused, the range may not begin\n+with a `fixup!`, `squash!`, or `amend!` commit, whose target is\n+necessarily outside the range.\n++\n+A branch or tag that points at a commit inside the range would be left\n+dangling once those commits are folded away, so with the default\n+`--update-refs=branches` the command refuses. Rerun with\n+`--update-refs=head` to rewrite only the current branch and leave such\n+refs pointing at the old commits.\n+\n OPTIONS\n -------\n \n@@ -107,7 +143,8 @@ OPTIONS\n \tref updates is generally safe.\n \n `--reedit-message`::\n-\tOpen an editor to modify the target commit's message.\n+\tOpen an editor to modify the rewritten commit's message. For `squash`\n+\tthe editor is pre-filled with the messages of all the folded commits.\n \n `--empty=(drop|keep|abort)`::\n \tControl what happens when a commit becomes empty as a result of the\ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..5c6ff95e31 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 8def280688..911b4e4643 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 305bde3102..63911a493d 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -30,6 +31,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -973,6 +976,246 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char **argv,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tstruct oidset *interior_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct commit_list *boundaries = NULL, *b;\n+\tstruct strvec args = STRVEC_INIT;\n+\tsize_t i;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_push(&args, \"--boundary\");\n+\tstrvec_push(&args, \"--ancestry-path\");\n+\tstrvec_pushv(&args, argv);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (i = 0; i < revs.cmdline.nr; i++)\n+\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n+\t\t\tbreak;\n+\tif (i == revs.cmdline.nr) {\n+\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tcommit_list_insert(commit, &boundaries);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\tif (tip)\n+\t\t\toidset_insert(interior_out, &tip->object.oid);\n+\t\ttip = commit;\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (!oldest->parents)\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\tbase = oldest->parents->item;\n+\n+\t/*\n+\t * A boundary other than the base is an in-range commit reaching a\n+\t * commit outside the range, so the range has more than one base.\n+\t */\n+\tfor (b = boundaries; b; b = b->next) {\n+\t\tif (b->item != base) {\n+\t\t\tret = error(_(\"the revision range has more than one base; \"\n+\t\t\t\t      \"cannot squash\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tcommit_list_free(boundaries);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+static int reject_fixupish_oldest(struct repository *repo,\n+\t\t\t\t  struct commit *oldest)\n+{\n+\tconst char *message, *subject;\n+\tint ret = 0;\n+\n+\tmessage = repo_logmsg_reencode(repo, oldest, NULL, NULL);\n+\tfind_commit_subject(message, &subject);\n+\tif (starts_with(subject, \"fixup! \") ||\n+\t    starts_with(subject, \"squash! \") ||\n+\t    starts_with(subject, \"amend! \"))\n+\t\tret = error(_(\"the range begins with a fixup!, squash! or amend! \"\n+\t\t\t      \"commit whose target is not in the range\"));\n+\trepo_unuse_commit_buffer(repo, oldest, message);\n+\treturn ret;\n+}\n+\n+struct interior_ref_cb {\n+\tconst struct oidset *interior;\n+\tconst char *name;\n+};\n+\n+static int find_interior_ref(const struct reference *ref, void *cb_data)\n+{\n+\tstruct interior_ref_cb *data = cb_data;\n+\n+\tif (oidset_contains(data->interior, ref->oid)) {\n+\t\tdata->name = xstrdup(ref->name);\n+\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct oidset interior = OIDSET_INIT;\n+\tstruct commit *base, *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage, 0);\n+\tif (!argc) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n+\t\t\t\t   &interior);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = reject_fixupish_oldest(repo, oldest);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tif (action == REF_ACTION_BRANCHES) {\n+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n+\n+\t\trefs_for_each_ref(get_main_ref_store(repo),\n+\t\t\t\t  find_interior_ref, &cb);\n+\t\tif (cb.name) {\n+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n+\t\t\t\t    cb.name);\n+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t\t    \"untouched.\"));\n+\t\t\tfree((char *)cb.name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\toidset_clear(&interior);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n int cmd_history(int argc,\n \t\tconst char **argv,\n \t\tconst char *prefix,\n@@ -982,6 +1225,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -989,6 +1233,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex 3219264fe7..63ea26b8ed 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -399,6 +399,7 @@ integration_tests = [\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..6598649971\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,517 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\ttest_commit --no-tag one other x &&\n+\ttest_commit --no-tag two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..HEAD ^keep &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n+\tgit checkout \"$main\" &&\n+\n+\tgit history squash start..off-history &&\n+\n+\tgit rev-list --count start..off-history >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo one >expect &&\n+\ttest_cmp expect subject &&\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$root..\" &&\n+\n+\tgit rev-list --count \"$root..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+\n+test_expect_success 'reuses the message of a fixup! commit in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag reg1 file b &&\n+\tgit commit --allow-empty -m \"fixup! reg1\" &&\n+\ttest_commit reg2 file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo reg1 >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'refuses a range whose oldest commit is a fixup!' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag \"fixup! something\" file b &&\n+\ttest_commit --no-tag tail file c &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'does not interpret squash! or amend! markers' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag marker-oldest file b &&\n+\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n+\tgit commit --allow-empty -m \"amend! marker-oldest\" &&\n+\ttest_commit --no-tag marker-newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo marker-oldest >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'preserves authorship of the oldest commit' '\n+\tgit reset --hard start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit --no-tag oldest file b &&\n+\ttest_commit newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"error: .* points into the squashed range\" err &&\n+\ttest_grep \"hint: .*--update-refs=head\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\tmid_before=$(git rev-parse mid) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'refuses to fold a range a tag points into' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"refs/tags/mark\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit tag -d mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo before-side >expect &&\n+\ttest_cmp expect subject &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$base..\" &&\n+\n+\tgit rev-list --count \"$base..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'refuses to squash a range with more than one base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b forked-before &&\n+\ttest_commit forked-side fside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit forked-base file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit forked-main file c &&\n+\tgit merge --no-ff -m merge forked-before &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"at-merge\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"547206","messageId":"001356db93594bca92747ab8f443373f1e9025ed.1783327849.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","subject":"[PATCH v7 4/5] sequencer: extract helpers for the squash message markers","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-06T08:50:48Z","receivedAt":"2026-07-06T08:51:07Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). A later change\nreuses them to give \"git history squash --reedit-message\" the same\ntemplate.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 64 ++++++++++++++++++++++++++++++++---------------------\n sequencer.h | 23 +++++++++++++++++++\n 2 files changed, 62 insertions(+), 25 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 57855b0066..f4893e8f40 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1892,6 +1892,32 @@ static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2005,20 +2031,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2083,9 +2102,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2109,12 +2127,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2133,9 +2148,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 3164bd437d..feed0e9de3 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -208,6 +208,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"547207","messageId":"615fe4dd3f3ad13ada8a605433f79b5012530018.1783327849.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","subject":"[PATCH v7 5/5] history: re-edit a squash with every message","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-06T08:50:49Z","receivedAt":"2026-07-06T08:51:07Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBy default \"git history squash\" reuses the oldest commit's message.\nWhen --reedit-message is given it only reopened that one message, so the\nmessages of the folded-in commits were lost.\n\nGather the messages of every commit in the range, oldest first, and build\nthe same editor template that \"git rebase -i\" shows for a squash, using\nadd_squash_combination_header(), add_squash_message_header() and\nsquash_subject_comment_len(). Only the message text differs, the changes\nare always folded in. Following autosquash, a fixup!'s message is\ncommented out in full under a \"will be skipped\" header, while a squash! or\namend! keeps its body with only the marker subject commented.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  12 +++-\n builtin/history.c              |  73 ++++++++++++++++++++-\n t/t3455-history-squash.sh      | 115 +++++++++++++++++++++++++++++++++\n 3 files changed, 196 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 783ddf4a51..f51332e731 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -117,15 +117,21 @@ like the arguments to linkgit:git-rev-list[1], so several arguments may be\n given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n already on `topic`.\n +\n-The oldest commit's message and authorship are preserved by default,\n-unless you specify `--reedit-message`. A merge commit inside the range is\n+The oldest commit's message and authorship are preserved by default. With\n+`--reedit-message`, an editor opens pre-filled with the messages of all the\n+folded commits so you can combine them. A merge commit inside the range is\n folded like any other, but the range must have a single base, so a range\n that reaches more than one entry point (for example a side branch that\n forked before the range and was later merged into it) is rejected.\n +\n Because the oldest commit's message is reused, the range may not begin\n with a `fixup!`, `squash!`, or `amend!` commit, whose target is\n-necessarily outside the range.\n+necessarily outside the range. The changes from every commit in the range\n+are always folded in. Only the message text differs. With\n+`--reedit-message` the template mirrors `git rebase -i`: the message of a\n+`fixup!` elsewhere in the range is commented out in full, while a\n+`squash!` or `amend!` keeps its message body with only the marker subject\n+commented, so you can fold the remark into the result.\n +\n A branch or tag that points at a commit inside the range would be left\n dangling once those commits are folded away, so with the default\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 63911a493d..8999ba9533 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1114,6 +1114,68 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct commit_list *commits = NULL, **tail = &commits, *c;\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tint n = 0, total, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs)))\n+\t\ttail = &commit_list_insert(commit, tail)->next;\n+\ttotal = commit_list_count(commits);\n+\n+\tfor (c = commits; c; c = c->next) {\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tint skip;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, c->item, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tskip = starts_with(body, \"fixup! \");\n+\t\tcommented_len = skip ? strlen(body) :\n+\t\t\tsquash_subject_comment_len(body, 1);\n+\n+\t\tif (!n)\n+\t\t\tadd_squash_combination_header(out, total);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, ++n, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, c->item, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tcommit_list_free(commits);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1138,6 +1200,7 @@ static int cmd_history_squash(int argc,\n \t\tOPT_END(),\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n \tstruct oidset interior = OIDSET_INIT;\n \tstruct commit *base, *oldest, *tip, *rewritten;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n@@ -1181,6 +1244,12 @@ static int cmd_history_squash(int argc,\n \t\t}\n \t}\n \n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n+\n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1189,7 +1258,8 @@ static int cmd_history_squash(int argc,\n \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n \tcommit_list_append(base, &parents);\n \n-\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n+\tret = commit_tree_ext(repo, \"squash\", oldest,\n+\t\t\t      message.len ? message.buf : NULL, parents,\n \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n@@ -1210,6 +1280,7 @@ static int cmd_history_squash(int argc,\n \n out:\n \tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n \toidset_clear(&interior);\n \tcommit_list_free(parents);\n \trelease_revisions(&revs);\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 6598649971..1985c83fbb 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -186,6 +186,121 @@ test_expect_success 'preserves authorship of the oldest commit' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--reedit-message offers every folded-in message' '\n+\tgit reset --hard start &&\n+\techo b >file &&\n+\tgit add file &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\techo combined >expect &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tprintf \"fixup! mark-base\\n\\nfixup body\\n\" >msg &&\n+\techo c >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\tprintf \"squash! mark-base\\n\\nsquash remark\\n\" >msg &&\n+\techo d >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\tprintf \"amend! mark-base\\n\\namended message\\n\" >msg &&\n+\techo e >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tgit log -1 --format=\"%B\" >final &&\n+\ttest_grep ! \"fixup body\" final &&\n+\ttest_grep \"squash remark\" final &&\n+\ttest_grep \"amended message\" final\n+'\n+\n+test_expect_success '--reedit-message aborts on an empty message' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\ttest_must_fail git history squash --reedit-message start.. &&\n+\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n test_expect_success '--update-refs=head only moves HEAD' '\n \tgit reset --hard three &&\n \tgit branch -f other HEAD &&\n-- \ngitgitgadget\n"},{"id":"547233","messageId":"5a5dbfae-4525-4b00-9e44-936be606ee85@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","subject":"Re: [PATCH v7 0/5] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-06T14:06:53Z","receivedAt":"2026-07-06T14:07:02Z","isPatch":true,"body":"Hi Harald\n\nOn 06/07/2026 09:50, Harald Nordgren via GitGitGadget wrote:\n> Adds git history squash <revision-range> to fold a range of commits.\n> \n> Changes in v7:\n> \n>   * --reedit-message\n\nThere was some discussion [1] about making that the default and renaming \nit - was that overlooked? If not it would be helpful to comment on those \ndiscussions to explain why you don't think it is a good idea.\n\n>     now builds the same editor template git rebase -i shows\n>     for a squash (a combination of N commits banner with each folded message\n>     under its own header) and follows autosquash for markers: a fixup!\n>     message falls out (commented under a will be skipped header), while a\n>     squash! or amend! keeps its body with only the marker subject commented\n>     so its remark can be reworded in. Only the message text is affected,\n>     every commit's changes are always folded in.\n\nRebase re-orders commits so that fixups immediately follow their target \n- do you do that here? I think that is very relevant because here we may \nbe dealing with several different commits each being targeted by a set \nof fixups and presenting them mixed together will be confusing. When \nrebase sees an \"amend!\" commit it comments out the message that is being \nreplaced - it is not clear from this description whether that happens here.\n\nAs I've said before I think we would be better off with a summary of the \ncommits that are being squashed and a more compact template message that \nonly contains the messages we want to keep [2]. What are the advantages \nof having lots of commented lines (or redundant messages if you don't \ncomment out the original when there is an amend! commit) in the middle \nof the template message?\n\n>   * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n>     commit extracts the banner, header and marker-comment helpers so both\n>     rebase and git history squash build the identical template from one\n>     source.\n>   * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n>     the marker's target cannot be inside the range.\n\nI think it should allow squashing a bunch of fixups together though. I \nthought there was a plan [3] to refuse to squash a fixup unless the \nrange included its target.\n\nThe range-diff does not show any input sanitization - what happens when \nthe user passes \"--reverse\" for example? As I said in [4] we should copy \nwhat \"git replay\" does to sanity check the rev-list options, otherwise \nwe've got no idea whether the parent of the first commit returned by \nget_revision() is the commit we want to use as the parent of the \nsquashed commit.\n\nThanks\n\nPhillip\n\n[1] \nhttps://lore.kernel.org/git/3c35bd17-e884-432d-a400-36a89964ed89@gmail.com/\n[2] \nhttps://lore.kernel.org/git/4b505228-4846-4a48-9255-e249f4e70a1f@gmail.com\n[3] \nhttps://lore.kernel.org/git/CAHwyqnWQmObWr3N81_EU6F13iyKp3FfY8KSNFfoAjS4r_0qJrQ@mail.gmail.com/\n[4] \nhttps://lore.kernel.org/git/f3fe7ff2-3ce9-4e90-95e7-8c620de5628a@gmail.com/\n\n>   * Reorder the squash usage so dashed options come before <revision-range>,\n>     and spell out HEAD instead of @ in the documentation and examples.\n>   * Expand the squash commit message and documentation with this overview,\n>     and scope the merge limitation so it no longer contradicts squash folding\n>     a single-base interior merge.\n> \n> Changes in v6:\n> \n>   * git history squash now accepts multiple revision arguments, read like the\n>     arguments to git-rev-list, so a compound range such as @~3.. ^topic\n>     works.\n>   * The base to reparent onto is now the oldest in-range commit's parent; a\n>     boundary other than that base means the range has more than one base and\n>     is rejected. This also fixes the earlier overly-restrictive handling of\n>     merges and side branches.\n>   * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n>     (this also covers the @^!-style example that previously succeeded\n>     silently).\n>   * Commit messages reworded: the squash commit now gives an overview of\n>     fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n> \n> Changes in v5:\n> \n>   * The range walk now uses --ancestry-path, so only commits descended from\n>     the base are folded; a single revision such as HEAD or HEAD~1 is now\n>     rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n>     down to the root.\n>   * This adopts the --ancestry-path suggestion; the multi-base rejection is\n>     unchanged, so a side branch that forked before the base and merged in is\n>     still refused.\n>   * Added tests covering more merge topologies: two interior merges, a nested\n>     merge, an octopus merge, an octopus arm forked before the base, a merge\n>     among the descendants replayed above the range, and a ref pointing at an\n>     interior merge commit.\n> \n> Changes in v4:\n> \n>   * git history squash now detects when another ref points at a commit inside\n>     the range being folded and refuses, with an advice.historyUpdateRefs hint\n>     to use --update-refs=head.\n>   * A merge inside the range is folded fine as long as the range has a single\n>     base; a range with merge commit at the tip or base also folds correctly.\n>     Only a range with more than one base is rejected.\n> \n> Changes in v3:\n> \n>   * Moved the feature out of git rebase and into a new git history squash\n>     <revision-range> subcommand, per the list discussion. git rebase --squash\n>     is dropped.\n>   * Takes an arbitrary range (git history squash @~3.., git history squash\n>     @~5..@~2), folding it into the oldest commit and replaying any\n>     descendants on top.\n>   * Implemented as a single tree operation rather than picking each commit,\n>     so there are no repeated conflict stops (addresses Phillip's efficiency\n>     point).\n>   * A merge inside the range is folded fine, only a range with more than one\n>     base is rejected.\n>   * --reedit-message seeds the editor with every folded-in message, not just\n>     the oldest.\n> \n> Harald Nordgren (5):\n>    history: extract helper for a commit's parent tree\n>    history: give commit_tree_ext a message template\n>    history: add squash subcommand to fold a range\n>    sequencer: extract helpers for the squash message markers\n>    history: re-edit a squash with every message\n> \n>   Documentation/config/advice.adoc |   4 +\n>   Documentation/git-history.adoc   |  49 ++-\n>   advice.c                         |   1 +\n>   advice.h                         |   1 +\n>   builtin/history.c                | 390 +++++++++++++++++--\n>   sequencer.c                      |  64 ++--\n>   sequencer.h                      |  23 ++\n>   t/meson.build                    |   1 +\n>   t/t3455-history-squash.sh        | 632 +++++++++++++++++++++++++++++++\n>   9 files changed, 1099 insertions(+), 66 deletions(-)\n>   create mode 100755 t/t3455-history-squash.sh\n> \n> \n> base-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc\n> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v7\n> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v7\n> Pull-Request: https://github.com/git/git/pull/2337\n> \n> Range-diff vs v6:\n> \n>   1:  fea6b79e60 = 1:  56ed8fadbb history: extract helper for a commit's parent tree\n>   2:  e2674e0bc4 = 2:  212e9c228f history: give commit_tree_ext a message template\n>   3:  811e393ab4 ! 3:  cf3346a1cd history: add squash subcommand to fold a range\n>       @@ Commit message\n>            Add \"git history squash <revision-range>\" to do this directly. It folds\n>            every commit in the range into the oldest one, keeping that commit's\n>            message and authorship and taking the tree of the newest commit, then\n>       -    replays the commits above the range on top. fixup!, squash! and amend!\n>       -    commits are folded like any other and are not interpreted, so the\n>       -    squashed message comes from the oldest commit, or from an editor with\n>       -    --reedit-message.\n>       +    replays the commits above the range on top. The squashed message comes\n>       +    from the oldest commit, or from an editor with --reedit-message. As that\n>       +    message is reused, a range whose oldest commit is a fixup!, squash! or\n>       +    amend! is refused, since the marker's target cannot be in the range.\n>        \n>            The range is read like the arguments to \"git rev-list\", so several\n>       -    arguments such as \"@~3.. ^topic\" are allowed. A merge inside the range\n>       -    is folded when its other parent is reachable from the base, otherwise\n>       -    the range has more than one base and is rejected. By default the command\n>       -    also refuses when a ref points at a commit that the fold would discard.\n>       -    Use --update-refs=head to rewrite only the current branch instead.\n>       +    arguments such as \"HEAD~3..HEAD ^topic\" are allowed. A merge inside the\n>       +    range is folded when its other parent is reachable from the base,\n>       +    otherwise the range has more than one base and is rejected. By default\n>       +    the command also refuses when a ref points at a commit that the fold\n>       +    would discard. Use --update-refs=head to rewrite only the current branch\n>       +    instead.\n>        \n>            Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>       @@ Documentation/git-history.adoc: SYNOPSIS\n>         git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n>         git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n>         git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n>       -+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n>       ++git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\n>         \n>         DESCRIPTION\n>         -----------\n>       +@@ Documentation/git-history.adoc: at once.\n>       + LIMITATIONS\n>       + -----------\n>       +\n>       +-This command does not (yet) work with histories that contain merges. You\n>       +-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n>       ++This command does not (yet) replay merge commits onto the rewritten\n>       ++history: if a commit that would be replayed is a merge, the operation is\n>       ++rejected, and you should use linkgit:git-rebase[1] with the\n>       ++`--rebase-merges` flag instead. The `squash` subcommand can still fold a\n>       ++merge that lies inside the range, as long as the range has a single base.\n>       +\n>       + Furthermore, the command does not support operations that can result in merge\n>       + conflicts. This limitation is by design as history rewrites are not intended to\n>        @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n>         It is invalid to select either all or no hunks, as that would lead to\n>         one of the commits becoming empty.\n>       @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n>        ++\n>        +The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n>        +the commit just below the oldest commit to squash. For example, `git\n>       -+history squash @~3..` folds the three most recent commits into one, and\n>       -+`git history squash @~5..@~2` squashes an interior range while leaving\n>       -+the two newest commits in place. _<revision-range>_ is read like the\n>       -+arguments to linkgit:git-rev-list[1], so several arguments may be given,\n>       -+for example `@~3.. ^topic` to additionally exclude what is already on\n>       -+`topic`.\n>       ++history squash HEAD~3..HEAD` folds the three most recent commits into\n>       ++one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n>       ++while leaving the two newest commits in place. _<revision-range>_ is read\n>       ++like the arguments to linkgit:git-rev-list[1], so several arguments may be\n>       ++given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n>       ++already on `topic`.\n>        ++\n>        +The oldest commit's message and authorship are preserved by default,\n>        +unless you specify `--reedit-message`. A merge commit inside the range is\n>       @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n>        +that reaches more than one entry point (for example a side branch that\n>        +forked before the range and was later merged into it) is rejected.\n>        ++\n>       -+The folded commits disappear from the history, so with the default\n>       -+`--update-refs=branches` the command refuses when another ref points at\n>       -+one of them. Rerun with `--update-refs=head` to rewrite only the current\n>       -+branch and leave those refs pointing at the old commits.\n>       ++Because the oldest commit's message is reused, the range may not begin\n>       ++with a `fixup!`, `squash!`, or `amend!` commit, whose target is\n>       ++necessarily outside the range.\n>       +++\n>       ++A branch or tag that points at a commit inside the range would be left\n>       ++dangling once those commits are folded away, so with the default\n>       ++`--update-refs=branches` the command refuses. Rerun with\n>       ++`--update-refs=head` to rewrite only the current branch and leave such\n>       ++refs pointing at the old commits.\n>        +\n>         OPTIONS\n>         -------\n>         \n>       +@@ Documentation/git-history.adoc: OPTIONS\n>       + \tref updates is generally safe.\n>       +\n>       + `--reedit-message`::\n>       +-\tOpen an editor to modify the target commit's message.\n>       ++\tOpen an editor to modify the rewritten commit's message. For `squash`\n>       ++\tthe editor is pre-filled with the messages of all the folded commits.\n>       +\n>       + `--empty=(drop|keep|abort)`::\n>       + \tControl what happens when a commit becomes empty as a result of the\n>        \n>         ## advice.c ##\n>        @@ advice.c: static struct {\n>       @@ builtin/history.c\n>         #define GIT_HISTORY_SPLIT_USAGE \\\n>         \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n>        +#define GIT_HISTORY_SQUASH_USAGE \\\n>       -+\tN_(\"git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\")\n>       ++\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\")\n>         \n>         static void change_data_free(void *util, const char *str UNUSED)\n>         {\n>       @@ builtin/history.c: out:\n>        +\treturn ret;\n>        +}\n>        +\n>       ++static int reject_fixupish_oldest(struct repository *repo,\n>       ++\t\t\t\t  struct commit *oldest)\n>       ++{\n>       ++\tconst char *message, *subject;\n>       ++\tint ret = 0;\n>       ++\n>       ++\tmessage = repo_logmsg_reencode(repo, oldest, NULL, NULL);\n>       ++\tfind_commit_subject(message, &subject);\n>       ++\tif (starts_with(subject, \"fixup! \") ||\n>       ++\t    starts_with(subject, \"squash! \") ||\n>       ++\t    starts_with(subject, \"amend! \"))\n>       ++\t\tret = error(_(\"the range begins with a fixup!, squash! or amend! \"\n>       ++\t\t\t      \"commit whose target is not in the range\"));\n>       ++\trepo_unuse_commit_buffer(repo, oldest, message);\n>       ++\treturn ret;\n>       ++}\n>       ++\n>        +struct interior_ref_cb {\n>        +\tconst struct oidset *interior;\n>        +\tconst char *name;\n>       @@ builtin/history.c: out:\n>        +\tif (ret < 0)\n>        +\t\tgoto out;\n>        +\n>       ++\tret = reject_fixupish_oldest(repo, oldest);\n>       ++\tif (ret < 0)\n>       ++\t\tgoto out;\n>       ++\n>        +\tif (action == REF_ACTION_BRANCHES) {\n>        +\t\tstruct interior_ref_cb cb = { .interior = &interior };\n>        +\n>       @@ t/t3455-history-squash.sh (new)\n>        +\n>        +test_expect_success 'squashes a range into a single commit without changing the tree' '\n>        +\tgit reset --hard three &&\n>       ++\thead_before=$(git rev-parse HEAD) &&\n>        +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n>        +\n>       ++\tgit history squash --dry-run start.. >out &&\n>       ++\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n>       ++\ttest_cmp_rev \"$head_before\" HEAD &&\n>       ++\n>        +\tgit history squash start.. &&\n>        +\n>       ++\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n>        +\tgit rev-list --count start..HEAD >count &&\n>        +\techo 1 >expect &&\n>        +\ttest_cmp expect count &&\n>       @@ t/t3455-history-squash.sh (new)\n>        +\ttest_cmp expect actual\n>        +'\n>        +\n>       -+test_expect_success 'keeps the oldest message even if it is a fixup!' '\n>       ++test_expect_success 'refuses a range whose oldest commit is a fixup!' '\n>        +\tgit reset --hard start &&\n>        +\ttest_commit --no-tag \"fixup! something\" file b &&\n>       -+\ttest_commit tail file c &&\n>       ++\ttest_commit --no-tag tail file c &&\n>       ++\thead_before=$(git rev-parse HEAD) &&\n>       ++\n>       ++\ttest_must_fail git history squash start.. 2>err &&\n>       ++\ttest_grep \"target is not in the range\" err &&\n>       ++\ttest_cmp_rev \"$head_before\" HEAD\n>       ++'\n>       ++\n>       ++test_expect_success 'does not interpret squash! or amend! markers' '\n>       ++\tgit reset --hard start &&\n>       ++\ttest_commit --no-tag marker-oldest file b &&\n>       ++\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n>       ++\tgit commit --allow-empty -m \"amend! marker-oldest\" &&\n>       ++\ttest_commit --no-tag marker-newest file c &&\n>        +\n>        +\tgit history squash start.. &&\n>        +\n>       ++\tgit rev-list --count start..HEAD >count &&\n>       ++\techo 1 >expect &&\n>       ++\ttest_cmp expect count &&\n>        +\tgit log --format=\"%s\" -1 >actual &&\n>       -+\techo \"fixup! something\" >expect &&\n>       ++\techo marker-oldest >expect &&\n>        +\ttest_cmp expect actual\n>        +'\n>        +\n>       @@ t/t3455-history-squash.sh (new)\n>        +\ttest_cmp expect actual\n>        +'\n>        +\n>       -+test_expect_success '--dry-run predicts the rewrite without performing it' '\n>       -+\tgit reset --hard three &&\n>       -+\thead_before=$(git rev-parse HEAD) &&\n>       -+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n>       -+\n>       -+\tgit history squash --dry-run start.. >out &&\n>       -+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n>       -+\ttest_cmp_rev \"$head_before\" HEAD &&\n>       -+\n>       -+\tgit history squash start.. &&\n>       -+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n>       -+\tgit rev-list --count start..HEAD >count &&\n>       -+\techo 1 >expect &&\n>       -+\ttest_cmp expect count &&\n>       -+\ttest_cmp_rev start HEAD^ &&\n>       -+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n>       -+'\n>       -+\n>        +test_expect_success '--update-refs=head only moves HEAD' '\n>        +\tgit reset --hard three &&\n>        +\tgit branch -f other HEAD &&\n>   -:  ---------- > 4:  001356db93 sequencer: extract helpers for the squash message markers\n>   4:  4edf012b77 ! 5:  615fe4dd3f history: re-edit a squash with every message\n>       @@ Commit message\n>            When --reedit-message is given it only reopened that one message, so the\n>            messages of the folded-in commits were lost.\n>        \n>       -    Gather the messages of every commit in the range, oldest first, and use\n>       -    them as the editor template when re-editing, mirroring how \"git rebase\n>       -    -i\" presents a squash.\n>       +    Gather the messages of every commit in the range, oldest first, and build\n>       +    the same editor template that \"git rebase -i\" shows for a squash, using\n>       +    add_squash_combination_header(), add_squash_message_header() and\n>       +    squash_subject_comment_len(). Only the message text differs, the changes\n>       +    are always folded in. Following autosquash, a fixup!'s message is\n>       +    commented out in full under a \"will be skipped\" header, while a squash! or\n>       +    amend! keeps its body with only the marker subject commented.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>         ## Documentation/git-history.adoc ##\n>       -@@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several arguments may be given,\n>       - for example `@~3.. ^topic` to additionally exclude what is already on\n>       - `topic`.\n>       +@@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], so several arguments may be\n>       + given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n>       + already on `topic`.\n>         +\n>        -The oldest commit's message and authorship are preserved by default,\n>        -unless you specify `--reedit-message`. A merge commit inside the range is\n>       @@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several\n>         folded like any other, but the range must have a single base, so a range\n>         that reaches more than one entry point (for example a side branch that\n>         forked before the range and was later merged into it) is rejected.\n>       + +\n>       + Because the oldest commit's message is reused, the range may not begin\n>       + with a `fixup!`, `squash!`, or `amend!` commit, whose target is\n>       +-necessarily outside the range.\n>       ++necessarily outside the range. The changes from every commit in the range\n>       ++are always folded in. Only the message text differs. With\n>       ++`--reedit-message` the template mirrors `git rebase -i`: the message of a\n>       ++`fixup!` elsewhere in the range is commented out in full, while a\n>       ++`squash!` or `amend!` keeps its message body with only the marker subject\n>       ++commented, so you can fold the remark into the result.\n>       + +\n>       + A branch or tag that points at a commit inside the range would be left\n>       + dangling once those commits are folded away, so with the default\n>        \n>         ## builtin/history.c ##\n>        @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data)\n>       @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi\n>        +\t\t\t\tstruct commit *tip,\n>        +\t\t\t\tstruct strbuf *out)\n>        +{\n>       ++\tstruct commit_list *commits = NULL, **tail = &commits, *c;\n>        +\tstruct rev_info revs;\n>        +\tstruct commit *commit;\n>        +\tstruct strvec args = STRVEC_INIT;\n>       -+\tint n = 0, ret;\n>       ++\tint n = 0, total, ret;\n>        +\n>        +\trepo_init_revisions(repo, &revs, NULL);\n>        +\tstrvec_push(&args, \"ignored\");\n>       @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi\n>        +\t\tgoto out;\n>        +\t}\n>        +\n>       -+\twhile ((commit = get_revision(&revs))) {\n>       ++\twhile ((commit = get_revision(&revs)))\n>       ++\t\ttail = &commit_list_insert(commit, tail)->next;\n>       ++\ttotal = commit_list_count(commits);\n>       ++\n>       ++\tfor (c = commits; c; c = c->next) {\n>        +\t\tconst char *message, *body;\n>       -+\t\tstruct strbuf one = STRBUF_INIT;\n>       ++\t\tsize_t commented_len;\n>       ++\t\tint skip;\n>        +\n>       -+\t\tmessage = repo_logmsg_reencode(repo, commit, NULL, NULL);\n>       ++\t\tmessage = repo_logmsg_reencode(repo, c->item, NULL, NULL);\n>        +\t\tfind_commit_subject(message, &body);\n>       -+\t\tstrbuf_addstr(&one, body);\n>       -+\t\tstrbuf_trim_trailing_newline(&one);\n>        +\n>       -+\t\tif (n++)\n>       -+\t\t\tstrbuf_addch(out, '\\n');\n>       -+\t\tstrbuf_addbuf(out, &one);\n>       ++\t\tskip = starts_with(body, \"fixup! \");\n>       ++\t\tcommented_len = skip ? strlen(body) :\n>       ++\t\t\tsquash_subject_comment_len(body, 1);\n>       ++\n>       ++\t\tif (!n)\n>       ++\t\t\tadd_squash_combination_header(out, total);\n>        +\t\tstrbuf_addch(out, '\\n');\n>       ++\t\tadd_squash_message_header(out, ++n, skip);\n>       ++\t\tstrbuf_addstr(out, \"\\n\\n\");\n>       ++\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n>       ++\t\tstrbuf_addstr(out, body + commented_len);\n>       ++\t\tstrbuf_complete_line(out);\n>        +\n>       -+\t\tstrbuf_release(&one);\n>       -+\t\trepo_unuse_commit_buffer(repo, commit, message);\n>       ++\t\trepo_unuse_commit_buffer(repo, c->item, message);\n>        +\t}\n>        +\n>        +\tret = 0;\n>        +\n>        +out:\n>       ++\tcommit_list_free(commits);\n>        +\treset_revision_walk();\n>        +\trelease_revisions(&revs);\n>        +\tstrvec_clear(&args);\n>       @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n>        +\ttest_commit re-three file d &&\n>        +\n>        +\twrite_script editor <<-\\EOF &&\n>       -+\tcp \"$1\" buffer &&\n>       ++\tcat \"$1\" >edited &&\n>        +\techo combined >\"$1\"\n>        +\tEOF\n>        +\ttest_set_editor \"$(pwd)/editor\" &&\n>        +\tgit history squash --reedit-message start.. &&\n>        +\n>       -+\ttest_grep \"re-one subject\" buffer &&\n>       -+\ttest_grep \"re-one body line\" buffer &&\n>       -+\ttest_grep re-two buffer &&\n>       -+\ttest_grep re-three buffer &&\n>       -+\tgit log --format=\"%s\" -1 >actual &&\n>       ++\tcat >expect <<-EOF &&\n>       ++\t# This is a combination of 3 commits.\n>       ++\t# This is the 1st commit message:\n>       ++\n>       ++\tre-one subject\n>       ++\n>       ++\tre-one body line\n>       ++\n>       ++\t# This is the commit message #2:\n>       ++\n>       ++\tre-two\n>       ++\n>       ++\t# This is the commit message #3:\n>       ++\n>       ++\tre-three\n>       ++\n>       ++\t# Please enter the commit message for the squash changes. Lines starting\n>       ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n>       ++\t# Changes to be committed:\n>       ++\t#\tmodified:   file\n>       ++\t#\n>       ++\tEOF\n>       ++\ttest_cmp expect edited &&\n>        +\techo combined >expect &&\n>       ++\tgit log --format=\"%s\" -1 >actual &&\n>        +\ttest_cmp expect actual\n>        +'\n>        +\n>       ++test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '\n>       ++\tgit reset --hard start &&\n>       ++\ttest_commit --no-tag mark-base file b &&\n>       ++\tprintf \"fixup! mark-base\\n\\nfixup body\\n\" >msg &&\n>       ++\techo c >file &&\n>       ++\tgit add file &&\n>       ++\tgit commit -qF msg &&\n>       ++\tprintf \"squash! mark-base\\n\\nsquash remark\\n\" >msg &&\n>       ++\techo d >file &&\n>       ++\tgit add file &&\n>       ++\tgit commit -qF msg &&\n>       ++\tprintf \"amend! mark-base\\n\\namended message\\n\" >msg &&\n>       ++\techo e >file &&\n>       ++\tgit add file &&\n>       ++\tgit commit -qF msg &&\n>       ++\n>       ++\twrite_script editor <<-\\EOF &&\n>       ++\tcat \"$1\" >edited\n>       ++\tEOF\n>       ++\ttest_set_editor \"$(pwd)/editor\" &&\n>       ++\tgit history squash --reedit-message start.. &&\n>       ++\n>       ++\tcat >expect <<-EOF &&\n>       ++\t# This is a combination of 4 commits.\n>       ++\t# This is the 1st commit message:\n>       ++\n>       ++\tmark-base\n>       ++\n>       ++\t# The commit message #2 will be skipped:\n>       ++\n>       ++\t# fixup! mark-base\n>       ++\t#\n>       ++\t# fixup body\n>       ++\n>       ++\t# This is the commit message #3:\n>       ++\n>       ++\t# squash! mark-base\n>       ++\n>       ++\tsquash remark\n>       ++\n>       ++\t# This is the commit message #4:\n>       ++\n>       ++\t# amend! mark-base\n>       ++\n>       ++\tamended message\n>       ++\n>       ++\t# Please enter the commit message for the squash changes. Lines starting\n>       ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n>       ++\t# Changes to be committed:\n>       ++\t#\tmodified:   file\n>       ++\t#\n>       ++\tEOF\n>       ++\ttest_cmp expect edited &&\n>       ++\tgit log -1 --format=\"%B\" >final &&\n>       ++\ttest_grep ! \"fixup body\" final &&\n>       ++\ttest_grep \"squash remark\" final &&\n>       ++\ttest_grep \"amended message\" final\n>       ++'\n>       ++\n>        +test_expect_success '--reedit-message aborts on an empty message' '\n>        +\tgit reset --hard three &&\n>        +\thead_before=$(git rev-parse HEAD) &&\n>       @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n>        +\ttest_cmp_rev \"$head_before\" HEAD\n>        +'\n>        +\n>       - test_expect_success '--dry-run predicts the rewrite without performing it' '\n>       + test_expect_success '--update-refs=head only moves HEAD' '\n>         \tgit reset --hard three &&\n>       - \thead_before=$(git rev-parse HEAD) &&\n>       + \tgit branch -f other HEAD &&\n> \n\n"},{"id":"547268","messageId":"xmqqse5v97e8.fsf@gitster.g","threadId":"65812","inReplyTo":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","subject":"Re: [PATCH v7 0/5] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-06T20:42:55Z","receivedAt":"2026-07-06T20:42:57Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> Adds git history squash <revision-range> to fold a range of commits.\n\nWhat I saw in the range-diff looked all reasonable.\n\n>  3:  811e393ab4 ! 3:  cf3346a1cd history: add squash subcommand to fold a range\n>      @@ Commit message\n>           Add \"git history squash <revision-range>\" to do this directly. It folds\n>           every commit in the range into the oldest one, keeping that commit's\n>           message and authorship and taking the tree of the newest commit, then\n>      -    replays the commits above the range on top. fixup!, squash! and amend!\n>      -    commits are folded like any other and are not interpreted, so the\n>      -    squashed message comes from the oldest commit, or from an editor with\n>      -    --reedit-message.\n>      +    replays the commits above the range on top. The squashed message comes\n>      +    from the oldest commit, or from an editor with --reedit-message. As that\n>      +    message is reused, a range whose oldest commit is a fixup!, squash! or\n>      +    amend! is refused, since the marker's target cannot be in the range.\n> ...\n>      -+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n>      ++git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\n\n>      ++static int reject_fixupish_oldest(struct repository *repo,\n>      ++\t\t\t\t  struct commit *oldest)\n>      ++{\n>      ++\tconst char *message, *subject;\n>      ++\tint ret = 0;\n>      ++\n>      ++\tmessage = repo_logmsg_reencode(repo, oldest, NULL, NULL);\n>      ++\tfind_commit_subject(message, &subject);\n>      ++\tif (starts_with(subject, \"fixup! \") ||\n>      ++\t    starts_with(subject, \"squash! \") ||\n>      ++\t    starts_with(subject, \"amend! \"))\n>      ++\t\tret = error(_(\"the range begins with a fixup!, squash! or amend! \"\n>      ++\t\t\t      \"commit whose target is not in the range\"));\n>      ++\trepo_unuse_commit_buffer(repo, oldest, message);\n>      ++\treturn ret;\n>      ++}\n\nNice. I often see myself getting rescued by the corresponding sanity\nchecks in the sequencer.\n\nWill replace.  Thanks.\n"},{"id":"547312","messageId":"CAHwyqnVxa34iGmFvL4Ujrc2dTbmtF+7j7b=q5v95y=-pzUf0NA@mail.gmail.com","threadId":"65812","inReplyTo":"5a5dbfae-4525-4b00-9e44-936be606ee85@gmail.com","subject":"Re: [PATCH v7 0/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-07T07:51:33Z","receivedAt":"2026-07-07T07:52:13Z","isPatch":true,"body":"> There was some discussion [1] about making that the default and renaming\n> it - was that overlooked? If not it would be helpful to comment on those\n> discussions to explain why you don't think it is a good idea.\n\nNot overlooked, but I side-stepped it because the discussion died\ndown, and yes I don't agree that it needs to be the default. I could\nhave mentioned my thinking in the cover letter.\n\n> >     now builds the same editor template git rebase -i shows\n> >     for a squash (a combination of N commits banner with each folded message\n> >     under its own header) and follows autosquash for markers: a fixup!\n> >     message falls out (commented under a will be skipped header), while a\n> >     squash! or amend! keeps its body with only the marker subject commented\n> >     so its remark can be reworded in. Only the message text is affected,\n> >     every commit's changes are always folded in.\n>\n> Rebase re-orders commits so that fixups immediately follow their target\n> - do you do that here? I think that is very relevant because here we may\n> be dealing with several different commits each being targeted by a set\n> of fixups and presenting them mixed together will be confusing.\n\nNo, I'm not doing that now, but I can take a look at that.\n\n> I think it should allow squashing a bunch of fixups together though. I\n> thought there was a plan [3] to refuse to squash a fixup unless the\n> range included its target.\n\nI attempted this with reject_fixupish_oldest(), assuming only the\nfirst commit needs to be checked as not being a fixup/squash/amend.\n\nBut now I realize that maybe we need to check all of the commits, and\nalso check if the target is in the range or not. It just makes the\nlogic a lot bigger.\n\n> The range-diff does not show any input sanitization - what happens when\n> the user passes \"--reverse\" for example? As I said in [4] we should copy\n> what \"git replay\" does to sanity check the rev-list options, otherwise\n> we've got no idea whether the parent of the first commit returned by\n> get_revision() is the commit we want to use as the parent of the\n> squashed commit.\n\nYeah, good point.\n\n\nHarald\n"},{"id":"547315","messageId":"CAHwyqnVd2OsmD-Y4YKVr9GsYdHRRNDot5EKSSESoRM-mf82YSg@mail.gmail.com","threadId":"65812","inReplyTo":"CAHwyqnVxa34iGmFvL4Ujrc2dTbmtF+7j7b=q5v95y=-pzUf0NA@mail.gmail.com","subject":"Re: [PATCH v7 0/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-07T08:55:39Z","receivedAt":"2026-07-07T08:56:18Z","isPatch":true,"body":"> > The range-diff does not show any input sanitization - what happens when\n> > the user passes \"--reverse\" for example? As I said in [4] we should copy\n> > what \"git replay\" does to sanity check the rev-list options, otherwise\n> > we've got no idea whether the parent of the first commit returned by\n> > get_revision() is the commit we want to use as the parent of the\n> > squashed commit.\n>\n> Yeah, good point.\n\nWell, the code already blocks \"--reverse\" and other unknown options,\nbut I can clarify that better in the commit message.\n\n\nHarald\n"},{"id":"547316","messageId":"38493ca6-8fdd-4b6c-9972-5145f3bf0aa4@gmail.com","threadId":"65812","inReplyTo":"CAHwyqnVd2OsmD-Y4YKVr9GsYdHRRNDot5EKSSESoRM-mf82YSg@mail.gmail.com","subject":"Re: [PATCH v7 0/5] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-07T09:30:18Z","receivedAt":"2026-07-07T09:30:21Z","isPatch":true,"body":"Hi Harald\n\nOn 07/07/2026 09:55, Harald Nordgren wrote:\n>>> The range-diff does not show any input sanitization - what happens when\n>>> the user passes \"--reverse\" for example? As I said in [4] we should copy\n>>> what \"git replay\" does to sanity check the rev-list options, otherwise\n>>> we've got no idea whether the parent of the first commit returned by\n>>> get_revision() is the commit we want to use as the parent of the\n>>> squashed commit.\n>>\n>> Yeah, good point.\n> \n> Well, the code already blocks \"--reverse\" and other unknown options,\n> but I can clarify that better in the commit message.\nWell it accepts\n\n\tgit history squash -- --reverse ...\n\nbecause after calling parse_options() everything after the \"--\" is \npassed to setup_revisions(). There was some discussion about accepting \nrev-list options [2] so it would have been helpful to reference that in \nthe cover letter. The cover letter should explain both the changes you \nhave made and the suggestions that were discussed that have not been \nimplemented so readers can get an overview of how this version relates \nto the previous discussion. Without that it is impossible to know if you \ndisagree with a suggestion or have just forgotten it.\n\n\"git replay\" supports arbitrary rev-list options by passing\n\n      PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT\n\nto parse_options(), then passing the remaining options to \nsetup_revisions(). After that it checks the various members of `struct \nrev_info` that it cares about are still set appropriately.\n\nThanks\n\nPhillip\n\n[1] https://lore.kernel.org/git/xmqqzf0dwalx.fsf@gitster.g\n"},{"id":"547317","messageId":"40add226-d986-409d-9d9e-d14a56e62f27@gmail.com","threadId":"65812","inReplyTo":"CAHwyqnVxa34iGmFvL4Ujrc2dTbmtF+7j7b=q5v95y=-pzUf0NA@mail.gmail.com","subject":"Re: [PATCH v7 0/5] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-07T09:48:17Z","receivedAt":"2026-07-07T09:48:20Z","isPatch":true,"body":"Hi Harald\n\nOn 07/07/2026 08:51, Harald Nordgren wrote:\n>> There was some discussion [1] about making that the default and renaming\n>> it - was that overlooked? If not it would be helpful to comment on those\n>> discussions to explain why you don't think it is a good idea.\n> \n> Not overlooked, but I side-stepped it because the discussion died\n> down, and yes I don't agree that it needs to be the default. I could\n> have mentioned my thinking in the cover letter.\n> \n>>>      now builds the same editor template git rebase -i shows\n>>>      for a squash (a combination of N commits banner with each folded message\n>>>      under its own header) and follows autosquash for markers: a fixup!\n>>>      message falls out (commented under a will be skipped header), while a\n>>>      squash! or amend! keeps its body with only the marker subject commented\n>>>      so its remark can be reworded in. Only the message text is affected,\n>>>      every commit's changes are always folded in.\n>>\n>> Rebase re-orders commits so that fixups immediately follow their target\n>> - do you do that here? I think that is very relevant because here we may\n>> be dealing with several different commits each being targeted by a set\n>> of fixups and presenting them mixed together will be confusing.\n> \n> No, I'm not doing that now, but I can take a look at that.\n\nThat's great, it is fine to punt things like this which require quite a \nbit of work to implement to a later re-roll but please be clear in the \ncover letter so reviewers know what to expect.\n\n>> I think it should allow squashing a bunch of fixups together though. I\n>> thought there was a plan [3] to refuse to squash a fixup unless the\n>> range included its target.\n> \n> I attempted this with reject_fixupish_oldest(), assuming only the\n> first commit needs to be checked as not being a fixup/squash/amend.\n> \n> But now I realize that maybe we need to check all of the commits, and\n> also check if the target is in the range or not. It just makes the\n> logic a lot bigger.\n\nYes it is a bit more involved. If the first commit is a fixup! then we \nshould allow the user to squash other fixups with the same target and \ntake the message from the last \"amend!\" commit if we see one. If there \nare other commits it the range then we should refuse to squash as you do \nhere.\n\nIf the first target is not a fixup then we should refuse fixup commits \nwhose target we have not seen. As well as exact subject matches \"git \nrebase\" accepts prefix matches and \"fixup! $objectid\". I think it is \nfine to skip the prefix matches to start with here. The $objectid \nmatches shouldn't be too much extra work and I think they are worth \nsupporting because if I remember correctly git-gui creates them. Another \ngotcha is that fixuping up a fixup prepends a \"fixup!\" to the subject \nline so you need to be able to handle things like\n\n\tfixup! fixup! the real target\n\tfixup! amend! the real target\n\tsquash! fixup! the real target\n\netc. Hopefully looking at the code that handles fixups in the sequencer \nwill help\n\nThanks\n\nPhillip\n\n"},{"id":"547701","messageId":"pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com","subject":"[PATCH v8 0/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-10T09:06:31Z","receivedAt":"2026-07-10T09:06:41Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v8:\n\n * --reedit-message now builds the same editor template as git rebase -i\n   --autosquash: fixup!, squash! and amend! commits are grouped under the\n   commit they target instead of shown in commit order, and an amend!\n   replaces its target's message.\n * A fixup!, squash! or amend! is refused only when its target is outside\n   the range, so several fixups for an in-range commit fold together. A\n   range that is entirely markers for one below-range target is combined\n   into a single commit, keeping the last amend! message.\n * Merges inside the range are folded when the range has a single base, with\n   no dedicated opt-in flag, --ancestry-path ensures only commits descended\n   from the base are folded, and a range reaching more than one base is\n   rejected.\n * Rev-list options are accepted and sanitized the way git replay does,\n   forcing the walk order back with a warning, which also fixes git history\n   squash -- --reverse slipping past the previous option check.\n * Kept this as an explicit squash subcommand rather than making\n   --reedit-message the default or renaming the command.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (5):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  history: add squash subcommand to fold a range\n  sequencer: share the squash message marker helpers and flags\n  history: re-edit a squash with every message\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  54 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 522 +++++++++++++++++++--\n sequencer.c                      |  70 +--\n sequencer.h                      |  30 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 776 +++++++++++++++++++++++++++++++\n 9 files changed, 1387 insertions(+), 72 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: f85a7e662054a7b0d9070e432508831afa214b47\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v8\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v8\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v7:\n\n 1:  56ed8fadbb = 1:  ba77752282 history: extract helper for a commit's parent tree\n 2:  212e9c228f = 2:  50f3572887 history: give commit_tree_ext a message template\n 3:  cf3346a1cd ! 3:  2d81a40a05 history: add squash subcommand to fold a range\n     @@ Commit message\n          every commit in the range into the oldest one, keeping that commit's\n          message and authorship and taking the tree of the newest commit, then\n          replays the commits above the range on top. The squashed message comes\n     -    from the oldest commit, or from an editor with --reedit-message. As that\n     -    message is reused, a range whose oldest commit is a fixup!, squash! or\n     -    amend! is refused, since the marker's target cannot be in the range.\n     +    from the oldest commit, or from an editor with --reedit-message. A\n     +    fixup!, squash! or amend! commit is refused unless the commit it targets\n     +    is also in the range, so the fold does not silently absorb a marker meant\n     +    for a commit outside it. The check runs the range through\n     +    todo_list_rearrange_squash(), which leaves such a marker as a plain pick.\n     +    Markers whose target is in the range fold in as usual. As an exception, a\n     +    range made up entirely of markers for one target is combined anyway,\n     +    taking its message from the last amend! if there is one, so a batch of\n     +    fixups for the same commit can be collapsed.\n      \n          The range is read like the arguments to \"git rev-list\", so several\n     -    arguments such as \"HEAD~3..HEAD ^topic\" are allowed. A merge inside the\n     -    range is folded when its other parent is reachable from the base,\n     -    otherwise the range has more than one base and is rejected. By default\n     -    the command also refuses when a ref points at a commit that the fold\n     -    would discard. Use --update-refs=head to rewrite only the current branch\n     -    instead.\n     +    revisions such as \"HEAD~3..HEAD ^topic\" may be given, and rev-list\n     +    options are accepted too. As \"git replay\" does, the walk options the fold\n     +    relies on are forced after setup_revisions() and a warning is printed if\n     +    an option changed them, so the first commit returned is the range's\n     +    oldest and its parent is the base regardless of what the user passed\n     +    (including after a \"--\"). A merge inside the range is folded when its\n     +    other parent is reachable from the base, otherwise the range has more\n     +    than one base and is rejected. By default the command also refuses when a\n     +    ref points at a commit that the fold would discard. Use --update-refs=head\n     +    to rewrite only the current branch instead.\n      \n          Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +the commit just below the oldest commit to squash. For example, `git\n      +history squash HEAD~3..HEAD` folds the three most recent commits into\n      +one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n     -+while leaving the two newest commits in place. _<revision-range>_ is read\n     -+like the arguments to linkgit:git-rev-list[1], so several arguments may be\n     ++while leaving the two newest commits in place. Several revisions may be\n      +given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n     -+already on `topic`.\n     ++already on `topic`. Rev-list options may also be given, but any that would\n     ++change how the range is walked are overridden with a warning.\n      ++\n      +The oldest commit's message and authorship are preserved by default,\n      +unless you specify `--reedit-message`. A merge commit inside the range is\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +that reaches more than one entry point (for example a side branch that\n      +forked before the range and was later merged into it) is rejected.\n      ++\n     -+Because the oldest commit's message is reused, the range may not begin\n     -+with a `fixup!`, `squash!`, or `amend!` commit, whose target is\n     -+necessarily outside the range.\n     ++A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n     ++targets is also in the range, so the fold does not silently absorb a\n     ++marker meant for a commit outside it. As an exception, a range made up\n     ++entirely of markers for one target is combined into a single commit,\n     ++keeping the last `amend!` message if there is one.\n      ++\n      +A branch or tag that points at a commit inside the range would be left\n      +dangling once those commits are folded away, so with the default\n     @@ builtin/history.c: out:\n      +\tint ret;\n      +\n      +\trepo_init_revisions(repo, &revs, NULL);\n     ++\trevs.reverse = 1;\n     ++\trevs.topo_order = 1;\n     ++\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n     ++\trevs.simplify_history = 0;\n     ++\trevs.boundary = 1;\n     ++\n      +\tstrvec_push(&args, \"ignored\");\n     -+\tstrvec_push(&args, \"--reverse\");\n     -+\tstrvec_push(&args, \"--topo-order\");\n     -+\tstrvec_push(&args, \"--boundary\");\n      +\tstrvec_push(&args, \"--ancestry-path\");\n      +\tstrvec_pushv(&args, argv);\n      +\tsetup_revisions_from_strvec(&args, &revs, NULL);\n     @@ builtin/history.c: out:\n      +\t\tgoto out;\n      +\t}\n      +\n     ++\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n     ++\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n     ++\t    revs.simplify_history != 0) {\n     ++\t\twarning(_(\"ignoring rev-list options that would change how the \"\n     ++\t\t\t  \"range is walked\"));\n     ++\t\trevs.reverse = 1;\n     ++\t\trevs.topo_order = 1;\n     ++\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n     ++\t\trevs.simplify_history = 0;\n     ++\t}\n     ++\n      +\t/*\n      +\t * A squash needs a base to reparent onto, so the range has to exclude\n      +\t * something, as in \"<base>..<tip>\". A revision range with no such\n     @@ builtin/history.c: out:\n      +\t\tgoto out;\n      +\t}\n      +\n     ++\t/*\n     ++\t * Set boundary commits aside for the base check below, and put every\n     ++\t * in-range commit but the tip into the interior set. A ref pointing\n     ++\t * at an interior commit would dangle once the range is folded away.\n     ++\t */\n      +\twhile ((commit = get_revision(&revs))) {\n      +\t\tif (commit->object.flags & BOUNDARY) {\n      +\t\t\tcommit_list_insert(commit, &boundaries);\n     @@ builtin/history.c: out:\n      +\tif (!oldest) {\n      +\t\tret = error(_(\"the revision range is empty\"));\n      +\t\tgoto out;\n     -+\t}\n     -+\n     -+\tif (oldest == tip) {\n     ++\t} else if (oldest == tip) {\n      +\t\tret = error(_(\"the revision range holds a single commit; \"\n      +\t\t\t      \"nothing to squash\"));\n      +\t\tgoto out;\n     -+\t}\n     -+\n     -+\tif (!oldest->parents)\n     ++\t} else if (!oldest->parents) {\n      +\t\tBUG(\"an in-range commit must have a parent\");\n     ++\t}\n      +\tbase = oldest->parents->item;\n      +\n      +\t/*\n     @@ builtin/history.c: out:\n      +\treturn ret;\n      +}\n      +\n     -+static int reject_fixupish_oldest(struct repository *repo,\n     -+\t\t\t\t  struct commit *oldest)\n     ++static const char *autosquash_target(const char *subject)\n     ++{\n     ++\tconst char *rest;\n     ++\n     ++\twhile (skip_prefix(subject, \"fixup! \", &rest) ||\n     ++\t       skip_prefix(subject, \"squash! \", &rest) ||\n     ++\t       skip_prefix(subject, \"amend! \", &rest))\n     ++\t\tsubject = rest;\n     ++\treturn subject;\n     ++}\n     ++\n     ++static int reject_dangling_fixups(struct repository *repo,\n     ++\t\t\t\t  struct commit *base,\n     ++\t\t\t\t  struct commit *tip,\n     ++\t\t\t\t  struct commit *oldest,\n     ++\t\t\t\t  struct commit **msg_source)\n      +{\n     -+\tconst char *message, *subject;\n     -+\tint ret = 0;\n     -+\n     -+\tmessage = repo_logmsg_reencode(repo, oldest, NULL, NULL);\n     -+\tfind_commit_subject(message, &subject);\n     -+\tif (starts_with(subject, \"fixup! \") ||\n     -+\t    starts_with(subject, \"squash! \") ||\n     -+\t    starts_with(subject, \"amend! \"))\n     -+\t\tret = error(_(\"the range begins with a fixup!, squash! or amend! \"\n     -+\t\t\t      \"commit whose target is not in the range\"));\n     -+\trepo_unuse_commit_buffer(repo, oldest, message);\n     ++\tstruct todo_list todo = TODO_LIST_INIT;\n     ++\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n     ++\tstruct rev_info revs;\n     ++\tstruct commit *commit, *last_amend = NULL;\n     ++\tstruct strvec args = STRVEC_INIT;\n     ++\tchar *dangling_subject = NULL, *dangling_target = NULL;\n     ++\tbool mixed_target = false, all_fixups_one_target;\n     ++\tint i, ret, nr_dangling = 0;\n     ++\n     ++\t*msg_source = oldest;\n     ++\n     ++\trepo_init_revisions(repo, &revs, NULL);\n     ++\tstrvec_push(&args, \"ignored\");\n     ++\tstrvec_push(&args, \"--reverse\");\n     ++\tstrvec_push(&args, \"--topo-order\");\n     ++\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n     ++\t\t     oid_to_hex(&tip->object.oid));\n     ++\tsetup_revisions_from_strvec(&args, &revs, NULL);\n     ++\n     ++\tif (prepare_revision_walk(&revs) < 0) {\n     ++\t\tret = error(_(\"error preparing revisions\"));\n     ++\t\tgoto out;\n     ++\t}\n     ++\twhile ((commit = get_revision(&revs)))\n     ++\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n     ++\t\t\t    oid_to_hex(&commit->object.oid));\n     ++\n     ++\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n     ++\t    todo_list_rearrange_squash(&todo) < 0) {\n     ++\t\tret = error(_(\"could not check the range for fixups\"));\n     ++\t\tgoto out;\n     ++\t}\n     ++\n     ++\tfor (i = 0; i < todo.nr; i++) {\n     ++\t\tconst char *message, *subject_start, *target;\n     ++\t\tchar *subject;\n     ++\t\tsize_t sublen;\n     ++\n     ++\t\tif (todo.items[i].command != TODO_PICK)\n     ++\t\t\tcontinue;\n     ++\t\tmessage = repo_logmsg_reencode(repo, todo.items[i].commit,\n     ++\t\t\t\t\t       NULL, NULL);\n     ++\t\tsublen = find_commit_subject(message, &subject_start);\n     ++\t\tsubject = xmemdupz(subject_start, sublen);\n     ++\t\ttarget = autosquash_target(subject);\n     ++\t\tif (target != subject) {\n     ++\t\t\tnr_dangling++;\n     ++\t\t\tif (!dangling_target) {\n     ++\t\t\t\tdangling_target = xstrdup(target);\n     ++\t\t\t\tdangling_subject = xstrdup(subject);\n     ++\t\t\t} else if (strcmp(dangling_target, target)) {\n     ++\t\t\t\tmixed_target = true;\n     ++\t\t\t}\n     ++\t\t\tif (starts_with(subject, \"amend! \"))\n     ++\t\t\t\tlast_amend = todo.items[i].commit;\n     ++\t\t}\n     ++\t\tfree(subject);\n     ++\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n     ++\t}\n     ++\n     ++\tall_fixups_one_target = nr_dangling == todo.nr && !mixed_target;\n     ++\tif (nr_dangling && !all_fixups_one_target) {\n     ++\t\tret = error(_(\"cannot squash '%s': its target is not in the \"\n     ++\t\t\t      \"range\"), dangling_subject);\n     ++\t} else {\n     ++\t\tif (last_amend)\n     ++\t\t\t*msg_source = last_amend;\n     ++\t\tret = 0;\n     ++\t}\n     ++\n     ++out:\n     ++\tfree(dangling_subject);\n     ++\tfree(dangling_target);\n     ++\ttodo_list_release(&todo);\n     ++\treplay_opts_release(&opts);\n     ++\treset_revision_walk();\n     ++\trelease_revisions(&revs);\n     ++\tstrvec_clear(&args);\n      +\treturn ret;\n      +}\n      +\n     @@ builtin/history.c: out:\n      +\t};\n      +\tstruct strbuf reflog_msg = STRBUF_INIT;\n      +\tstruct oidset interior = OIDSET_INIT;\n     -+\tstruct commit *base, *oldest, *tip, *rewritten;\n     ++\tstruct commit *base, *oldest, *tip, *rewritten, *msg_source;\n      +\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n      +\tstruct commit_list *parents = NULL;\n      +\tstruct rev_info revs = { 0 };\n      +\tint ret;\n      +\n     -+\targc = parse_options(argc, argv, prefix, options, usage, 0);\n     ++\targc = parse_options(argc, argv, prefix, options, usage,\n     ++\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT);\n      +\tif (!argc) {\n      +\t\tret = error(_(\"command expects a revision range\"));\n      +\t\tgoto out;\n     @@ builtin/history.c: out:\n      +\tif (ret < 0)\n      +\t\tgoto out;\n      +\n     -+\tret = reject_fixupish_oldest(repo, oldest);\n     ++\tret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n      +\n     @@ builtin/history.c: out:\n      +\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n      +\tcommit_list_append(base, &parents);\n      +\n     -+\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n     ++\tret = commit_tree_ext(repo, \"squash\", msg_source, NULL, parents,\n      +\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n      +\tif (ret < 0) {\n      +\t\tret = error(_(\"failed writing squashed commit\"));\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_grep \"squash: updating\" reflog\n      +'\n      +\n     ++test_expect_success 'sanitizes rev-list walk options, before and after --' '\n     ++\tgit reset --hard three &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash --date-order start.. 2>err &&\n     ++\ttest_grep \"ignoring rev-list options\" err &&\n     ++\ttest_cmp_rev start HEAD^ &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     ++\n     ++\tgit reset --hard three &&\n     ++\tgit history squash -- --reverse start.. 2>err &&\n     ++\ttest_grep \"ignoring rev-list options\" err &&\n     ++\ttest_cmp_rev start HEAD^ &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n     ++'\n     ++\n      +test_expect_success 'squashes an interior range and replays descendants verbatim' '\n      +\tgit reset --hard three &&\n      +\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n     @@ t/t3455-history-squash.sh (new)\n      +'\n      +\n      +\n     -+test_expect_success 'reuses the message of a fixup! commit in the range' '\n     ++test_expect_success 'folds fixups whose target is in the range' '\n      +\tgit reset --hard start &&\n     -+\ttest_commit --no-tag reg1 file b &&\n     -+\tgit commit --allow-empty -m \"fixup! reg1\" &&\n     -+\ttest_commit reg2 file c &&\n     ++\ttest_commit --no-tag target file b &&\n     ++\tgit commit --allow-empty -m \"fixup! target\" &&\n     ++\tgit commit --allow-empty -m \"fixup! target\" &&\n     ++\ttest_commit --no-tag later file c &&\n      +\n      +\tgit history squash start.. &&\n      +\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n      +\tgit log --format=\"%s\" -1 >actual &&\n     -+\techo reg1 >expect &&\n     ++\techo target >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success 'refuses a range whose oldest commit is a fixup!' '\n     ++test_expect_success 'refuses a below-range fixup! after an in-range commit' '\n      +\tgit reset --hard start &&\n     -+\ttest_commit --no-tag \"fixup! something\" file b &&\n     -+\ttest_commit --no-tag tail file c &&\n     ++\ttest_commit --no-tag inside file b &&\n     ++\ttest_commit --no-tag \"fixup! outside\" file c &&\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\n      +\ttest_must_fail git history squash start.. 2>err &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp_rev \"$head_before\" HEAD\n      +'\n      +\n     -+test_expect_success 'does not interpret squash! or amend! markers' '\n     ++test_expect_success 'combines a run of fixups for one commit below the range' '\n     ++\tgit reset --hard start &&\n     ++\techo b >file && git add file && git commit -m \"fixup! base\" &&\n     ++\techo c >file && git add file && git commit -m \"fixup! base\" &&\n     ++\n     ++\tgit history squash start.. &&\n     ++\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\tgit log --format=\"%s\" -1 >actual &&\n     ++\techo \"fixup! base\" >expect &&\n     ++\ttest_cmp expect actual\n     ++'\n     ++\n     ++test_expect_success 'combining below-range fixups keeps the last amend! message' '\n     ++\tgit reset --hard start &&\n     ++\techo b >file && git add file && git commit -m \"fixup! base\" &&\n     ++\tprintf \"amend! base\\n\\namended body\\n\" >msg &&\n     ++\techo c >file && git add file && git commit -qF msg &&\n     ++\n     ++\tgit history squash start.. &&\n     ++\n     ++\tgit rev-list --count start..HEAD >count &&\n     ++\techo 1 >expect &&\n     ++\ttest_cmp expect count &&\n     ++\tgit log --format=\"%s\" -1 >actual &&\n     ++\techo \"amend! base\" >expect &&\n     ++\ttest_cmp expect actual &&\n     ++\tgit log --format=\"%b\" -1 >body &&\n     ++\ttest_grep \"amended body\" body\n     ++'\n     ++\n     ++test_expect_success 'refuses fixups for two different commits below the range' '\n     ++\tgit reset --hard start &&\n     ++\techo b >file && git add file && git commit -m \"fixup! aaa\" &&\n     ++\techo c >file && git add file && git commit -m \"fixup! bbb\" &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash start.. 2>err &&\n     ++\ttest_grep \"target is not in the range\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD\n     ++'\n     ++\n     ++test_expect_success 'keeps the oldest message for in-range squash! and amend!' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag marker-oldest file b &&\n      +\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n     @@ t/t3455-history-squash.sh (new)\n      +test_expect_success 'advice.historyUpdateRefs silences the hint' '\n      +\tgit reset --hard three &&\n      +\tgit branch -f mid HEAD~1 &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n      +\n      +\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n      +\t\thistory squash start.. 2>err &&\n      +\ttest_grep \"points into the squashed range\" err &&\n      +\ttest_grep ! \"hint:\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n      +\tgit branch -D mid\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n      +'\n      +\n     -+test_expect_success 'refuses to squash a range with more than one base' '\n     -+\tgit reset --hard start &&\n     -+\tmain=$(git symbolic-ref --short HEAD) &&\n     -+\tgit checkout -b forked-before &&\n     -+\ttest_commit forked-side fside x &&\n     -+\tgit checkout \"$main\" &&\n     -+\ttest_commit forked-base file b &&\n     -+\tbase=$(git rev-parse HEAD) &&\n     -+\ttest_commit forked-main file c &&\n     -+\tgit merge --no-ff -m merge forked-before &&\n     -+\tmerged=$(git rev-parse HEAD) &&\n     -+\n     -+\ttest_must_fail git history squash \"$base..\" 2>err &&\n     -+\ttest_grep \"more than one base\" err &&\n     -+\ttest_cmp_rev \"$merged\" HEAD\n     -+'\n     -+\n      +test_expect_success 'folds a range with two interior merges' '\n      +\tgit reset --hard start &&\n      +\tmain=$(git symbolic-ref --short HEAD) &&\n 4:  001356db93 ! 4:  0a735117ad sequencer: extract helpers for the squash message markers\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    sequencer: extract helpers for the squash message markers\n     +    sequencer: share the squash message marker helpers and flags\n      \n          When \"git rebase -i\" squashes commits it builds an editor template with a\n          \"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\n     @@ Commit message\n          Pull the three pieces out into add_squash_combination_header(),\n          add_squash_message_header() (which takes a flag for the \"will be skipped\"\n          variant) and squash_subject_comment_len(), and use them from\n     -    update_squash_messages() and append_squash_message(). A later change\n     -    reuses them to give \"git history squash --reedit-message\" the same\n     -    template.\n     +    update_squash_messages() and append_squash_message(). Also move the\n     +    todo_item_flags enum to the header, so a caller reading the output of\n     +    todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\n     +    from a plain fixup!. A later change reuses all of this to give \"git\n     +    history squash --reedit-message\" the same template.\n      \n          No change in behavior.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## sequencer.c ##\n     -@@ sequencer.c: static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will\n     +@@ sequencer.c: static int is_pick_or_similar(enum todo_command command)\n     + \t}\n     + }\n     + \n     +-enum todo_item_flags {\n     +-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n     +-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n     +-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n     +-};\n     +-\n     + static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n     + static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n     + static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n       static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n       static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n       \n     @@ sequencer.c: static int update_squash_messages(struct repository *r,\n       \t\t\t\t\t   comment_line_str);\n      \n       ## sequencer.h ##\n     +@@ sequencer.h: enum todo_command {\n     + \tTODO_COMMENT\n     + };\n     + \n     ++/* Bits for the \"flags\" member of struct todo_item */\n     ++enum todo_item_flags {\n     ++\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n     ++\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n     ++\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n     ++};\n     ++\n     + struct todo_item {\n     + \tenum todo_command command;\n     + \tstruct commit *commit;\n      @@ sequencer.h: int todo_list_rearrange_squash(struct todo_list *todo_list);\n        */\n       void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n 5:  615fe4dd3f ! 5:  baf7e6f0a6 history: re-edit a squash with every message\n     @@ Commit message\n      \n          By default \"git history squash\" reuses the oldest commit's message.\n          When --reedit-message is given it only reopened that one message, so the\n     -    messages of the folded-in commits were lost.\n     +    messages of the other commits in the range were lost.\n      \n     -    Gather the messages of every commit in the range, oldest first, and build\n     -    the same editor template that \"git rebase -i\" shows for a squash, using\n     +    Gather the message of every commit in the range and build the same editor\n     +    template that \"git rebase -i --autosquash\" shows for a squash, reusing\n          add_squash_combination_header(), add_squash_message_header() and\n     -    squash_subject_comment_len(). Only the message text differs, the changes\n     -    are always folded in. Following autosquash, a fixup!'s message is\n     -    commented out in full under a \"will be skipped\" header, while a squash! or\n     -    amend! keeps its body with only the marker subject commented.\n     +    squash_subject_comment_len(). Feed the range through\n     +    todo_list_rearrange_squash() so that each fixup!, squash! or amend! is\n     +    grouped under the commit it targets rather than shown in commit order,\n     +    exactly as autosquash would arrange them.\n     +\n     +    Only the message text differs, the changes are always folded in. A fixup!\n     +    message is commented out in full under a \"will be skipped\" header, a\n     +    squash! keeps its body with only the marker subject commented, and an\n     +    amend! replaces its target's message unless a squash! already folded into\n     +    that target, in which case it behaves like a squash!.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-history.adoc ##\n     -@@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], so several arguments may be\n     - given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n     - already on `topic`.\n     +@@ Documentation/git-history.adoc: given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n     + already on `topic`. Rev-list options may also be given, but any that would\n     + change how the range is walked are overridden with a warning.\n       +\n      -The oldest commit's message and authorship are preserved by default,\n      -unless you specify `--reedit-message`. A merge commit inside the range is\n     @@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], s\n       folded like any other, but the range must have a single base, so a range\n       that reaches more than one entry point (for example a side branch that\n       forked before the range and was later merged into it) is rejected.\n     - +\n     - Because the oldest commit's message is reused, the range may not begin\n     - with a `fixup!`, `squash!`, or `amend!` commit, whose target is\n     --necessarily outside the range.\n     -+necessarily outside the range. The changes from every commit in the range\n     -+are always folded in. Only the message text differs. With\n     -+`--reedit-message` the template mirrors `git rebase -i`: the message of a\n     -+`fixup!` elsewhere in the range is commented out in full, while a\n     -+`squash!` or `amend!` keeps its message body with only the marker subject\n     -+commented, so you can fold the remark into the result.\n     +@@ Documentation/git-history.adoc: A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n     + targets is also in the range, so the fold does not silently absorb a\n     + marker meant for a commit outside it. As an exception, a range made up\n     + entirely of markers for one target is combined into a single commit,\n     +-keeping the last `amend!` message if there is one.\n     ++keeping the last `amend!` message if there is one. The changes from every\n     ++commit in the range are always folded in. Only the message text differs.\n     ++With `--reedit-message` the template mirrors `git rebase -i --autosquash`:\n     ++each `fixup!`, `squash!`, or `amend!` is grouped under the commit it\n     ++targets rather than shown in commit order. A `fixup!` message is dropped\n     ++(commented out in full), a `squash!` keeps its body with only the marker\n     ++subject commented, and an `amend!` replaces its target's message, unless\n     ++a `squash!` folded into that target first, in which case it keeps its\n     ++body like a `squash!`.\n       +\n       A branch or tag that points at a commit inside the range would be left\n       dangling once those commits are folded away, so with the default\n     @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi\n       \treturn 0;\n       }\n       \n     ++static bool amend_replaces_target(struct todo_list *todo, int target)\n     ++{\n     ++\tint i;\n     ++\n     ++\tfor (i = target + 1; i < todo->nr &&\n     ++\t\t\t     todo->items[i].command != TODO_PICK; i++) {\n     ++\t\tif (todo->items[i].command == TODO_SQUASH)\n     ++\t\t\treturn false;\n     ++\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n     ++\t\t\treturn true;\n     ++\t}\n     ++\treturn false;\n     ++}\n     ++\n      +static int build_squash_message(struct repository *repo,\n      +\t\t\t\tstruct commit *base,\n      +\t\t\t\tstruct commit *tip,\n      +\t\t\t\tstruct strbuf *out)\n      +{\n     -+\tstruct commit_list *commits = NULL, **tail = &commits, *c;\n      +\tstruct rev_info revs;\n      +\tstruct commit *commit;\n      +\tstruct strvec args = STRVEC_INIT;\n     -+\tint n = 0, total, ret;\n     ++\tstruct todo_list todo = TODO_LIST_INIT;\n     ++\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n     ++\tint i, nr_commits, ret;\n      +\n      +\trepo_init_revisions(repo, &revs, NULL);\n      +\tstrvec_push(&args, \"ignored\");\n     @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi\n      +\t}\n      +\n      +\twhile ((commit = get_revision(&revs)))\n     -+\t\ttail = &commit_list_insert(commit, tail)->next;\n     -+\ttotal = commit_list_count(commits);\n     ++\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n     ++\t\t\t    oid_to_hex(&commit->object.oid));\n      +\n     -+\tfor (c = commits; c; c = c->next) {\n     ++\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n     ++\t    todo_list_rearrange_squash(&todo) < 0) {\n     ++\t\tret = error(_(\"could not prepare the squash message\"));\n     ++\t\tgoto out;\n     ++\t}\n     ++\n     ++\tnr_commits = todo.nr;\n     ++\tfor (i = 0; i < nr_commits; i++) {\n     ++\t\tstruct todo_item *item = &todo.items[i];\n      +\t\tconst char *message, *body;\n      +\t\tsize_t commented_len;\n     -+\t\tint skip;\n     ++\t\tbool skip, squashing;\n      +\n     -+\t\tmessage = repo_logmsg_reencode(repo, c->item, NULL, NULL);\n     ++\t\tsquashing = item->command == TODO_SQUASH ||\n     ++\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n     ++\t\tif (item->command == TODO_PICK)\n     ++\t\t\tskip = amend_replaces_target(&todo, i);\n     ++\t\telse\n     ++\t\t\tskip = !squashing;\n     ++\n     ++\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n      +\t\tfind_commit_subject(message, &body);\n      +\n     -+\t\tskip = starts_with(body, \"fixup! \");\n     -+\t\tcommented_len = skip ? strlen(body) :\n     -+\t\t\tsquash_subject_comment_len(body, 1);\n     ++\t\tif (skip)\n     ++\t\t\tcommented_len = strlen(body);\n     ++\t\telse if (squashing)\n     ++\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n     ++\t\telse\n     ++\t\t\tcommented_len = 0;\n      +\n     -+\t\tif (!n)\n     -+\t\t\tadd_squash_combination_header(out, total);\n     ++\t\tif (!i)\n     ++\t\t\tadd_squash_combination_header(out, nr_commits);\n      +\t\tstrbuf_addch(out, '\\n');\n     -+\t\tadd_squash_message_header(out, ++n, skip);\n     ++\t\tadd_squash_message_header(out, i + 1, skip);\n      +\t\tstrbuf_addstr(out, \"\\n\\n\");\n      +\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n      +\t\tstrbuf_addstr(out, body + commented_len);\n      +\t\tstrbuf_complete_line(out);\n      +\n     -+\t\trepo_unuse_commit_buffer(repo, c->item, message);\n     ++\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n      +\t}\n      +\n      +\tret = 0;\n      +\n      +out:\n     -+\tcommit_list_free(commits);\n     ++\ttodo_list_release(&todo);\n     ++\treplay_opts_release(&opts);\n      +\treset_revision_walk();\n      +\trelease_revisions(&revs);\n      +\tstrvec_clear(&args);\n     @@ builtin/history.c: static int cmd_history_squash(int argc,\n       \tstruct strbuf reflog_msg = STRBUF_INIT;\n      +\tstruct strbuf message = STRBUF_INIT;\n       \tstruct oidset interior = OIDSET_INIT;\n     - \tstruct commit *base, *oldest, *tip, *rewritten;\n     + \tstruct commit *base, *oldest, *tip, *rewritten, *msg_source;\n       \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n      @@ builtin/history.c: static int cmd_history_squash(int argc,\n       \t\t}\n     @@ builtin/history.c: static int cmd_history_squash(int argc,\n       \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n       \tcommit_list_append(base, &parents);\n       \n     --\tret = commit_tree_ext(repo, \"squash\", oldest, NULL, parents,\n     -+\tret = commit_tree_ext(repo, \"squash\", oldest,\n     +-\tret = commit_tree_ext(repo, \"squash\", msg_source, NULL, parents,\n     ++\tret = commit_tree_ext(repo, \"squash\", msg_source,\n      +\t\t\t      message.len ? message.buf : NULL, parents,\n       \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n       \tif (ret < 0) {\n     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +\ttest_grep \"amended message\" final\n      +'\n      +\n     ++test_expect_success '--reedit-message groups fixups under their targets' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag alpha file a1 &&\n     ++\ttest_commit --no-tag beta file b1 &&\n     ++\tprintf \"fixup! alpha\\n\" >msg &&\n     ++\techo a2 >file &&\n     ++\tgit add file &&\n     ++\tgit commit -qF msg &&\n     ++\tprintf \"fixup! beta\\n\" >msg &&\n     ++\techo b2 >file &&\n     ++\tgit add file &&\n     ++\tgit commit -qF msg &&\n     ++\n     ++\twrite_script editor <<-\\EOF &&\n     ++\tcat \"$1\" >edited\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n     ++\tgit history squash --reedit-message start.. &&\n     ++\n     ++\tcat >expect <<-EOF &&\n     ++\t# This is a combination of 4 commits.\n     ++\t# This is the 1st commit message:\n     ++\n     ++\talpha\n     ++\n     ++\t# The commit message #2 will be skipped:\n     ++\n     ++\t# fixup! alpha\n     ++\n     ++\t# This is the commit message #3:\n     ++\n     ++\tbeta\n     ++\n     ++\t# The commit message #4 will be skipped:\n     ++\n     ++\t# fixup! beta\n     ++\n     ++\t# Please enter the commit message for the squash changes. Lines starting\n     ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     ++\t# Changes to be committed:\n     ++\t#\tmodified:   file\n     ++\t#\n     ++\tEOF\n     ++\ttest_cmp expect edited\n     ++'\n     ++\n     ++test_expect_success '--reedit-message lets amend! replace its target message' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag mark-base file b &&\n     ++\tprintf \"amend! mark-base\\n\\namended message\\n\" >msg &&\n     ++\techo c >file &&\n     ++\tgit add file &&\n     ++\tgit commit -qF msg &&\n     ++\tprintf \"squash! mark-base\\n\\nsquash remark\\n\" >msg &&\n     ++\techo d >file &&\n     ++\tgit add file &&\n     ++\tgit commit -qF msg &&\n     ++\n     ++\twrite_script editor <<-\\EOF &&\n     ++\tcat \"$1\" >edited\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n     ++\tgit history squash --reedit-message start.. &&\n     ++\n     ++\tcat >expect <<-EOF &&\n     ++\t# This is a combination of 3 commits.\n     ++\t# The 1st commit message will be skipped:\n     ++\n     ++\t# mark-base\n     ++\n     ++\t# This is the commit message #2:\n     ++\n     ++\t# amend! mark-base\n     ++\n     ++\tamended message\n     ++\n     ++\t# This is the commit message #3:\n     ++\n     ++\t# squash! mark-base\n     ++\n     ++\tsquash remark\n     ++\n     ++\t# Please enter the commit message for the squash changes. Lines starting\n     ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     ++\t# Changes to be committed:\n     ++\t#\tmodified:   file\n     ++\t#\n     ++\tEOF\n     ++\ttest_cmp expect edited &&\n     ++\tgit log -1 --format=\"%B\" >final &&\n     ++\ttest_grep ! \"mark-base\" final &&\n     ++\ttest_grep \"amended message\" final &&\n     ++\ttest_grep \"squash remark\" final\n     ++'\n     ++\n      +test_expect_success '--reedit-message aborts on an empty message' '\n      +\tgit reset --hard three &&\n      +\thead_before=$(git rev-parse HEAD) &&\n\n-- \ngitgitgadget\n"},{"id":"547702","messageId":"ba77752282e400de0af7082d54d861596e78fa93.1783674396.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com","subject":"[PATCH v8 1/5] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-10T09:06:32Z","receivedAt":"2026-07-10T09:06:43Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex fd83de8265..9f516687fe 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -157,6 +157,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"547703","messageId":"50f35728876bee48671b7af766d51e2493f2e281.1783674396.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com","subject":"[PATCH v8 2/5] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-10T09:06:33Z","receivedAt":"2026-07-10T09:06:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 9f516687fe..cbba25096f 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -101,6 +101,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"547704","messageId":"2d81a40a051fc24afedaee8cc82b73e1433dac75.1783674396.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com","subject":"[PATCH v8 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-10T09:06:34Z","receivedAt":"2026-07-10T09:06:50Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into the oldest one, keeping that commit's\nmessage and authorship and taking the tree of the newest commit, then\nreplays the commits above the range on top. The squashed message comes\nfrom the oldest commit, or from an editor with --reedit-message. A\nfixup!, squash! or amend! commit is refused unless the commit it targets\nis also in the range, so the fold does not silently absorb a marker meant\nfor a commit outside it. The check runs the range through\ntodo_list_rearrange_squash(), which leaves such a marker as a plain pick.\nMarkers whose target is in the range fold in as usual. As an exception, a\nrange made up entirely of markers for one target is combined anyway,\ntaking its message from the last amend! if there is one, so a batch of\nfixups for the same commit can be collapsed.\n\nThe range is read like the arguments to \"git rev-list\", so several\nrevisions such as \"HEAD~3..HEAD ^topic\" may be given, and rev-list\noptions are accepted too. As \"git replay\" does, the walk options the fold\nrelies on are forced after setup_revisions() and a warning is printed if\nan option changed them, so the first commit returned is the range's\noldest and its parent is the base regardless of what the user passed\n(including after a \"--\"). A merge inside the range is folded when its\nother parent is reachable from the base, otherwise the range has more\nthan one base and is rejected. By default the command also refuses when a\nref points at a commit that the fold would discard. Use --update-refs=head\nto rewrite only the current branch instead.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  45 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 343 +++++++++++++++++++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 566 +++++++++++++++++++++++++++++++\n 7 files changed, 958 insertions(+), 3 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex c3c190ba6a..cd27f7e795 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -59,6 +59,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 2ba8121795..ad921ac2be 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -11,6 +11,7 @@ SYNOPSIS\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -42,8 +43,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold a\n+merge that lies inside the range, as long as the range has a single base.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -97,6 +101,40 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's message and\n+\tauthorship and takes the tree of the range's newest commit, so the\n+\twhole range collapses into a single commit. Commits above the range\n+\tare replayed on top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. Several revisions may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`. Rev-list options may also be given, but any that would\n+change how the range is walked are overridden with a warning.\n++\n+The oldest commit's message and authorship are preserved by default,\n+unless you specify `--reedit-message`. A merge commit inside the range is\n+folded like any other, but the range must have a single base, so a range\n+that reaches more than one entry point (for example a side branch that\n+forked before the range and was later merged into it) is rejected.\n++\n+A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n+targets is also in the range, so the fold does not silently absorb a\n+marker meant for a commit outside it. As an exception, a range made up\n+entirely of markers for one target is combined into a single commit,\n+keeping the last `amend!` message if there is one.\n++\n+A branch or tag that points at a commit inside the range would be left\n+dangling once those commits are folded away, so with the default\n+`--update-refs=branches` the command refuses. Rerun with\n+`--update-refs=head` to rewrite only the current branch and leave such\n+refs pointing at the old commits.\n+\n OPTIONS\n -------\n \n@@ -107,7 +145,8 @@ OPTIONS\n \tref updates is generally safe.\n \n `--reedit-message`::\n-\tOpen an editor to modify the target commit's message.\n+\tOpen an editor to modify the rewritten commit's message. For `squash`\n+\tthe editor is pre-filled with the messages of all the folded commits.\n \n `--empty=(drop|keep|abort)`::\n \tControl what happens when a commit becomes empty as a result of the\ndiff --git a/advice.c b/advice.c\nindex 0018501b7b..5c6ff95e31 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 8def280688..911b4e4643 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex cbba25096f..d3f535ded9 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -30,6 +31,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -973,6 +976,344 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char **argv,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tstruct oidset *interior_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct commit_list *boundaries = NULL, *b;\n+\tstruct strvec args = STRVEC_INIT;\n+\tsize_t i;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\trevs.reverse = 1;\n+\trevs.topo_order = 1;\n+\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\trevs.simplify_history = 0;\n+\trevs.boundary = 1;\n+\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--ancestry-path\");\n+\tstrvec_pushv(&args, argv);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n+\t\tgoto out;\n+\t}\n+\n+\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n+\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n+\t    revs.simplify_history != 0) {\n+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n+\t\t\t  \"range is walked\"));\n+\t\trevs.reverse = 1;\n+\t\trevs.topo_order = 1;\n+\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\t\trevs.simplify_history = 0;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (i = 0; i < revs.cmdline.nr; i++)\n+\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n+\t\t\tbreak;\n+\tif (i == revs.cmdline.nr) {\n+\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Set boundary commits aside for the base check below, and put every\n+\t * in-range commit but the tip into the interior set. A ref pointing\n+\t * at an interior commit would dangle once the range is folded away.\n+\t */\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tcommit_list_insert(commit, &boundaries);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\tif (tip)\n+\t\t\toidset_insert(interior_out, &tip->object.oid);\n+\t\ttip = commit;\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t} else if (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t} else if (!oldest->parents) {\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\t}\n+\tbase = oldest->parents->item;\n+\n+\t/*\n+\t * A boundary other than the base is an in-range commit reaching a\n+\t * commit outside the range, so the range has more than one base.\n+\t */\n+\tfor (b = boundaries; b; b = b->next) {\n+\t\tif (b->item != base) {\n+\t\t\tret = error(_(\"the revision range has more than one base; \"\n+\t\t\t\t      \"cannot squash\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tcommit_list_free(boundaries);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+static const char *autosquash_target(const char *subject)\n+{\n+\tconst char *rest;\n+\n+\twhile (skip_prefix(subject, \"fixup! \", &rest) ||\n+\t       skip_prefix(subject, \"squash! \", &rest) ||\n+\t       skip_prefix(subject, \"amend! \", &rest))\n+\t\tsubject = rest;\n+\treturn subject;\n+}\n+\n+static int reject_dangling_fixups(struct repository *repo,\n+\t\t\t\t  struct commit *base,\n+\t\t\t\t  struct commit *tip,\n+\t\t\t\t  struct commit *oldest,\n+\t\t\t\t  struct commit **msg_source)\n+{\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *last_amend = NULL;\n+\tstruct strvec args = STRVEC_INIT;\n+\tchar *dangling_subject = NULL, *dangling_target = NULL;\n+\tbool mixed_target = false, all_fixups_one_target;\n+\tint i, ret, nr_dangling = 0;\n+\n+\t*msg_source = oldest;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\twhile ((commit = get_revision(&revs)))\n+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n+\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not check the range for fixups\"));\n+\t\tgoto out;\n+\t}\n+\n+\tfor (i = 0; i < todo.nr; i++) {\n+\t\tconst char *message, *subject_start, *target;\n+\t\tchar *subject;\n+\t\tsize_t sublen;\n+\n+\t\tif (todo.items[i].command != TODO_PICK)\n+\t\t\tcontinue;\n+\t\tmessage = repo_logmsg_reencode(repo, todo.items[i].commit,\n+\t\t\t\t\t       NULL, NULL);\n+\t\tsublen = find_commit_subject(message, &subject_start);\n+\t\tsubject = xmemdupz(subject_start, sublen);\n+\t\ttarget = autosquash_target(subject);\n+\t\tif (target != subject) {\n+\t\t\tnr_dangling++;\n+\t\t\tif (!dangling_target) {\n+\t\t\t\tdangling_target = xstrdup(target);\n+\t\t\t\tdangling_subject = xstrdup(subject);\n+\t\t\t} else if (strcmp(dangling_target, target)) {\n+\t\t\t\tmixed_target = true;\n+\t\t\t}\n+\t\t\tif (starts_with(subject, \"amend! \"))\n+\t\t\t\tlast_amend = todo.items[i].commit;\n+\t\t}\n+\t\tfree(subject);\n+\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n+\t}\n+\n+\tall_fixups_one_target = nr_dangling == todo.nr && !mixed_target;\n+\tif (nr_dangling && !all_fixups_one_target) {\n+\t\tret = error(_(\"cannot squash '%s': its target is not in the \"\n+\t\t\t      \"range\"), dangling_subject);\n+\t} else {\n+\t\tif (last_amend)\n+\t\t\t*msg_source = last_amend;\n+\t\tret = 0;\n+\t}\n+\n+out:\n+\tfree(dangling_subject);\n+\tfree(dangling_target);\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+struct interior_ref_cb {\n+\tconst struct oidset *interior;\n+\tconst char *name;\n+};\n+\n+static int find_interior_ref(const struct reference *ref, void *cb_data)\n+{\n+\tstruct interior_ref_cb *data = cb_data;\n+\n+\tif (oidset_contains(data->interior, ref->oid)) {\n+\t\tdata->name = xstrdup(ref->name);\n+\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct oidset interior = OIDSET_INIT;\n+\tstruct commit *base, *oldest, *tip, *rewritten, *msg_source;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage,\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT);\n+\tif (!argc) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n+\t\t\t\t   &interior);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tif (action == REF_ACTION_BRANCHES) {\n+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n+\n+\t\trefs_for_each_ref(get_main_ref_store(repo),\n+\t\t\t\t  find_interior_ref, &cb);\n+\t\tif (cb.name) {\n+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n+\t\t\t\t    cb.name);\n+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t\t    \"untouched.\"));\n+\t\t\tfree((char *)cb.name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", msg_source, NULL, parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\toidset_clear(&interior);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n int cmd_history(int argc,\n \t\tconst char **argv,\n \t\tconst char *prefix,\n@@ -982,6 +1323,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -989,6 +1331,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex 7c3c070426..459e251623 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -400,6 +400,7 @@ integration_tests = [\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..6bc0a89ebc\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,566 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\ttest_commit --no-tag one other x &&\n+\ttest_commit --no-tag two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..HEAD ^keep &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n+\tgit checkout \"$main\" &&\n+\n+\tgit history squash start..off-history &&\n+\n+\tgit rev-list --count start..off-history >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo one >expect &&\n+\ttest_cmp expect subject &&\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'sanitizes rev-list walk options, before and after --' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --date-order start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash -- --reverse start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\tgit log --format=\"%s\" start..HEAD >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\ttest_cmp expect actual &&\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$root..\" &&\n+\n+\tgit rev-list --count \"$root..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+\n+test_expect_success 'folds fixups whose target is in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag target file b &&\n+\tgit commit --allow-empty -m \"fixup! target\" &&\n+\tgit commit --allow-empty -m \"fixup! target\" &&\n+\ttest_commit --no-tag later file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo target >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'refuses a below-range fixup! after an in-range commit' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag inside file b &&\n+\ttest_commit --no-tag \"fixup! outside\" file c &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'combines a run of fixups for one commit below the range' '\n+\tgit reset --hard start &&\n+\techo b >file && git add file && git commit -m \"fixup! base\" &&\n+\techo c >file && git add file && git commit -m \"fixup! base\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo \"fixup! base\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'combining below-range fixups keeps the last amend! message' '\n+\tgit reset --hard start &&\n+\techo b >file && git add file && git commit -m \"fixup! base\" &&\n+\tprintf \"amend! base\\n\\namended body\\n\" >msg &&\n+\techo c >file && git add file && git commit -qF msg &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo \"amend! base\" >expect &&\n+\ttest_cmp expect actual &&\n+\tgit log --format=\"%b\" -1 >body &&\n+\ttest_grep \"amended body\" body\n+'\n+\n+test_expect_success 'refuses fixups for two different commits below the range' '\n+\tgit reset --hard start &&\n+\techo b >file && git add file && git commit -m \"fixup! aaa\" &&\n+\techo c >file && git add file && git commit -m \"fixup! bbb\" &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'keeps the oldest message for in-range squash! and amend!' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag marker-oldest file b &&\n+\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n+\tgit commit --allow-empty -m \"amend! marker-oldest\" &&\n+\ttest_commit --no-tag marker-newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\techo marker-oldest >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'preserves authorship of the oldest commit' '\n+\tgit reset --hard start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit --no-tag oldest file b &&\n+\ttest_commit newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"error: .* points into the squashed range\" err &&\n+\ttest_grep \"hint: .*--update-refs=head\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\tmid_before=$(git rev-parse mid) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'refuses to fold a range a tag points into' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"refs/tags/mark\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit tag -d mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\tgit log --format=\"%s\" -1 >subject &&\n+\techo before-side >expect &&\n+\ttest_cmp expect subject &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$base..\" &&\n+\n+\tgit rev-list --count \"$base..HEAD\" >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit rev-list --count start..HEAD >count &&\n+\techo 1 >expect &&\n+\ttest_cmp expect count &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"at-merge\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"547705","messageId":"0a735117ad16f10013823b4c70c171962b4ce235.1783674396.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com","subject":"[PATCH v8 4/5] sequencer: share the squash message marker helpers and flags","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-10T09:06:35Z","receivedAt":"2026-07-10T09:06:53Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). Also move the\ntodo_item_flags enum to the header, so a caller reading the output of\ntodo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\nfrom a plain fixup!. A later change reuses all of this to give \"git\nhistory squash --reedit-message\" the same template.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 70 +++++++++++++++++++++++++++++------------------------\n sequencer.h | 30 +++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 31 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 0fe8fed6c3..2387afd9b5 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)\n \t}\n }\n \n-enum todo_item_flags {\n-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n-};\n-\n static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 64a9c7fb1b..b01f897020 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -119,6 +119,13 @@ enum todo_command {\n \tTODO_COMMENT\n };\n \n+/* Bits for the \"flags\" member of struct todo_item */\n+enum todo_item_flags {\n+\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n+\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n+\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n+};\n+\n struct todo_item {\n \tenum todo_command command;\n \tstruct commit *commit;\n@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"547706","messageId":"baf7e6f0a6a901d4d5ca144e9f2906d4907ec1c9.1783674396.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com","subject":"[PATCH v8 5/5] history: re-edit a squash with every message","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-10T09:06:36Z","receivedAt":"2026-07-10T09:06:56Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBy default \"git history squash\" reuses the oldest commit's message.\nWhen --reedit-message is given it only reopened that one message, so the\nmessages of the other commits in the range were lost.\n\nGather the message of every commit in the range and build the same editor\ntemplate that \"git rebase -i --autosquash\" shows for a squash, reusing\nadd_squash_combination_header(), add_squash_message_header() and\nsquash_subject_comment_len(). Feed the range through\ntodo_list_rearrange_squash() so that each fixup!, squash! or amend! is\ngrouped under the commit it targets rather than shown in commit order,\nexactly as autosquash would arrange them.\n\nOnly the message text differs, the changes are always folded in. A fixup!\nmessage is commented out in full under a \"will be skipped\" header, a\nsquash! keeps its body with only the marker subject commented, and an\namend! replaces its target's message unless a squash! already folded into\nthat target, in which case it behaves like a squash!.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  15 ++-\n builtin/history.c              | 107 ++++++++++++++++-\n t/t3455-history-squash.sh      | 210 +++++++++++++++++++++++++++++++++\n 3 files changed, 328 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex ad921ac2be..697155f5d8 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -117,8 +117,9 @@ given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n already on `topic`. Rev-list options may also be given, but any that would\n change how the range is walked are overridden with a warning.\n +\n-The oldest commit's message and authorship are preserved by default,\n-unless you specify `--reedit-message`. A merge commit inside the range is\n+The oldest commit's message and authorship are preserved by default. With\n+`--reedit-message`, an editor opens pre-filled with the messages of all the\n+folded commits so you can combine them. A merge commit inside the range is\n folded like any other, but the range must have a single base, so a range\n that reaches more than one entry point (for example a side branch that\n forked before the range and was later merged into it) is rejected.\n@@ -127,7 +128,15 @@ A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n targets is also in the range, so the fold does not silently absorb a\n marker meant for a commit outside it. As an exception, a range made up\n entirely of markers for one target is combined into a single commit,\n-keeping the last `amend!` message if there is one.\n+keeping the last `amend!` message if there is one. The changes from every\n+commit in the range are always folded in. Only the message text differs.\n+With `--reedit-message` the template mirrors `git rebase -i --autosquash`:\n+each `fixup!`, `squash!`, or `amend!` is grouped under the commit it\n+targets rather than shown in commit order. A `fixup!` message is dropped\n+(commented out in full), a `squash!` keeps its body with only the marker\n+subject commented, and an `amend!` replaces its target's message, unless\n+a `squash!` folded into that target first, in which case it keeps its\n+body like a `squash!`.\n +\n A branch or tag that points at a commit inside the range would be left\n dangling once those commits are folded away, so with the default\ndiff --git a/builtin/history.c b/builtin/history.c\nindex d3f535ded9..f82001508d 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1211,6 +1211,102 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n+static bool amend_replaces_target(struct todo_list *todo, int target)\n+{\n+\tint i;\n+\n+\tfor (i = target + 1; i < todo->nr &&\n+\t\t\t     todo->items[i].command != TODO_PICK; i++) {\n+\t\tif (todo->items[i].command == TODO_SQUASH)\n+\t\t\treturn false;\n+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tint i, nr_commits, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs)))\n+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n+\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not prepare the squash message\"));\n+\t\tgoto out;\n+\t}\n+\n+\tnr_commits = todo.nr;\n+\tfor (i = 0; i < nr_commits; i++) {\n+\t\tstruct todo_item *item = &todo.items[i];\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tbool skip, squashing;\n+\n+\t\tsquashing = item->command == TODO_SQUASH ||\n+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n+\t\tif (item->command == TODO_PICK)\n+\t\t\tskip = amend_replaces_target(&todo, i);\n+\t\telse\n+\t\t\tskip = !squashing;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tif (skip)\n+\t\t\tcommented_len = strlen(body);\n+\t\telse if (squashing)\n+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n+\t\telse\n+\t\t\tcommented_len = 0;\n+\n+\t\tif (!i)\n+\t\t\tadd_squash_combination_header(out, nr_commits);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, i + 1, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1235,6 +1331,7 @@ static int cmd_history_squash(int argc,\n \t\tOPT_END(),\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n \tstruct oidset interior = OIDSET_INIT;\n \tstruct commit *base, *oldest, *tip, *rewritten, *msg_source;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n@@ -1279,6 +1376,12 @@ static int cmd_history_squash(int argc,\n \t\t}\n \t}\n \n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n+\n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1287,7 +1390,8 @@ static int cmd_history_squash(int argc,\n \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n \tcommit_list_append(base, &parents);\n \n-\tret = commit_tree_ext(repo, \"squash\", msg_source, NULL, parents,\n+\tret = commit_tree_ext(repo, \"squash\", msg_source,\n+\t\t\t      message.len ? message.buf : NULL, parents,\n \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n@@ -1308,6 +1412,7 @@ static int cmd_history_squash(int argc,\n \n out:\n \tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n \toidset_clear(&interior);\n \tcommit_list_free(parents);\n \trelease_revisions(&revs);\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 6bc0a89ebc..9b24f546e0 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -250,6 +250,216 @@ test_expect_success 'preserves authorship of the oldest commit' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--reedit-message offers every folded-in message' '\n+\tgit reset --hard start &&\n+\techo b >file &&\n+\tgit add file &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\techo combined >expect &&\n+\tgit log --format=\"%s\" -1 >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tprintf \"fixup! mark-base\\n\\nfixup body\\n\" >msg &&\n+\techo c >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\tprintf \"squash! mark-base\\n\\nsquash remark\\n\" >msg &&\n+\techo d >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\tprintf \"amend! mark-base\\n\\namended message\\n\" >msg &&\n+\techo e >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tgit log -1 --format=\"%B\" >final &&\n+\ttest_grep ! \"fixup body\" final &&\n+\ttest_grep \"squash remark\" final &&\n+\ttest_grep \"amended message\" final\n+'\n+\n+test_expect_success '--reedit-message groups fixups under their targets' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag alpha file a1 &&\n+\ttest_commit --no-tag beta file b1 &&\n+\tprintf \"fixup! alpha\\n\" >msg &&\n+\techo a2 >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\tprintf \"fixup! beta\\n\" >msg &&\n+\techo b2 >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\talpha\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! alpha\n+\n+\t# This is the commit message #3:\n+\n+\tbeta\n+\n+\t# The commit message #4 will be skipped:\n+\n+\t# fixup! beta\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited\n+'\n+\n+test_expect_success '--reedit-message lets amend! replace its target message' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tprintf \"amend! mark-base\\n\\namended message\\n\" >msg &&\n+\techo c >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\tprintf \"squash! mark-base\\n\\nsquash remark\\n\" >msg &&\n+\techo d >file &&\n+\tgit add file &&\n+\tgit commit -qF msg &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# The 1st commit message will be skipped:\n+\n+\t# mark-base\n+\n+\t# This is the commit message #2:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tgit log -1 --format=\"%B\" >final &&\n+\ttest_grep ! \"mark-base\" final &&\n+\ttest_grep \"amended message\" final &&\n+\ttest_grep \"squash remark\" final\n+'\n+\n+test_expect_success '--reedit-message aborts on an empty message' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\ttest_must_fail git history squash --reedit-message start.. &&\n+\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n test_expect_success '--update-refs=head only moves HEAD' '\n \tgit reset --hard three &&\n \tgit branch -f other HEAD &&\n-- \ngitgitgadget\n"},{"id":"548075","messageId":"DJY0QSJYNG0J.210HZQH198Y1N@lfurio.us","threadId":"65812","inReplyTo":"pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com","subject":"Re: [PATCH v8 0/5] history: add squash subcommand to fold a range","fromName":"Matt Hunter","fromEmail":"m@lfurio.us","sentAt":"2026-07-14T04:44:58Z","receivedAt":"2026-07-14T04:45:05Z","isPatch":true,"body":"On Fri Jul 10, 2026 at 5:06 AM EDT, Harald Nordgren via GitGitGadget wrote:\n> Adds git history squash <revision-range> to fold a range of commits.\n>\n> Changes in v8:\n>\n>  * --reedit-message now builds the same editor template as git rebase -i\n>    --autosquash: fixup!, squash! and amend! commits are grouped under the\n>    commit they target instead of shown in commit order, and an amend!\n>    replaces its target's message.\n>  * A fixup!, squash! or amend! is refused only when its target is outside\n>    the range, so several fixups for an in-range commit fold together. A\n>    range that is entirely markers for one below-range target is combined\n>    into a single commit, keeping the last amend! message.\n>  * Merges inside the range are folded when the range has a single base, with\n>    no dedicated opt-in flag, --ancestry-path ensures only commits descended\n>    from the base are folded, and a range reaching more than one base is\n>    rejected.\n>  * Rev-list options are accepted and sanitized the way git replay does,\n>    forcing the walk order back with a warning, which also fixes git history\n>    squash -- --reverse slipping past the previous option check.\n>  * Kept this as an explicit squash subcommand rather than making\n>    --reedit-message the default or renaming the command.\n\nThis feature looks like it's coming together pretty well imo.  I just have\none observation I want to comment on:\n\nI noticed that 'git history squash <range>', when --reedit-message is\nomitted, will ignore any amend! message in the range that targets the\nfirst folded commit.\n\nOn the surface, this makes sense.  The feature is pretty explicit that\nit will faithfully stick with the first commit's message, unless\nmodified by use of --reedit-message.\n\nHowever, this edge case is a little surprising, given that\n'git history squash' seems to be aware of the semantics of fixup!, amend!,\nand squash! messages whether --reedit-message was given or not.  For instance,\nthe default command notices when the range contains a squash! commit whose\ntarget is elsewhere (a useful feature).  It seems consistent then, that the\ndefault command would incorporate an amend! it is aware of when placing the\n\"first commit's\" message in the resulting squash.  This seems useful to me\nas well.\n\nAt the same time, I can understand why the current implementation does\nwhat it does.  So I'm not entirely sure what the correct answer is here.\n\nI'll mention as well that I really like the decisions made for how this\ncommand handles squashing a bunch of related fixups.  This \"fixup\nconsolidation\" is a use-case that this command may steal away from rebase\nfor me.  And the way a final amend! is handled in this case is what got me\nthinking about it in the general case.\n\nThanks for the work on this topic!\n"},{"id":"548092","messageId":"CAHwyqnVVYMqYTD=Hri1gYW6CvkjKgTMv8AGP59bkOOd+-huwbg@mail.gmail.com","threadId":"65812","inReplyTo":"DJY0QSJYNG0J.210HZQH198Y1N@lfurio.us","subject":"Re: [PATCH v8 0/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-14T08:38:34Z","receivedAt":"2026-07-14T08:39:19Z","isPatch":true,"body":"> This feature looks like it's coming together pretty well imo.  I just have\n> one observation I want to comment on:\n>\n> I noticed that 'git history squash <range>', when --reedit-message is\n> omitted, will ignore any amend! message in the range that targets the\n> first folded commit.\n>\n> On the surface, this makes sense.  The feature is pretty explicit that\n> it will faithfully stick with the first commit's message, unless\n> modified by use of --reedit-message.\n>\n> However, this edge case is a little surprising, given that\n> 'git history squash' seems to be aware of the semantics of fixup!, amend!,\n> and squash! messages whether --reedit-message was given or not.  For instance,\n> the default command notices when the range contains a squash! commit whose\n> target is elsewhere (a useful feature).  It seems consistent then, that the\n> default command would incorporate an amend! it is aware of when placing the\n> \"first commit's\" message in the resulting squash.  This seems useful to me\n> as well.\n>\n> At the same time, I can understand why the current implementation does\n> what it does.  So I'm not entirely sure what the correct answer is here.\n>\n> I'll mention as well that I really like the decisions made for how this\n> command handles squashing a bunch of related fixups.  This \"fixup\n> consolidation\" is a use-case that this command may steal away from rebase\n> for me.  And the way a final amend! is handled in this case is what got me\n> thinking about it in the general case.\n>\n> Thanks for the work on this topic!\n\nThanks!\n\nThat's an interesting observation, I'll see what I can do about it.\n\n\nHarald\n"},{"id":"548095","messageId":"CAHwyqnVYQ6Sk=4ot6=5AbUdqxCrwS15xt_+wX3DB1h369CSqsA@mail.gmail.com","threadId":"65812","inReplyTo":"CAHwyqnVVYMqYTD=Hri1gYW6CvkjKgTMv8AGP59bkOOd+-huwbg@mail.gmail.com","subject":"Re: [PATCH v8 0/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-14T09:04:19Z","receivedAt":"2026-07-14T09:05:01Z","isPatch":true,"body":"I made a fix for this and also took the opportunity to create test\nhelpers to clarify the tests.\n\nSame pattern as the ones @Phillip Wood helped me with on my\n'delete-merged' topic. I would like to push out a new version before\nanyone needs to review the current tests -- the new version would be a\nlot easier to look at. Should I?\n\n\nHarald\n"},{"id":"548124","messageId":"0ECE2A94-0537-42E0-A525-FA16184D7735@gmail.com","threadId":"65812","inReplyTo":"DJY0QSJYNG0J.210HZQH198Y1N@lfurio.us","subject":"Re: [PATCH v8 0/5] history: add squash subcommand to fold a range","fromName":"Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-07-14T12:36:07Z","receivedAt":"2026-07-14T12:36:21Z","isPatch":true,"body":"\n> Le 14 juil. 2026 à 00:45, Matt Hunter <m@lfurio.us> a écrit :\n> \n> ﻿On Fri Jul 10, 2026 at 5:06 AM EDT, Harald Nordgren via GitGitGadget wrote:\n>> Adds git history squash <revision-range> to fold a range of commits.\n\n[snip]\n\n> I'll mention as well that I really like the decisions made for how this\n> command handles squashing a bunch of related fixups.  This \"fixup\n> consolidation\" is a use-case that this command may steal away from rebase\n> for me.  And the way a final amend! is handled in this case is what got me\n> thinking about it in the general case.\n> \n> Thanks for the work on this topic!\n\nDitto! I suspect that using a combination of « git history squash » and « git replay » to emulate « git rebase » in non-interactive autosquash mode will be much faster, too, due to the differences in implementation. If that proves to be the case and we can safely do so with feature compatibility, I wonder if it will be worth making the non-interactive autosquash rebase actually delegate through a history squash + replay.\n\nI’m sure there’s a few instances that couldn’t be done (for example when the special! commits cross the current range and upstream; that is, a fixup! for an upstream commit or some such oddity;; there are also conflicts to consider), but in the cases it can be it ought to be a performance win."},{"id":"548159","messageId":"xmqqzezts9an.fsf@gitster.g","threadId":"65812","inReplyTo":"0ECE2A94-0537-42E0-A525-FA16184D7735@gmail.com","subject":"Re: [PATCH v8 0/5] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-14T18:41:52Z","receivedAt":"2026-07-14T18:41:56Z","isPatch":true,"body":"Ben Knoble <ben.knoble@gmail.com> writes:\n\n>> Thanks for the work on this topic!\n>\n> Ditto! I suspect that using a combination of « git history squash\n> » and « git replay » to emulate « git rebase » in non-interactive\n> autosquash mode will be much faster, too, due to the differences\n> in implementation. If that proves to be the case and we can safely\n> do so with feature compatibility, I wonder if it will be worth\n> making the non-interactive autosquash rebase actually delegate\n> through a history squash + replay.\n\nYes, that would be an ideal future, and these efforts move us in\nthat direction.\n\n> I’m sure there’s a few instances that couldn’t be done (for\n> example when the special! commits cross the current range and\n> upstream; that is, a fixup! for an upstream commit or some such\n> oddity;; there are also conflicts to consider), but in the cases\n> it can be it ought to be a performance win.\n\nSince you assume \"we can safely do so with feature compatibility\"\nabove, once we are finished, there will, by definition, be no\nsuch \"special\" commits that the combination cannot handle.  By\nthe time that happens, we will have replaced the internals of\n\"rebase [-i]\" with a new implementation that does not need to\ntouch the working tree.\n\nThat would indeed be an exciting future.\n"},{"id":"548283","messageId":"pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v8.git.git.1783674396.gitgitgadget@gmail.com","subject":"[PATCH v9 0/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-15T15:16:08Z","receivedAt":"2026-07-15T15:16:17Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v9:\n\n * Use the last amend! targeting the oldest folded commit as the default\n   squashed message. Ignore amend! markers targeting later commits while\n   selecting that replacement message.\n * Improve tests.\n\nChanges in v8:\n\n * --reedit-message now builds the same editor template as git rebase -i\n   --autosquash: fixup!, squash! and amend! commits are grouped under the\n   commit they target instead of shown in commit order, and an amend!\n   replaces its target's message.\n * A fixup!, squash! or amend! is refused only when its target is outside\n   the range, so several fixups for an in-range commit fold together. A\n   range that is entirely markers for one below-range target is combined\n   into a single commit, keeping the last amend! message.\n * Merges inside the range are folded when the range has a single base, with\n   no dedicated opt-in flag, --ancestry-path ensures only commits descended\n   from the base are folded, and a range reaching more than one base is\n   rejected.\n * Rev-list options are accepted and sanitized the way git replay does,\n   forcing the walk order back with a warning, which also fixes git history\n   squash -- --reverse slipping past the previous option check.\n * Kept this as an explicit squash subcommand rather than making\n   --reedit-message the default or renaming the command.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (5):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  history: add squash subcommand to fold a range\n  sequencer: share the squash message marker helpers and flags\n  history: re-edit a squash with every message\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  57 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 550 ++++++++++++++++++++--\n sequencer.c                      |  70 +--\n sequencer.h                      |  30 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 766 +++++++++++++++++++++++++++++++\n 9 files changed, 1408 insertions(+), 72 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 55526a18268bbc1ddaf8a6b7850c33d984eac9e9\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v9\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v9\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v8:\n\n 1:  ba77752282 = 1:  352c818c29 history: extract helper for a commit's parent tree\n 2:  50f3572887 = 2:  e06e49095b history: give commit_tree_ext a message template\n 3:  2d81a40a05 ! 3:  ead974c317 history: add squash subcommand to fold a range\n     @@ Commit message\n      \n          Add \"git history squash <revision-range>\" to do this directly. It folds\n          every commit in the range into the oldest one, keeping that commit's\n     -    message and authorship and taking the tree of the newest commit, then\n     -    replays the commits above the range on top. The squashed message comes\n     -    from the oldest commit, or from an editor with --reedit-message. A\n     -    fixup!, squash! or amend! commit is refused unless the commit it targets\n     -    is also in the range, so the fold does not silently absorb a marker meant\n     -    for a commit outside it. The check runs the range through\n     -    todo_list_rearrange_squash(), which leaves such a marker as a plain pick.\n     -    Markers whose target is in the range fold in as usual. As an exception, a\n     -    range made up entirely of markers for one target is combined anyway,\n     -    taking its message from the last amend! if there is one, so a batch of\n     -    fixups for the same commit can be collapsed.\n     +    authorship and taking the tree of the newest commit, then replays the\n     +    commits above the range on top. The squashed message comes from the\n     +    oldest commit by default, or from the body of the last amend! commit\n     +    targeting it. An editor opens with the selected message when\n     +    --reedit-message is given. A fixup!, squash! or amend! commit is refused\n     +    unless the commit it targets is also in the range, so the fold does not\n     +    silently absorb a marker meant for a commit outside it. The check runs\n     +    the range through todo_list_rearrange_squash(), which leaves such a\n     +    marker as a plain pick. Markers whose target is in the range fold in as\n     +    usual. As an exception, a range made up entirely of markers for one\n     +    target is combined anyway, taking its message from the last amend! if\n     +    there is one, so a batch of fixups for the same commit can be collapsed.\n      \n          The range is read like the arguments to \"git rev-list\", so several\n          revisions such as \"HEAD~3..HEAD ^topic\" may be given, and rev-list\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n       \n      +`squash <revision-range>`::\n      +\tFold all commits in _<revision-range>_ into the oldest commit of that\n     -+\trange. The resulting commit keeps the oldest commit's message and\n     -+\tauthorship and takes the tree of the range's newest commit, so the\n     -+\twhole range collapses into a single commit. Commits above the range\n     -+\tare replayed on top of the result.\n     ++\trange. The resulting commit keeps the oldest commit's authorship and\n     ++\ttakes the tree of the range's newest commit, so the whole range\n     ++\tcollapses into a single commit. Commits above the range are replayed\n     ++\ton top of the result.\n      ++\n      +The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n      +the commit just below the oldest commit to squash. For example, `git\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +already on `topic`. Rev-list options may also be given, but any that would\n      +change how the range is walked are overridden with a warning.\n      ++\n     -+The oldest commit's message and authorship are preserved by default,\n     -+unless you specify `--reedit-message`. A merge commit inside the range is\n     -+folded like any other, but the range must have a single base, so a range\n     -+that reaches more than one entry point (for example a side branch that\n     -+forked before the range and was later merged into it) is rejected.\n     ++The oldest commit's message is preserved by default, except that an `amend!`\n     ++commit targeting it replaces its message. Specify `--reedit-message` to edit\n     ++the resulting message. A merge commit inside the range is folded like any\n     ++other, but the range must have a single base, so a range that reaches more\n     ++than one entry point (for example a side branch that forked before the range\n     ++and was later merged into it) is rejected.\n      ++\n      +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n      +targets is also in the range, so the fold does not silently absorb a\n     -+marker meant for a commit outside it. As an exception, a range made up\n     -+entirely of markers for one target is combined into a single commit,\n     -+keeping the last `amend!` message if there is one.\n     ++marker meant for a commit outside it. The body after an `amend!` subject\n     ++replaces the oldest commit's message when the marker targets that commit. As\n     ++an exception, a range made up entirely of markers for one target is combined\n     ++into a single commit, keeping the last `amend!` message if there is one.\n      ++\n      +A branch or tag that points at a commit inside the range would be left\n      +dangling once those commits are folded away, so with the default\n     @@ builtin/history.c: out:\n      +\t\t\t\t  struct commit *base,\n      +\t\t\t\t  struct commit *tip,\n      +\t\t\t\t  struct commit *oldest,\n     -+\t\t\t\t  struct commit **msg_source)\n     ++\t\t\t\t  struct commit **msg_source,\n     ++\t\t\t\t  struct commit **amend_source)\n      +{\n      +\tstruct todo_list todo = TODO_LIST_INIT;\n      +\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n     @@ builtin/history.c: out:\n      +\tstruct strvec args = STRVEC_INIT;\n      +\tchar *dangling_subject = NULL, *dangling_target = NULL;\n      +\tbool mixed_target = false, all_fixups_one_target;\n     ++\tbool past_oldest_group = false;\n      +\tint i, ret, nr_dangling = 0;\n      +\n      +\t*msg_source = oldest;\n     ++\t*amend_source = NULL;\n      +\n      +\trepo_init_revisions(repo, &revs, NULL);\n      +\tstrvec_push(&args, \"ignored\");\n     @@ builtin/history.c: out:\n      +\t\tchar *subject;\n      +\t\tsize_t sublen;\n      +\n     -+\t\tif (todo.items[i].command != TODO_PICK)\n     -+\t\t\tcontinue;\n      +\t\tmessage = repo_logmsg_reencode(repo, todo.items[i].commit,\n      +\t\t\t\t\t       NULL, NULL);\n      +\t\tsublen = find_commit_subject(message, &subject_start);\n     ++\n     ++\t\tif (todo.items[i].command != TODO_PICK) {\n     ++\t\t\tif (!past_oldest_group &&\n     ++\t\t\t    starts_with(subject_start, \"amend! \"))\n     ++\t\t\t\t*amend_source = todo.items[i].commit;\n     ++\t\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n     ++\t\t\tcontinue;\n     ++\t\t}\n     ++\t\tif (i)\n     ++\t\t\tpast_oldest_group = true;\n     ++\n      +\t\tsubject = xmemdupz(subject_start, sublen);\n      +\t\ttarget = autosquash_target(subject);\n      +\t\tif (target != subject) {\n     @@ builtin/history.c: out:\n      +\t\tOPT_END(),\n      +\t};\n      +\tstruct strbuf reflog_msg = STRBUF_INIT;\n     ++\tstruct strbuf message = STRBUF_INIT;\n      +\tstruct oidset interior = OIDSET_INIT;\n     -+\tstruct commit *base, *oldest, *tip, *rewritten, *msg_source;\n     ++\tstruct commit *base, *oldest, *tip, *rewritten, *msg_source,\n     ++\t\t*amend_source;\n      +\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n     ++\tconst char *message_template = NULL;\n      +\tstruct commit_list *parents = NULL;\n      +\tstruct rev_info revs = { 0 };\n      +\tint ret;\n     @@ builtin/history.c: out:\n      +\tif (ret < 0)\n      +\t\tgoto out;\n      +\n     -+\tret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source);\n     ++\tret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,\n     ++\t\t\t\t     &amend_source);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n     ++\tif (amend_source) {\n     ++\t\tconst char *amend_message, *body;\n     ++\n     ++\t\tamend_message = repo_logmsg_reencode(repo, amend_source,\n     ++\t\t\t\t\t\t     NULL, NULL);\n     ++\t\tfind_commit_subject(amend_message, &body);\n     ++\t\tbody = skip_blank_lines(body + commit_subject_length(body));\n     ++\t\tstrbuf_addstr(&message, body);\n     ++\t\tmessage_template = message.buf;\n     ++\t\trepo_unuse_commit_buffer(repo, amend_source, amend_message);\n     ++\t}\n      +\n      +\tif (action == REF_ACTION_BRANCHES) {\n      +\t\tstruct interior_ref_cb cb = { .interior = &interior };\n     @@ builtin/history.c: out:\n      +\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n      +\tcommit_list_append(base, &parents);\n      +\n     -+\tret = commit_tree_ext(repo, \"squash\", msg_source, NULL, parents,\n     ++\tret = commit_tree_ext(repo, \"squash\", msg_source, message_template,\n     ++\t\t\t      parents,\n      +\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n      +\tif (ret < 0) {\n      +\t\tret = error(_(\"failed writing squashed commit\"));\n     @@ builtin/history.c: out:\n      +\n      +out:\n      +\tstrbuf_release(&reflog_msg);\n     ++\tstrbuf_release(&message);\n      +\toidset_clear(&interior);\n      +\tcommit_list_free(parents);\n      +\trelease_revisions(&revs);\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +. ./test-lib.sh\n      +\n     ++stage_file () {\n     ++\tprintf \"%s\\n\" \"$1\" >file &&\n     ++\tgit add file\n     ++}\n     ++\n     ++commit_with_message () {\n     ++\tprintf \"%b\" \"$1\" >msg &&\n     ++\tgit commit --allow-empty -qF msg\n     ++}\n     ++\n     ++check_commit_count () {\n     ++\tgit rev-list --count \"$1\" >actual &&\n     ++\techo \"$2\" >expect &&\n     ++\ttest_cmp expect actual\n     ++}\n     ++\n     ++check_log_subjects () {\n     ++\tgit log --format=\"%s\" \"$1\" >actual &&\n     ++\tcat >expect &&\n     ++\ttest_cmp expect actual\n     ++}\n     ++\n     ++check_log_messages () {\n     ++\tgit log --format=\"%B\" \"$1\" >actual &&\n     ++\tcat >expect &&\n     ++\ttest_cmp expect actual\n     ++}\n     ++\n      +test_expect_success 'setup linear history touching two files' '\n      +\ttest_commit base file a &&\n      +\tgit tag start &&\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start..HEAD ^keep &&\n      +\n     -+\tgit log --format=\"%s\" start..HEAD >actual &&\n     -+\tcat >expect <<-\\EOF &&\n     ++\tcheck_log_subjects start..HEAD <<-\\EOF &&\n      +\ttwo\n      +\tone\n      +\tEOF\n     -+\ttest_cmp expect actual &&\n      +\ttest_cmp_rev keep HEAD~1 &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n      +\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start..off-history &&\n      +\n     -+\tgit rev-list --count start..off-history >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..off-history 1 &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n      +\tgit branch -D off-history\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit history squash start.. &&\n      +\n      +\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n      +\ttest_cmp_rev start HEAD^ &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     -+\tgit log --format=\"%s\" -1 >subject &&\n     -+\techo one >expect &&\n     -+\ttest_cmp expect subject &&\n     ++\tcheck_log_subjects -1 <<-\\EOF &&\n     ++\tone\n     ++\tEOF\n      +\tgit reflog >reflog &&\n      +\ttest_grep \"squash: updating\" reflog\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start..@~1 &&\n      +\n     -+\tgit log --format=\"%s\" start..HEAD >actual &&\n     -+\tcat >expect <<-\\EOF &&\n     ++\tcheck_log_subjects start..HEAD <<-\\EOF &&\n      +\tthree\n      +\tone\n      +\tEOF\n     -+\ttest_cmp expect actual &&\n      +\n      +\ttest_cmp_rev start HEAD~2 &&\n      +\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash \"$root..\" &&\n      +\n     -+\tgit rev-list --count \"$root..HEAD\" >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count \"$root..HEAD\" 1 &&\n      +\ttest_cmp_rev \"$root\" HEAD^ &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     -+\tgit log --format=\"%s\" -1 >actual &&\n     -+\techo target >expect &&\n     -+\ttest_cmp expect actual\n     ++\tcheck_commit_count start..HEAD 1 &&\n     ++\tcheck_log_subjects -1 <<-\\EOF\n     ++\ttarget\n     ++\tEOF\n      +'\n      +\n      +test_expect_success 'refuses a below-range fixup! after an in-range commit' '\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +test_expect_success 'combines a run of fixups for one commit below the range' '\n      +\tgit reset --hard start &&\n     -+\techo b >file && git add file && git commit -m \"fixup! base\" &&\n     -+\techo c >file && git add file && git commit -m \"fixup! base\" &&\n     ++\tstage_file b && git commit -m \"fixup! base\" &&\n     ++\tstage_file c && git commit -m \"fixup! base\" &&\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     -+\tgit log --format=\"%s\" -1 >actual &&\n     -+\techo \"fixup! base\" >expect &&\n     -+\ttest_cmp expect actual\n     ++\tcheck_commit_count start..HEAD 1 &&\n     ++\tcheck_log_subjects -1 <<-\\EOF\n     ++\tfixup! base\n     ++\tEOF\n      +'\n      +\n      +test_expect_success 'combining below-range fixups keeps the last amend! message' '\n      +\tgit reset --hard start &&\n     -+\techo b >file && git add file && git commit -m \"fixup! base\" &&\n     -+\tprintf \"amend! base\\n\\namended body\\n\" >msg &&\n     -+\techo c >file && git add file && git commit -qF msg &&\n     ++\tstage_file b && git commit -m \"fixup! base\" &&\n     ++\tstage_file c &&\n     ++\tcommit_with_message \"amend! base\\n\\namended body\\n\" &&\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     -+\tgit log --format=\"%s\" -1 >actual &&\n     -+\techo \"amend! base\" >expect &&\n     -+\ttest_cmp expect actual &&\n     -+\tgit log --format=\"%b\" -1 >body &&\n     -+\ttest_grep \"amended body\" body\n     ++\tcheck_commit_count start..HEAD 1 &&\n     ++\tcheck_log_messages -1 <<-\\EOF\n     ++\tamend! base\n     ++\n     ++\tamended body\n     ++\n     ++\tEOF\n      +'\n      +\n      +test_expect_success 'refuses fixups for two different commits below the range' '\n      +\tgit reset --hard start &&\n     -+\techo b >file && git add file && git commit -m \"fixup! aaa\" &&\n     -+\techo c >file && git add file && git commit -m \"fixup! bbb\" &&\n     ++\tstage_file b && git commit -m \"fixup! aaa\" &&\n     ++\tstage_file c && git commit -m \"fixup! bbb\" &&\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\n      +\ttest_must_fail git history squash start.. 2>err &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp_rev \"$head_before\" HEAD\n      +'\n      +\n     -+test_expect_success 'keeps the oldest message for in-range squash! and amend!' '\n     ++test_expect_success 'the last amend! for the oldest commit replaces its message' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag marker-oldest file b &&\n      +\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n     -+\tgit commit --allow-empty -m \"amend! marker-oldest\" &&\n     -+\ttest_commit --no-tag marker-newest file c &&\n     ++\tcommit_with_message \"amend! marker-oldest\\n\\nearlier message\\n\" &&\n     ++\tcommit_with_message \\\n     ++\t\t\"amend! marker-oldest\\n\\namended subject\\n\\namended body\\n\" &&\n     ++\ttest_commit --no-tag marker-later file c &&\n     ++\tcommit_with_message \"amend! marker-later\\n\\nwrong message\\n\" &&\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     -+\tgit log --format=\"%s\" -1 >actual &&\n     -+\techo marker-oldest >expect &&\n     -+\ttest_cmp expect actual\n     ++\tcheck_commit_count start..HEAD 1 &&\n     ++\tcheck_log_messages -1 <<-\\EOF\n     ++\tamended subject\n     ++\n     ++\tamended body\n     ++\n     ++\tEOF\n      +'\n      +\n      +test_expect_success 'preserves authorship of the oldest commit' '\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash --update-refs=head start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n      +\ttest_cmp_rev \"$other_before\" other\n      +'\n      +\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash --update-refs=head start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n      +\ttest_cmp_rev \"$mid_before\" mid &&\n      +\n      +\tgit branch -D mid\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     -+\tgit log --format=\"%s\" -1 >subject &&\n     -+\techo before-side >expect &&\n     -+\ttest_cmp expect subject &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n     ++\tcheck_log_subjects -1 <<-\\EOF &&\n     ++\tbefore-side\n     ++\tEOF\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n      +\ttest_path_is_file inner\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n      +\ttest_cmp_rev start HEAD^ &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n      +\ttest_path_is_file side\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n      +\ttest_path_is_file side\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash \"$base..\" &&\n      +\n     -+\tgit rev-list --count \"$base..HEAD\" >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count \"$base..HEAD\" 1 &&\n      +\ttest_cmp_rev \"$base\" HEAD^ &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n      +\ttest_path_is_file s1 &&\n      +\ttest_path_is_file s2\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n      +\ttest_path_is_file outer &&\n      +\ttest_path_is_file inner\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start.. &&\n      +\n     -+\tgit rev-list --count start..HEAD >count &&\n     -+\techo 1 >expect &&\n     -+\ttest_cmp expect count &&\n     ++\tcheck_commit_count start..HEAD 1 &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n      +\ttest_path_is_file o1 &&\n      +\ttest_path_is_file o2\n 4:  0a735117ad = 4:  08915cee51 sequencer: share the squash message marker helpers and flags\n 5:  baf7e6f0a6 ! 5:  fb76afe31c history: re-edit a squash with every message\n     @@ Metadata\n       ## Commit message ##\n          history: re-edit a squash with every message\n      \n     -    By default \"git history squash\" reuses the oldest commit's message.\n     -    When --reedit-message is given it only reopened that one message, so the\n     +    By default \"git history squash\" reuses the oldest commit's message, or\n     +    the replacement body from an amend! commit targeting it. When\n     +    --reedit-message is given it only reopened that selected message, so the\n          messages of the other commits in the range were lost.\n      \n          Gather the message of every commit in the range and build the same editor\n     @@ Commit message\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-history.adoc ##\n     -@@ Documentation/git-history.adoc: given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n     - already on `topic`. Rev-list options may also be given, but any that would\n     +@@ Documentation/git-history.adoc: already on `topic`. Rev-list options may also be given, but any that would\n       change how the range is walked are overridden with a warning.\n       +\n     --The oldest commit's message and authorship are preserved by default,\n     --unless you specify `--reedit-message`. A merge commit inside the range is\n     -+The oldest commit's message and authorship are preserved by default. With\n     -+`--reedit-message`, an editor opens pre-filled with the messages of all the\n     -+folded commits so you can combine them. A merge commit inside the range is\n     - folded like any other, but the range must have a single base, so a range\n     - that reaches more than one entry point (for example a side branch that\n     - forked before the range and was later merged into it) is rejected.\n     -@@ Documentation/git-history.adoc: A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n     + The oldest commit's message is preserved by default, except that an `amend!`\n     +-commit targeting it replaces its message. Specify `--reedit-message` to edit\n     +-the resulting message. A merge commit inside the range is folded like any\n     +-other, but the range must have a single base, so a range that reaches more\n     +-than one entry point (for example a side branch that forked before the range\n     +-and was later merged into it) is rejected.\n     ++commit targeting it replaces its message. With `--reedit-message`, an editor\n     ++opens pre-filled with the messages of all the folded commits so you can\n     ++combine them. A merge commit inside the range is folded like any other, but\n     ++the range must have a single base, so a range that reaches more than one entry\n     ++point (for example a side branch that forked before the range and was later\n     ++merged into it) is rejected.\n     + +\n     + A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n       targets is also in the range, so the fold does not silently absorb a\n     - marker meant for a commit outside it. As an exception, a range made up\n     - entirely of markers for one target is combined into a single commit,\n     --keeping the last `amend!` message if there is one.\n     -+keeping the last `amend!` message if there is one. The changes from every\n     -+commit in the range are always folded in. Only the message text differs.\n     +@@ Documentation/git-history.adoc: marker meant for a commit outside it. The body after an `amend!` subject\n     + replaces the oldest commit's message when the marker targets that commit. As\n     + an exception, a range made up entirely of markers for one target is combined\n     + into a single commit, keeping the last `amend!` message if there is one.\n     ++The changes from every commit in the range are always folded in. Only the\n     ++message text differs.\n      +With `--reedit-message` the template mirrors `git rebase -i --autosquash`:\n      +each `fixup!`, `squash!`, or `amend!` is grouped under the commit it\n      +targets rather than shown in commit order. A `fixup!` message is dropped\n     @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi\n       static int cmd_history_squash(int argc,\n       \t\t\t      const char **argv,\n       \t\t\t      const char *prefix,\n     -@@ builtin/history.c: static int cmd_history_squash(int argc,\n     - \t\tOPT_END(),\n     - \t};\n     - \tstruct strbuf reflog_msg = STRBUF_INIT;\n     -+\tstruct strbuf message = STRBUF_INIT;\n     - \tstruct oidset interior = OIDSET_INIT;\n     - \tstruct commit *base, *oldest, *tip, *rewritten, *msg_source;\n     - \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n      @@ builtin/history.c: static int cmd_history_squash(int argc,\n       \t\t}\n       \t}\n       \n      +\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n     ++\t\tstrbuf_reset(&message);\n      +\t\tret = build_squash_message(repo, base, tip, &message);\n      +\t\tif (ret < 0)\n      +\t\t\tgoto out;\n     ++\t\tmessage_template = message.buf;\n      +\t}\n      +\n       \tret = setup_revwalk(repo, action, tip, &revs);\n       \tif (ret < 0)\n       \t\tgoto out;\n     -@@ builtin/history.c: static int cmd_history_squash(int argc,\n     - \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n     - \tcommit_list_append(base, &parents);\n     - \n     --\tret = commit_tree_ext(repo, \"squash\", msg_source, NULL, parents,\n     -+\tret = commit_tree_ext(repo, \"squash\", msg_source,\n     -+\t\t\t      message.len ? message.buf : NULL, parents,\n     - \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n     - \tif (ret < 0) {\n     - \t\tret = error(_(\"failed writing squashed commit\"));\n     -@@ builtin/history.c: static int cmd_history_squash(int argc,\n     - \n     - out:\n     - \tstrbuf_release(&reflog_msg);\n     -+\tstrbuf_release(&message);\n     - \toidset_clear(&interior);\n     - \tcommit_list_free(parents);\n     - \trelease_revisions(&revs);\n      \n       ## t/t3455-history-squash.sh ##\n      @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the oldest commit' '\n     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n       \n      +test_expect_success '--reedit-message offers every folded-in message' '\n      +\tgit reset --hard start &&\n     -+\techo b >file &&\n     -+\tgit add file &&\n     ++\tstage_file b &&\n      +\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n      +\ttest_commit --no-tag re-two file c &&\n      +\ttest_commit re-three file d &&\n     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +\t#\n      +\tEOF\n      +\ttest_cmp expect edited &&\n     -+\techo combined >expect &&\n     -+\tgit log --format=\"%s\" -1 >actual &&\n     -+\ttest_cmp expect actual\n     ++\tcheck_log_subjects -1 <<-\\EOF\n     ++\tcombined\n     ++\tEOF\n      +'\n      +\n      +test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag mark-base file b &&\n     -+\tprintf \"fixup! mark-base\\n\\nfixup body\\n\" >msg &&\n     -+\techo c >file &&\n     -+\tgit add file &&\n     -+\tgit commit -qF msg &&\n     -+\tprintf \"squash! mark-base\\n\\nsquash remark\\n\" >msg &&\n     -+\techo d >file &&\n     -+\tgit add file &&\n     -+\tgit commit -qF msg &&\n     -+\tprintf \"amend! mark-base\\n\\namended message\\n\" >msg &&\n     -+\techo e >file &&\n     -+\tgit add file &&\n     -+\tgit commit -qF msg &&\n     ++\tstage_file c &&\n     ++\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n     ++\tstage_file d &&\n     ++\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n     ++\tstage_file e &&\n     ++\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n      +\n      +\twrite_script editor <<-\\EOF &&\n      +\tcat \"$1\" >edited\n     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +\t#\n      +\tEOF\n      +\ttest_cmp expect edited &&\n     -+\tgit log -1 --format=\"%B\" >final &&\n     -+\ttest_grep ! \"fixup body\" final &&\n     -+\ttest_grep \"squash remark\" final &&\n     -+\ttest_grep \"amended message\" final\n     ++\tcheck_log_messages -1 <<-\\EOF\n     ++\tmark-base\n     ++\n     ++\tsquash remark\n     ++\n     ++\tamended message\n     ++\n     ++\tEOF\n      +'\n      +\n      +test_expect_success '--reedit-message groups fixups under their targets' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag alpha file a1 &&\n      +\ttest_commit --no-tag beta file b1 &&\n     -+\tprintf \"fixup! alpha\\n\" >msg &&\n     -+\techo a2 >file &&\n     -+\tgit add file &&\n     -+\tgit commit -qF msg &&\n     -+\tprintf \"fixup! beta\\n\" >msg &&\n     -+\techo b2 >file &&\n     -+\tgit add file &&\n     -+\tgit commit -qF msg &&\n     ++\tstage_file a2 &&\n     ++\tcommit_with_message \"fixup! alpha\\n\" &&\n     ++\tstage_file b2 &&\n     ++\tcommit_with_message \"fixup! beta\\n\" &&\n      +\n      +\twrite_script editor <<-\\EOF &&\n      +\tcat \"$1\" >edited\n     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +test_expect_success '--reedit-message lets amend! replace its target message' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag mark-base file b &&\n     -+\tprintf \"amend! mark-base\\n\\namended message\\n\" >msg &&\n     -+\techo c >file &&\n     -+\tgit add file &&\n     -+\tgit commit -qF msg &&\n     -+\tprintf \"squash! mark-base\\n\\nsquash remark\\n\" >msg &&\n     -+\techo d >file &&\n     -+\tgit add file &&\n     -+\tgit commit -qF msg &&\n     ++\tstage_file c &&\n     ++\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n     ++\tstage_file d &&\n     ++\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n      +\n      +\twrite_script editor <<-\\EOF &&\n      +\tcat \"$1\" >edited\n     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde\n      +\t#\n      +\tEOF\n      +\ttest_cmp expect edited &&\n     -+\tgit log -1 --format=\"%B\" >final &&\n     -+\ttest_grep ! \"mark-base\" final &&\n     -+\ttest_grep \"amended message\" final &&\n     -+\ttest_grep \"squash remark\" final\n     ++\tcheck_log_messages -1 <<-\\EOF\n     ++\tamended message\n     ++\n     ++\tsquash remark\n     ++\n     ++\tEOF\n      +'\n      +\n      +test_expect_success '--reedit-message aborts on an empty message' '\n\n-- \ngitgitgadget\n"},{"id":"548282","messageId":"352c818c29f6423eb3d7f2f6b1f0b3eee8ce77e0.1784128573.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com","subject":"[PATCH v9 1/5] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-15T15:16:09Z","receivedAt":"2026-07-15T15:16:18Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex fd83de8265..9f516687fe 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -157,6 +157,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"548284","messageId":"e06e49095b48c7323157851ae233e9283f45185a.1784128573.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com","subject":"[PATCH v9 2/5] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-15T15:16:10Z","receivedAt":"2026-07-15T15:16:19Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 9f516687fe..cbba25096f 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -101,6 +101,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"548286","messageId":"ead974c3173770f9230d2ba8442ff06dd9e91e00.1784128573.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com","subject":"[PATCH v9 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-15T15:16:11Z","receivedAt":"2026-07-15T15:16:20Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into the oldest one, keeping that commit's\nauthorship and taking the tree of the newest commit, then replays the\ncommits above the range on top. The squashed message comes from the\noldest commit by default, or from the body of the last amend! commit\ntargeting it. An editor opens with the selected message when\n--reedit-message is given. A fixup!, squash! or amend! commit is refused\nunless the commit it targets is also in the range, so the fold does not\nsilently absorb a marker meant for a commit outside it. The check runs\nthe range through todo_list_rearrange_squash(), which leaves such a\nmarker as a plain pick. Markers whose target is in the range fold in as\nusual. As an exception, a range made up entirely of markers for one\ntarget is combined anyway, taking its message from the last amend! if\nthere is one, so a batch of fixups for the same commit can be collapsed.\n\nThe range is read like the arguments to \"git rev-list\", so several\nrevisions such as \"HEAD~3..HEAD ^topic\" may be given, and rev-list\noptions are accepted too. As \"git replay\" does, the walk options the fold\nrelies on are forced after setup_revisions() and a warning is printed if\nan option changed them, so the first commit returned is the range's\noldest and its parent is the base regardless of what the user passed\n(including after a \"--\"). A merge inside the range is folded when its\nother parent is reachable from the base, otherwise the range has more\nthan one base and is rejected. By default the command also refuses when a\nref points at a commit that the fold would discard. Use --update-refs=head\nto rewrite only the current branch instead.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  47 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 372 ++++++++++++++++++++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 565 +++++++++++++++++++++++++++++++\n 7 files changed, 988 insertions(+), 3 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 81f80a9274..e2a3487778 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -59,6 +59,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 2ba8121795..2c0d861303 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -11,6 +11,7 @@ SYNOPSIS\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -42,8 +43,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold a\n+merge that lies inside the range, as long as the range has a single base.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -97,6 +101,42 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's authorship and\n+\ttakes the tree of the range's newest commit, so the whole range\n+\tcollapses into a single commit. Commits above the range are replayed\n+\ton top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. Several revisions may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`. Rev-list options may also be given, but any that would\n+change how the range is walked are overridden with a warning.\n++\n+The oldest commit's message is preserved by default, except that an `amend!`\n+commit targeting it replaces its message. Specify `--reedit-message` to edit\n+the resulting message. A merge commit inside the range is folded like any\n+other, but the range must have a single base, so a range that reaches more\n+than one entry point (for example a side branch that forked before the range\n+and was later merged into it) is rejected.\n++\n+A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n+targets is also in the range, so the fold does not silently absorb a\n+marker meant for a commit outside it. The body after an `amend!` subject\n+replaces the oldest commit's message when the marker targets that commit. As\n+an exception, a range made up entirely of markers for one target is combined\n+into a single commit, keeping the last `amend!` message if there is one.\n++\n+A branch or tag that points at a commit inside the range would be left\n+dangling once those commits are folded away, so with the default\n+`--update-refs=branches` the command refuses. Rerun with\n+`--update-refs=head` to rewrite only the current branch and leave such\n+refs pointing at the old commits.\n+\n OPTIONS\n -------\n \n@@ -107,7 +147,8 @@ OPTIONS\n \tref updates is generally safe.\n \n `--reedit-message`::\n-\tOpen an editor to modify the target commit's message.\n+\tOpen an editor to modify the rewritten commit's message. For `squash`\n+\tthe editor is pre-filled with the messages of all the folded commits.\n \n `--empty=(drop|keep|abort)`::\n \tControl what happens when a commit becomes empty as a result of the\ndiff --git a/advice.c b/advice.c\nindex 63bf8b0c5f..401d047391 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 66f6cd6a77..3f0b4f0485 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex cbba25096f..edf98a21d3 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -30,6 +31,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -973,6 +976,373 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char **argv,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tstruct oidset *interior_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct commit_list *boundaries = NULL, *b;\n+\tstruct strvec args = STRVEC_INIT;\n+\tsize_t i;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\trevs.reverse = 1;\n+\trevs.topo_order = 1;\n+\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\trevs.simplify_history = 0;\n+\trevs.boundary = 1;\n+\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--ancestry-path\");\n+\tstrvec_pushv(&args, argv);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n+\t\tgoto out;\n+\t}\n+\n+\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n+\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n+\t    revs.simplify_history != 0) {\n+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n+\t\t\t  \"range is walked\"));\n+\t\trevs.reverse = 1;\n+\t\trevs.topo_order = 1;\n+\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\t\trevs.simplify_history = 0;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (i = 0; i < revs.cmdline.nr; i++)\n+\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n+\t\t\tbreak;\n+\tif (i == revs.cmdline.nr) {\n+\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Set boundary commits aside for the base check below, and put every\n+\t * in-range commit but the tip into the interior set. A ref pointing\n+\t * at an interior commit would dangle once the range is folded away.\n+\t */\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tcommit_list_insert(commit, &boundaries);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\tif (tip)\n+\t\t\toidset_insert(interior_out, &tip->object.oid);\n+\t\ttip = commit;\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t} else if (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t} else if (!oldest->parents) {\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\t}\n+\tbase = oldest->parents->item;\n+\n+\t/*\n+\t * A boundary other than the base is an in-range commit reaching a\n+\t * commit outside the range, so the range has more than one base.\n+\t */\n+\tfor (b = boundaries; b; b = b->next) {\n+\t\tif (b->item != base) {\n+\t\t\tret = error(_(\"the revision range has more than one base; \"\n+\t\t\t\t      \"cannot squash\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tcommit_list_free(boundaries);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+static const char *autosquash_target(const char *subject)\n+{\n+\tconst char *rest;\n+\n+\twhile (skip_prefix(subject, \"fixup! \", &rest) ||\n+\t       skip_prefix(subject, \"squash! \", &rest) ||\n+\t       skip_prefix(subject, \"amend! \", &rest))\n+\t\tsubject = rest;\n+\treturn subject;\n+}\n+\n+static int reject_dangling_fixups(struct repository *repo,\n+\t\t\t\t  struct commit *base,\n+\t\t\t\t  struct commit *tip,\n+\t\t\t\t  struct commit *oldest,\n+\t\t\t\t  struct commit **msg_source,\n+\t\t\t\t  struct commit **amend_source)\n+{\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *last_amend = NULL;\n+\tstruct strvec args = STRVEC_INIT;\n+\tchar *dangling_subject = NULL, *dangling_target = NULL;\n+\tbool mixed_target = false, all_fixups_one_target;\n+\tbool past_oldest_group = false;\n+\tint i, ret, nr_dangling = 0;\n+\n+\t*msg_source = oldest;\n+\t*amend_source = NULL;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\twhile ((commit = get_revision(&revs)))\n+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n+\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not check the range for fixups\"));\n+\t\tgoto out;\n+\t}\n+\n+\tfor (i = 0; i < todo.nr; i++) {\n+\t\tconst char *message, *subject_start, *target;\n+\t\tchar *subject;\n+\t\tsize_t sublen;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, todo.items[i].commit,\n+\t\t\t\t\t       NULL, NULL);\n+\t\tsublen = find_commit_subject(message, &subject_start);\n+\n+\t\tif (todo.items[i].command != TODO_PICK) {\n+\t\t\tif (!past_oldest_group &&\n+\t\t\t    starts_with(subject_start, \"amend! \"))\n+\t\t\t\t*amend_source = todo.items[i].commit;\n+\t\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (i)\n+\t\t\tpast_oldest_group = true;\n+\n+\t\tsubject = xmemdupz(subject_start, sublen);\n+\t\ttarget = autosquash_target(subject);\n+\t\tif (target != subject) {\n+\t\t\tnr_dangling++;\n+\t\t\tif (!dangling_target) {\n+\t\t\t\tdangling_target = xstrdup(target);\n+\t\t\t\tdangling_subject = xstrdup(subject);\n+\t\t\t} else if (strcmp(dangling_target, target)) {\n+\t\t\t\tmixed_target = true;\n+\t\t\t}\n+\t\t\tif (starts_with(subject, \"amend! \"))\n+\t\t\t\tlast_amend = todo.items[i].commit;\n+\t\t}\n+\t\tfree(subject);\n+\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n+\t}\n+\n+\tall_fixups_one_target = nr_dangling == todo.nr && !mixed_target;\n+\tif (nr_dangling && !all_fixups_one_target) {\n+\t\tret = error(_(\"cannot squash '%s': its target is not in the \"\n+\t\t\t      \"range\"), dangling_subject);\n+\t} else {\n+\t\tif (last_amend)\n+\t\t\t*msg_source = last_amend;\n+\t\tret = 0;\n+\t}\n+\n+out:\n+\tfree(dangling_subject);\n+\tfree(dangling_target);\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+struct interior_ref_cb {\n+\tconst struct oidset *interior;\n+\tconst char *name;\n+};\n+\n+static int find_interior_ref(const struct reference *ref, void *cb_data)\n+{\n+\tstruct interior_ref_cb *data = cb_data;\n+\n+\tif (oidset_contains(data->interior, ref->oid)) {\n+\t\tdata->name = xstrdup(ref->name);\n+\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n+\tstruct oidset interior = OIDSET_INIT;\n+\tstruct commit *base, *oldest, *tip, *rewritten, *msg_source,\n+\t\t*amend_source;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tconst char *message_template = NULL;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage,\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT);\n+\tif (!argc) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n+\t\t\t\t   &interior);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,\n+\t\t\t\t     &amend_source);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\tif (amend_source) {\n+\t\tconst char *amend_message, *body;\n+\n+\t\tamend_message = repo_logmsg_reencode(repo, amend_source,\n+\t\t\t\t\t\t     NULL, NULL);\n+\t\tfind_commit_subject(amend_message, &body);\n+\t\tbody = skip_blank_lines(body + commit_subject_length(body));\n+\t\tstrbuf_addstr(&message, body);\n+\t\tmessage_template = message.buf;\n+\t\trepo_unuse_commit_buffer(repo, amend_source, amend_message);\n+\t}\n+\n+\tif (action == REF_ACTION_BRANCHES) {\n+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n+\n+\t\trefs_for_each_ref(get_main_ref_store(repo),\n+\t\t\t\t  find_interior_ref, &cb);\n+\t\tif (cb.name) {\n+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n+\t\t\t\t    cb.name);\n+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t\t    \"untouched.\"));\n+\t\t\tfree((char *)cb.name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", msg_source, message_template,\n+\t\t\t      parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n+\toidset_clear(&interior);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n int cmd_history(int argc,\n \t\tconst char **argv,\n \t\tconst char *prefix,\n@@ -982,6 +1352,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -989,6 +1360,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex 7c3c070426..459e251623 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -400,6 +400,7 @@ integration_tests = [\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..d6199b9644\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,565 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+stage_file () {\n+\tprintf \"%s\\n\" \"$1\" >file &&\n+\tgit add file\n+}\n+\n+commit_with_message () {\n+\tprintf \"%b\" \"$1\" >msg &&\n+\tgit commit --allow-empty -qF msg\n+}\n+\n+check_commit_count () {\n+\tgit rev-list --count \"$1\" >actual &&\n+\techo \"$2\" >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_subjects () {\n+\tgit log --format=\"%s\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_messages () {\n+\tgit log --format=\"%B\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\ttest_commit --no-tag one other x &&\n+\ttest_commit --no-tag two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..HEAD ^keep &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n+\tgit checkout \"$main\" &&\n+\n+\tgit history squash start..off-history &&\n+\n+\tcheck_commit_count start..off-history 1 &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tone\n+\tEOF\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'sanitizes rev-list walk options, before and after --' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --date-order start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash -- --reverse start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$root..\" &&\n+\n+\tcheck_commit_count \"$root..HEAD\" 1 &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+\n+test_expect_success 'folds fixups whose target is in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag target file b &&\n+\tgit commit --allow-empty -m \"fixup! target\" &&\n+\tgit commit --allow-empty -m \"fixup! target\" &&\n+\ttest_commit --no-tag later file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\ttarget\n+\tEOF\n+'\n+\n+test_expect_success 'refuses a below-range fixup! after an in-range commit' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag inside file b &&\n+\ttest_commit --no-tag \"fixup! outside\" file c &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'combines a run of fixups for one commit below the range' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! base\" &&\n+\tstage_file c && git commit -m \"fixup! base\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tfixup! base\n+\tEOF\n+'\n+\n+test_expect_success 'combining below-range fixups keeps the last amend! message' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! base\" &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! base\\n\\namended body\\n\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamend! base\n+\n+\tamended body\n+\n+\tEOF\n+'\n+\n+test_expect_success 'refuses fixups for two different commits below the range' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! aaa\" &&\n+\tstage_file c && git commit -m \"fixup! bbb\" &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'the last amend! for the oldest commit replaces its message' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag marker-oldest file b &&\n+\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n+\tcommit_with_message \"amend! marker-oldest\\n\\nearlier message\\n\" &&\n+\tcommit_with_message \\\n+\t\t\"amend! marker-oldest\\n\\namended subject\\n\\namended body\\n\" &&\n+\ttest_commit --no-tag marker-later file c &&\n+\tcommit_with_message \"amend! marker-later\\n\\nwrong message\\n\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended subject\n+\n+\tamended body\n+\n+\tEOF\n+'\n+\n+test_expect_success 'preserves authorship of the oldest commit' '\n+\tgit reset --hard start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit --no-tag oldest file b &&\n+\ttest_commit newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"error: .* points into the squashed range\" err &&\n+\ttest_grep \"hint: .*--update-refs=head\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\tmid_before=$(git rev-parse mid) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'refuses to fold a range a tag points into' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"refs/tags/mark\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit tag -d mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tbefore-side\n+\tEOF\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$base..\" &&\n+\n+\tcheck_commit_count \"$base..HEAD\" 1 &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"at-merge\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"548285","messageId":"08915cee51f49b6fff6608e718febbe48d3bc3d4.1784128573.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com","subject":"[PATCH v9 4/5] sequencer: share the squash message marker helpers and flags","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-15T15:16:12Z","receivedAt":"2026-07-15T15:16:22Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). Also move the\ntodo_item_flags enum to the header, so a caller reading the output of\ntodo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\nfrom a plain fixup!. A later change reuses all of this to give \"git\nhistory squash --reedit-message\" the same template.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 70 +++++++++++++++++++++++++++++------------------------\n sequencer.h | 30 +++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 31 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 0fe8fed6c3..2387afd9b5 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)\n \t}\n }\n \n-enum todo_item_flags {\n-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n-};\n-\n static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 64a9c7fb1b..b01f897020 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -119,6 +119,13 @@ enum todo_command {\n \tTODO_COMMENT\n };\n \n+/* Bits for the \"flags\" member of struct todo_item */\n+enum todo_item_flags {\n+\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n+\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n+\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n+};\n+\n struct todo_item {\n \tenum todo_command command;\n \tstruct commit *commit;\n@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"548287","messageId":"fb76afe31c98833582d2b6be764fa3a1c8b71bbe.1784128573.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com","subject":"[PATCH v9 5/5] history: re-edit a squash with every message","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-15T15:16:13Z","receivedAt":"2026-07-15T15:16:23Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBy default \"git history squash\" reuses the oldest commit's message, or\nthe replacement body from an amend! commit targeting it. When\n--reedit-message is given it only reopened that selected message, so the\nmessages of the other commits in the range were lost.\n\nGather the message of every commit in the range and build the same editor\ntemplate that \"git rebase -i --autosquash\" shows for a squash, reusing\nadd_squash_combination_header(), add_squash_message_header() and\nsquash_subject_comment_len(). Feed the range through\ntodo_list_rearrange_squash() so that each fixup!, squash! or amend! is\ngrouped under the commit it targets rather than shown in commit order,\nexactly as autosquash would arrange them.\n\nOnly the message text differs, the changes are always folded in. A fixup!\nmessage is commented out in full under a \"will be skipped\" header, a\nsquash! keeps its body with only the marker subject commented, and an\namend! replaces its target's message unless a squash! already folded into\nthat target, in which case it behaves like a squash!.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  20 +++-\n builtin/history.c              | 104 +++++++++++++++++\n t/t3455-history-squash.sh      | 201 +++++++++++++++++++++++++++++++++\n 3 files changed, 320 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 2c0d861303..dc5580531f 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -118,11 +118,12 @@ already on `topic`. Rev-list options may also be given, but any that would\n change how the range is walked are overridden with a warning.\n +\n The oldest commit's message is preserved by default, except that an `amend!`\n-commit targeting it replaces its message. Specify `--reedit-message` to edit\n-the resulting message. A merge commit inside the range is folded like any\n-other, but the range must have a single base, so a range that reaches more\n-than one entry point (for example a side branch that forked before the range\n-and was later merged into it) is rejected.\n+commit targeting it replaces its message. With `--reedit-message`, an editor\n+opens pre-filled with the messages of all the folded commits so you can\n+combine them. A merge commit inside the range is folded like any other, but\n+the range must have a single base, so a range that reaches more than one entry\n+point (for example a side branch that forked before the range and was later\n+merged into it) is rejected.\n +\n A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n targets is also in the range, so the fold does not silently absorb a\n@@ -130,6 +131,15 @@ marker meant for a commit outside it. The body after an `amend!` subject\n replaces the oldest commit's message when the marker targets that commit. As\n an exception, a range made up entirely of markers for one target is combined\n into a single commit, keeping the last `amend!` message if there is one.\n+The changes from every commit in the range are always folded in. Only the\n+message text differs.\n+With `--reedit-message` the template mirrors `git rebase -i --autosquash`:\n+each `fixup!`, `squash!`, or `amend!` is grouped under the commit it\n+targets rather than shown in commit order. A `fixup!` message is dropped\n+(commented out in full), a `squash!` keeps its body with only the marker\n+subject commented, and an `amend!` replaces its target's message, unless\n+a `squash!` folded into that target first, in which case it keeps its\n+body like a `squash!`.\n +\n A branch or tag that points at a commit inside the range would be left\n dangling once those commits are folded away, so with the default\ndiff --git a/builtin/history.c b/builtin/history.c\nindex edf98a21d3..b1f84e8297 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1223,6 +1223,102 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n+static bool amend_replaces_target(struct todo_list *todo, int target)\n+{\n+\tint i;\n+\n+\tfor (i = target + 1; i < todo->nr &&\n+\t\t\t     todo->items[i].command != TODO_PICK; i++) {\n+\t\tif (todo->items[i].command == TODO_SQUASH)\n+\t\t\treturn false;\n+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tint i, nr_commits, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs)))\n+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n+\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not prepare the squash message\"));\n+\t\tgoto out;\n+\t}\n+\n+\tnr_commits = todo.nr;\n+\tfor (i = 0; i < nr_commits; i++) {\n+\t\tstruct todo_item *item = &todo.items[i];\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tbool skip, squashing;\n+\n+\t\tsquashing = item->command == TODO_SQUASH ||\n+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n+\t\tif (item->command == TODO_PICK)\n+\t\t\tskip = amend_replaces_target(&todo, i);\n+\t\telse\n+\t\t\tskip = !squashing;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tif (skip)\n+\t\t\tcommented_len = strlen(body);\n+\t\telse if (squashing)\n+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n+\t\telse\n+\t\t\tcommented_len = 0;\n+\n+\t\tif (!i)\n+\t\t\tadd_squash_combination_header(out, nr_commits);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, i + 1, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1306,6 +1402,14 @@ static int cmd_history_squash(int argc,\n \t\t}\n \t}\n \n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tstrbuf_reset(&message);\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t\tmessage_template = message.buf;\n+\t}\n+\n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n \t\tgoto out;\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex d6199b9644..f2835f5379 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -267,6 +267,207 @@ test_expect_success 'preserves authorship of the oldest commit' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--reedit-message offers every folded-in message' '\n+\tgit reset --hard start &&\n+\tstage_file b &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tcombined\n+\tEOF\n+'\n+\n+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\tstage_file e &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tmark-base\n+\n+\tsquash remark\n+\n+\tamended message\n+\n+\tEOF\n+'\n+\n+test_expect_success '--reedit-message groups fixups under their targets' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag alpha file a1 &&\n+\ttest_commit --no-tag beta file b1 &&\n+\tstage_file a2 &&\n+\tcommit_with_message \"fixup! alpha\\n\" &&\n+\tstage_file b2 &&\n+\tcommit_with_message \"fixup! beta\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\talpha\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! alpha\n+\n+\t# This is the commit message #3:\n+\n+\tbeta\n+\n+\t# The commit message #4 will be skipped:\n+\n+\t# fixup! beta\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited\n+'\n+\n+test_expect_success '--reedit-message lets amend! replace its target message' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# The 1st commit message will be skipped:\n+\n+\t# mark-base\n+\n+\t# This is the commit message #2:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended message\n+\n+\tsquash remark\n+\n+\tEOF\n+'\n+\n+test_expect_success '--reedit-message aborts on an empty message' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\ttest_must_fail git history squash --reedit-message start.. &&\n+\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n test_expect_success '--update-refs=head only moves HEAD' '\n \tgit reset --hard three &&\n \tgit branch -f other HEAD &&\n-- \ngitgitgadget\n"},{"id":"548577","messageId":"DK1KIF2OI8IF.11188A3YEQV1C@lfurio.us","threadId":"65812","inReplyTo":"ead974c3173770f9230d2ba8442ff06dd9e91e00.1784128573.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v9 3/5] history: add squash subcommand to fold a range","fromName":"Matt Hunter","fromEmail":"m@lfurio.us","sentAt":"2026-07-18T08:52:24Z","receivedAt":"2026-07-18T08:52:31Z","isPatch":true,"body":"Hi Harald,\n\nThe new functionality for amend! messages seems to be working well, so I\ndug a little deeper and found the following...\n\nOn Wed Jul 15, 2026 at 11:16 AM EDT, Harald Nordgren via GitGitGadget wrote:\n> ++\n> +The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n> +the commit just below the oldest commit to squash. For example, `git\n> +history squash HEAD~3..HEAD` folds the three most recent commits into\n> +one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n> +while leaving the two newest commits in place. Several revisions may be\n> +given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n> +already on `topic`. Rev-list options may also be given, but any that would\n> +change how the range is walked are overridden with a warning.\n> ++\n> +The oldest commit's message is preserved by default, except that an `amend!`\n> +commit targeting it replaces its message.\n\nThe new behavior from v9 is documented here, but...\n\n>                                            Specify `--reedit-message` to edit\n> +the resulting message. A merge commit inside the range is folded like any\n> +other, but the range must have a single base, so a range that reaches more\n> +than one entry point (for example a side branch that forked before the range\n> +and was later merged into it) is rejected.\n> ++\n> +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n> +targets is also in the range, so the fold does not silently absorb a\n> +marker meant for a commit outside it. The body after an `amend!` subject\n> +replaces the oldest commit's message when the marker targets that commit.\n\n...a redundant explanation appears here too.  Personally, I think this\nparagraph flows better if the 'The body after an `amend!`...targets that\ncommit.' sentence were removed.\n\n> +As an exception, a range made up entirely of markers for one target is combined\n> +into a single commit, keeping the last `amend!` message if there is one.\n> ++\n> +A branch or tag that points at a commit inside the range would be left\n> +dangling once those commits are folded away, so with the default\n> +`--update-refs=branches` the command refuses. Rerun with\n> +`--update-refs=head` to rewrite only the current branch and leave such\n> +refs pointing at the old commits.\n> +\n>  OPTIONS\n>  -------\n>  \n> @@ -107,7 +147,8 @@ OPTIONS\n>  \tref updates is generally safe.\n>  \n>  `--reedit-message`::\n> -\tOpen an editor to modify the target commit's message.\n> +\tOpen an editor to modify the rewritten commit's message. For `squash`\n> +\tthe editor is pre-filled with the messages of all the folded commits.\n\nAt the moment of this patch, this is a false statement, though it is\nmade true by patch 5/5 pre-filling all messages.\n\n> diff --git a/builtin/history.c b/builtin/history.c\n> index cbba25096f..edf98a21d3 100644\n> --- a/builtin/history.c\n> +++ b/builtin/history.c\n> +\n> +\trepo_init_revisions(repo, &revs, NULL);\n> +\trevs.reverse = 1;\n> +\trevs.topo_order = 1;\n> +\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n> +\trevs.simplify_history = 0;\n> +\trevs.boundary = 1;\n> +\n> +\tstrvec_push(&args, \"ignored\");\n> +\tstrvec_push(&args, \"--ancestry-path\");\n> +\tstrvec_pushv(&args, argv);\n> +\tsetup_revisions_from_strvec(&args, &revs, NULL);\n> +\tif (args.nr != 1) {\n> +\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n> +\t\tgoto out;\n> +\t}\n> +\n> +\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n> +\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n> +\t    revs.simplify_history != 0) {\n> +\t\twarning(_(\"ignoring rev-list options that would change how the \"\n> +\t\t\t  \"range is walked\"));\n> +\t\trevs.reverse = 1;\n> +\t\trevs.topo_order = 1;\n> +\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n> +\t\trevs.simplify_history = 0;\n> +\t}\n\nShould revs.boundary still == 1 be asserted here too?\n\n> +\n> +\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n> +\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n> +\tcommit_list_append(base, &parents);\n> +\n> +\tret = commit_tree_ext(repo, \"squash\", msg_source, message_template,\n> +\t\t\t      parents,\n> +\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n> +\tif (ret < 0) {\n> +\t\tret = error(_(\"failed writing squashed commit\"));\n> +\t\tgoto out;\n> +\t}\n> +\n> +\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n\nWith this format string, the reflog will miss cases like:\n\n    git history squash HEAD~5..HEAD ^origin/master\n\nOnly \"squash: updating HEAD~5..HEAD\" will be recorded in the log.\n"},{"id":"548578","messageId":"DK1KIH6CXW0X.1U2V3GU8L6HB7@lfurio.us","threadId":"65812","inReplyTo":"fb76afe31c98833582d2b6be764fa3a1c8b71bbe.1784128573.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v9 5/5] history: re-edit a squash with every message","fromName":"Matt Hunter","fromEmail":"m@lfurio.us","sentAt":"2026-07-18T08:52:28Z","receivedAt":"2026-07-18T08:52:35Z","isPatch":true,"body":"On Wed Jul 15, 2026 at 11:16 AM EDT, Harald Nordgren via GitGitGadget wrote:\n> @@ -130,6 +131,15 @@ marker meant for a commit outside it. The body after an `amend!` subject\n>  replaces the oldest commit's message when the marker targets that commit. As\n>  an exception, a range made up entirely of markers for one target is combined\n>  into a single commit, keeping the last `amend!` message if there is one.\n\n> +The changes from every commit in the range are always folded in. Only the\n> +message text differs.\n\nThis sentence kept puzzling me when I re-read this part.  That isn't to\nsay that it doesn't make sense on its own, or isn't correct.  But in\nthis context, I wasn't sure why it was included.\n\nFor whatever reason, the diff from your v8 made it click, and I believe\nyou're trying to explain the previous sentence about the last `amend!`,\nstating that all the other _effects_ of the other fixup!s are also kept\neven though the message changes.  Is that right?\n\nIf so, I might suggest removing this sentence too.  At least to my\nbrain, it doesn't contribute to my understanding of the command, and\nmakes the paragraph feel like it changes subject in the middle.\n\n> +With `--reedit-message` the template mirrors `git rebase -i --autosquash`:\n> +each `fixup!`, `squash!`, or `amend!` is grouped under the commit it\n> +targets rather than shown in commit order. A `fixup!` message is dropped\n> +(commented out in full), a `squash!` keeps its body with only the marker\n> +subject commented, and an `amend!` replaces its target's message, unless\n> +a `squash!` folded into that target first, in which case it keeps its\n> +body like a `squash!`.\n\nThis bit that comes right after would possibly do better as its own\nparagraph imo.\n\nThanks!\n"},{"id":"548582","messageId":"CAHwyqnW9aLUGDQAYcJa=z6Zs4q8vaLfcWVuM7r2OWX9pg17P=A@mail.gmail.com","threadId":"65812","inReplyTo":"DK1KIF2OI8IF.11188A3YEQV1C@lfurio.us","subject":"Re: [PATCH v9 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-18T09:28:19Z","receivedAt":"2026-07-18T09:28:58Z","isPatch":true,"body":"Thanks, good points!\n\n\nHarald\n"},{"id":"548584","messageId":"CAHwyqnWV7whjONR15y0W=xfZdwz2burQtn5BzxVuU0fijs=9Yw@mail.gmail.com","threadId":"65812","inReplyTo":"DK1KIH6CXW0X.1U2V3GU8L6HB7@lfurio.us","subject":"Re: [PATCH v9 5/5] history: re-edit a squash with every message","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-18T09:36:32Z","receivedAt":"2026-07-18T09:37:10Z","isPatch":true,"body":"Good points!\n\n\nHarald\n"},{"id":"548650","messageId":"pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v9.git.git.1784128573.gitgitgadget@gmail.com","subject":"[PATCH v10 0/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-20T08:26:59Z","receivedAt":"2026-07-20T08:27:07Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v10:\n\n * Record the full revision expression in squash reflog.\n * Preserve the boundary-walk invariant when sanitizing rev-list options.\n * Clarify amend! and --reedit-message documentation.\n\nChanges in v9:\n\n * Use the last amend! targeting the oldest folded commit as the default\n   squashed message. Ignore amend! markers targeting later commits while\n   selecting that replacement message.\n * Improve tests.\n\nChanges in v8:\n\n * --reedit-message now builds the same editor template as git rebase -i\n   --autosquash: fixup!, squash! and amend! commits are grouped under the\n   commit they target instead of shown in commit order, and an amend!\n   replaces its target's message.\n * A fixup!, squash! or amend! is refused only when its target is outside\n   the range, so several fixups for an in-range commit fold together. A\n   range that is entirely markers for one below-range target is combined\n   into a single commit, keeping the last amend! message.\n * Merges inside the range are folded when the range has a single base, with\n   no dedicated opt-in flag, --ancestry-path ensures only commits descended\n   from the base are folded, and a range reaching more than one base is\n   rejected.\n * Rev-list options are accepted and sanitized the way git replay does,\n   forcing the walk order back with a warning, which also fixes git history\n   squash -- --reverse slipping past the previous option check.\n * Kept this as an explicit squash subcommand rather than making\n   --reedit-message the default or renaming the command.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (5):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  history: add squash subcommand to fold a range\n  sequencer: share the squash message marker helpers and flags\n  history: re-edit a squash with every message\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  55 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 552 ++++++++++++++++++++--\n sequencer.c                      |  70 +--\n sequencer.h                      |  30 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 770 +++++++++++++++++++++++++++++++\n 9 files changed, 1412 insertions(+), 72 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 41365c2a9ba347870b80881c0d67454edd22fd49\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v10\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v10\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v9:\n\n 1:  352c818c29 = 1:  f848103497 history: extract helper for a commit's parent tree\n 2:  e06e49095b = 2:  49dadc3410 history: give commit_tree_ext a message template\n 3:  ead974c317 ! 3:  6b5b2c93f2 history: add squash subcommand to fold a range\n     @@ Documentation/config/advice.adoc: all advice messages.\n       \t\tset as executable.\n      \n       ## Documentation/git-history.adoc ##\n     -@@ Documentation/git-history.adoc: SYNOPSIS\n     +@@ Documentation/git-history.adoc: git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(\n       git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n       git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n       git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      ++\n      +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n      +targets is also in the range, so the fold does not silently absorb a\n     -+marker meant for a commit outside it. The body after an `amend!` subject\n     -+replaces the oldest commit's message when the marker targets that commit. As\n     -+an exception, a range made up entirely of markers for one target is combined\n     -+into a single commit, keeping the last `amend!` message if there is one.\n     ++marker meant for a commit outside it. As an exception, a range made up entirely\n     ++of markers for one target is combined into a single commit, keeping the last\n     ++`amend!` message if there is one.\n      ++\n      +A branch or tag that points at a commit inside the range would be left\n      +dangling once those commits are folded away, so with the default\n     @@ Documentation/git-history.adoc: OPTIONS\n       \n       `--reedit-message`::\n      -\tOpen an editor to modify the target commit's message.\n     -+\tOpen an editor to modify the rewritten commit's message. For `squash`\n     -+\tthe editor is pre-filled with the messages of all the folded commits.\n     ++\tOpen an editor to modify the rewritten commit's message.\n       \n       `--empty=(drop|keep|abort)`::\n       \tControl what happens when a commit becomes empty as a result of the\n     @@ builtin/history.c: out:\n      +\n      +\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n      +\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n     -+\t    revs.simplify_history != 0) {\n     ++\t    revs.simplify_history != 0 || revs.boundary != 1) {\n      +\t\twarning(_(\"ignoring rev-list options that would change how the \"\n      +\t\t\t  \"range is walked\"));\n      +\t\trevs.reverse = 1;\n      +\t\trevs.topo_order = 1;\n      +\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n      +\t\trevs.simplify_history = 0;\n     ++\t\trevs.boundary = 1;\n      +\t}\n      +\n      +\t/*\n     @@ builtin/history.c: out:\n      +\t\tgoto out;\n      +\t}\n      +\n     -+\tstrbuf_addf(&reflog_msg, \"squash: updating %s\", argv[0]);\n     ++\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n     ++\tstrbuf_join_argv(&reflog_msg, argc, argv, ' ');\n      +\n      +\tret = handle_reference_updates(&revs, action, tip, rewritten,\n      +\t\t\t\t       reflog_msg.buf, dry_run,\n     @@ builtin/history.c: out:\n      +\treturn ret;\n      +}\n      +\n     - int cmd_history(int argc,\n     - \t\tconst char **argv,\n     - \t\tconst char *prefix,\n     + static int update_worktree(struct repository *repo,\n     + \t\t\t   const struct commit *old_head,\n     + \t\t\t   const struct commit *new_head,\n      @@ builtin/history.c: int cmd_history(int argc,\n       \t\tGIT_HISTORY_FIXUP_USAGE,\n       \t\tGIT_HISTORY_REWORD_USAGE,\n     @@ builtin/history.c: int cmd_history(int argc,\n      \n       ## t/meson.build ##\n      @@ t/meson.build: integration_tests = [\n     -   't3451-history-reword.sh',\n         't3452-history-split.sh',\n         't3453-history-fixup.sh',\n     +   't3454-history-drop.sh',\n      +  't3455-history-squash.sh',\n         't3500-cherry.sh',\n         't3501-revert-cherry-pick.sh',\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tgit history squash start..HEAD ^keep &&\n      +\n     ++\tgit reflog -1 --format=%gs >actual &&\n     ++\techo \"squash: updating start..HEAD ^keep\" >expect &&\n     ++\ttest_cmp expect actual &&\n     ++\n      +\tcheck_log_subjects start..HEAD <<-\\EOF &&\n      +\ttwo\n      +\tone\n 4:  08915cee51 = 4:  41156c9afb sequencer: share the squash message marker helpers and flags\n 5:  fb76afe31c ! 5:  cdbc183428 history: re-edit a squash with every message\n     @@ Documentation/git-history.adoc: already on `topic`. Rev-list options may also be\n       +\n       A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n       targets is also in the range, so the fold does not silently absorb a\n     -@@ Documentation/git-history.adoc: marker meant for a commit outside it. The body after an `amend!` subject\n     - replaces the oldest commit's message when the marker targets that commit. As\n     - an exception, a range made up entirely of markers for one target is combined\n     - into a single commit, keeping the last `amend!` message if there is one.\n     -+The changes from every commit in the range are always folded in. Only the\n     -+message text differs.\n     +@@ Documentation/git-history.adoc: marker meant for a commit outside it. As an exception, a range made up entirely\n     + of markers for one target is combined into a single commit, keeping the last\n     + `amend!` message if there is one.\n     + +\n      +With `--reedit-message` the template mirrors `git rebase -i --autosquash`:\n      +each `fixup!`, `squash!`, or `amend!` is grouped under the commit it\n      +targets rather than shown in commit order. A `fixup!` message is dropped\n     @@ Documentation/git-history.adoc: marker meant for a commit outside it. The body a\n      +subject commented, and an `amend!` replaces its target's message, unless\n      +a `squash!` folded into that target first, in which case it keeps its\n      +body like a `squash!`.\n     - +\n     +++\n       A branch or tag that points at a commit inside the range would be left\n       dangling once those commits are folded away, so with the default\n     + `--update-refs=branches` the command refuses. Rerun with\n     +@@ Documentation/git-history.adoc: OPTIONS\n     + \tref updates is generally safe.\n     + \n     + `--reedit-message`::\n     +-\tOpen an editor to modify the rewritten commit's message.\n     ++\tOpen an editor to modify the rewritten commit's message. For `squash`\n     ++\tthe editor is pre-filled with the messages of all the folded commits.\n     + \n     + `--empty=(drop|keep|abort)`::\n     + \tControl what happens when a commit becomes empty as a result of the\n      \n       ## builtin/history.c ##\n      @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data)\n\n-- \ngitgitgadget\n"},{"id":"548651","messageId":"f8481034975769641a15a94e0f33b39d9cf7b07f.1784536024.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com","subject":"[PATCH v10 1/5] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-20T08:27:00Z","receivedAt":"2026-07-20T08:27:08Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex d28c1f08bb..673744a55a 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -164,6 +164,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -171,21 +190,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -475,18 +484,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -830,16 +831,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"548652","messageId":"49dadc341083b8dfbbee43e6c4024a695a57e856.1784536024.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com","subject":"[PATCH v10 2/5] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-20T08:27:01Z","receivedAt":"2026-07-20T08:27:09Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 673744a55a..b592b98393 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -108,6 +108,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -675,7 +679,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -886,7 +890,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -903,7 +907,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"548654","messageId":"6b5b2c93f2e3e55bf456b86a8be61f5f85137a2c.1784536024.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com","subject":"[PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-20T08:27:02Z","receivedAt":"2026-07-20T08:27:10Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into the oldest one, keeping that commit's\nauthorship and taking the tree of the newest commit, then replays the\ncommits above the range on top. The squashed message comes from the\noldest commit by default, or from the body of the last amend! commit\ntargeting it. An editor opens with the selected message when\n--reedit-message is given. A fixup!, squash! or amend! commit is refused\nunless the commit it targets is also in the range, so the fold does not\nsilently absorb a marker meant for a commit outside it. The check runs\nthe range through todo_list_rearrange_squash(), which leaves such a\nmarker as a plain pick. Markers whose target is in the range fold in as\nusual. As an exception, a range made up entirely of markers for one\ntarget is combined anyway, taking its message from the last amend! if\nthere is one, so a batch of fixups for the same commit can be collapsed.\n\nThe range is read like the arguments to \"git rev-list\", so several\nrevisions such as \"HEAD~3..HEAD ^topic\" may be given, and rev-list\noptions are accepted too. As \"git replay\" does, the walk options the fold\nrelies on are forced after setup_revisions() and a warning is printed if\nan option changed them, so the first commit returned is the range's\noldest and its parent is the base regardless of what the user passed\n(including after a \"--\"). A merge inside the range is folded when its\nother parent is reachable from the base, otherwise the range has more\nthan one base and is rejected. By default the command also refuses when a\nref points at a commit that the fold would discard. Use --update-refs=head\nto rewrite only the current branch instead.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  45 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 374 ++++++++++++++++++++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 569 +++++++++++++++++++++++++++++++\n 7 files changed, 992 insertions(+), 3 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 81f80a9274..e2a3487778 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -59,6 +59,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 28b477cd37..e1e930f355 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -43,8 +44,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold a\n+merge that lies inside the range, as long as the range has a single base.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -113,6 +117,41 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's authorship and\n+\ttakes the tree of the range's newest commit, so the whole range\n+\tcollapses into a single commit. Commits above the range are replayed\n+\ton top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. Several revisions may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`. Rev-list options may also be given, but any that would\n+change how the range is walked are overridden with a warning.\n++\n+The oldest commit's message is preserved by default, except that an `amend!`\n+commit targeting it replaces its message. Specify `--reedit-message` to edit\n+the resulting message. A merge commit inside the range is folded like any\n+other, but the range must have a single base, so a range that reaches more\n+than one entry point (for example a side branch that forked before the range\n+and was later merged into it) is rejected.\n++\n+A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n+targets is also in the range, so the fold does not silently absorb a\n+marker meant for a commit outside it. As an exception, a range made up entirely\n+of markers for one target is combined into a single commit, keeping the last\n+`amend!` message if there is one.\n++\n+A branch or tag that points at a commit inside the range would be left\n+dangling once those commits are folded away, so with the default\n+`--update-refs=branches` the command refuses. Rerun with\n+`--update-refs=head` to rewrite only the current branch and leave such\n+refs pointing at the old commits.\n+\n OPTIONS\n -------\n \n@@ -123,7 +162,7 @@ OPTIONS\n \tref updates is generally safe.\n \n `--reedit-message`::\n-\tOpen an editor to modify the target commit's message.\n+\tOpen an editor to modify the rewritten commit's message.\n \n `--empty=(drop|keep|abort)`::\n \tControl what happens when a commit becomes empty as a result of the\ndiff --git a/advice.c b/advice.c\nindex 63bf8b0c5f..401d047391 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 66f6cd6a77..3f0b4f0485 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex b592b98393..423c8beaaf 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -34,6 +35,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -1004,6 +1007,375 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char **argv,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tstruct oidset *interior_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct commit_list *boundaries = NULL, *b;\n+\tstruct strvec args = STRVEC_INIT;\n+\tsize_t i;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\trevs.reverse = 1;\n+\trevs.topo_order = 1;\n+\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\trevs.simplify_history = 0;\n+\trevs.boundary = 1;\n+\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--ancestry-path\");\n+\tstrvec_pushv(&args, argv);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n+\t\tgoto out;\n+\t}\n+\n+\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n+\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n+\t    revs.simplify_history != 0 || revs.boundary != 1) {\n+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n+\t\t\t  \"range is walked\"));\n+\t\trevs.reverse = 1;\n+\t\trevs.topo_order = 1;\n+\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\t\trevs.simplify_history = 0;\n+\t\trevs.boundary = 1;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (i = 0; i < revs.cmdline.nr; i++)\n+\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n+\t\t\tbreak;\n+\tif (i == revs.cmdline.nr) {\n+\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Set boundary commits aside for the base check below, and put every\n+\t * in-range commit but the tip into the interior set. A ref pointing\n+\t * at an interior commit would dangle once the range is folded away.\n+\t */\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tcommit_list_insert(commit, &boundaries);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\tif (tip)\n+\t\t\toidset_insert(interior_out, &tip->object.oid);\n+\t\ttip = commit;\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t} else if (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t} else if (!oldest->parents) {\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\t}\n+\tbase = oldest->parents->item;\n+\n+\t/*\n+\t * A boundary other than the base is an in-range commit reaching a\n+\t * commit outside the range, so the range has more than one base.\n+\t */\n+\tfor (b = boundaries; b; b = b->next) {\n+\t\tif (b->item != base) {\n+\t\t\tret = error(_(\"the revision range has more than one base; \"\n+\t\t\t\t      \"cannot squash\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tcommit_list_free(boundaries);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+static const char *autosquash_target(const char *subject)\n+{\n+\tconst char *rest;\n+\n+\twhile (skip_prefix(subject, \"fixup! \", &rest) ||\n+\t       skip_prefix(subject, \"squash! \", &rest) ||\n+\t       skip_prefix(subject, \"amend! \", &rest))\n+\t\tsubject = rest;\n+\treturn subject;\n+}\n+\n+static int reject_dangling_fixups(struct repository *repo,\n+\t\t\t\t  struct commit *base,\n+\t\t\t\t  struct commit *tip,\n+\t\t\t\t  struct commit *oldest,\n+\t\t\t\t  struct commit **msg_source,\n+\t\t\t\t  struct commit **amend_source)\n+{\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *last_amend = NULL;\n+\tstruct strvec args = STRVEC_INIT;\n+\tchar *dangling_subject = NULL, *dangling_target = NULL;\n+\tbool mixed_target = false, all_fixups_one_target;\n+\tbool past_oldest_group = false;\n+\tint i, ret, nr_dangling = 0;\n+\n+\t*msg_source = oldest;\n+\t*amend_source = NULL;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\twhile ((commit = get_revision(&revs)))\n+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n+\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not check the range for fixups\"));\n+\t\tgoto out;\n+\t}\n+\n+\tfor (i = 0; i < todo.nr; i++) {\n+\t\tconst char *message, *subject_start, *target;\n+\t\tchar *subject;\n+\t\tsize_t sublen;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, todo.items[i].commit,\n+\t\t\t\t\t       NULL, NULL);\n+\t\tsublen = find_commit_subject(message, &subject_start);\n+\n+\t\tif (todo.items[i].command != TODO_PICK) {\n+\t\t\tif (!past_oldest_group &&\n+\t\t\t    starts_with(subject_start, \"amend! \"))\n+\t\t\t\t*amend_source = todo.items[i].commit;\n+\t\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (i)\n+\t\t\tpast_oldest_group = true;\n+\n+\t\tsubject = xmemdupz(subject_start, sublen);\n+\t\ttarget = autosquash_target(subject);\n+\t\tif (target != subject) {\n+\t\t\tnr_dangling++;\n+\t\t\tif (!dangling_target) {\n+\t\t\t\tdangling_target = xstrdup(target);\n+\t\t\t\tdangling_subject = xstrdup(subject);\n+\t\t\t} else if (strcmp(dangling_target, target)) {\n+\t\t\t\tmixed_target = true;\n+\t\t\t}\n+\t\t\tif (starts_with(subject, \"amend! \"))\n+\t\t\t\tlast_amend = todo.items[i].commit;\n+\t\t}\n+\t\tfree(subject);\n+\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n+\t}\n+\n+\tall_fixups_one_target = nr_dangling == todo.nr && !mixed_target;\n+\tif (nr_dangling && !all_fixups_one_target) {\n+\t\tret = error(_(\"cannot squash '%s': its target is not in the \"\n+\t\t\t      \"range\"), dangling_subject);\n+\t} else {\n+\t\tif (last_amend)\n+\t\t\t*msg_source = last_amend;\n+\t\tret = 0;\n+\t}\n+\n+out:\n+\tfree(dangling_subject);\n+\tfree(dangling_target);\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+struct interior_ref_cb {\n+\tconst struct oidset *interior;\n+\tconst char *name;\n+};\n+\n+static int find_interior_ref(const struct reference *ref, void *cb_data)\n+{\n+\tstruct interior_ref_cb *data = cb_data;\n+\n+\tif (oidset_contains(data->interior, ref->oid)) {\n+\t\tdata->name = xstrdup(ref->name);\n+\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n+\tstruct oidset interior = OIDSET_INIT;\n+\tstruct commit *base, *oldest, *tip, *rewritten, *msg_source,\n+\t\t*amend_source;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tconst char *message_template = NULL;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage,\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT);\n+\tif (!argc) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n+\t\t\t\t   &interior);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,\n+\t\t\t\t     &amend_source);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\tif (amend_source) {\n+\t\tconst char *amend_message, *body;\n+\n+\t\tamend_message = repo_logmsg_reencode(repo, amend_source,\n+\t\t\t\t\t\t     NULL, NULL);\n+\t\tfind_commit_subject(amend_message, &body);\n+\t\tbody = skip_blank_lines(body + commit_subject_length(body));\n+\t\tstrbuf_addstr(&message, body);\n+\t\tmessage_template = message.buf;\n+\t\trepo_unuse_commit_buffer(repo, amend_source, amend_message);\n+\t}\n+\n+\tif (action == REF_ACTION_BRANCHES) {\n+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n+\n+\t\trefs_for_each_ref(get_main_ref_store(repo),\n+\t\t\t\t  find_interior_ref, &cb);\n+\t\tif (cb.name) {\n+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n+\t\t\t\t    cb.name);\n+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t\t    \"untouched.\"));\n+\t\t\tfree((char *)cb.name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", msg_source, message_template,\n+\t\t\t      parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n+\tstrbuf_join_argv(&reflog_msg, argc, argv, ' ');\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n+\toidset_clear(&interior);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int update_worktree(struct repository *repo,\n \t\t\t   const struct commit *old_head,\n \t\t\t   const struct commit *new_head,\n@@ -1192,6 +1564,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -1200,6 +1573,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex 8ae6ab6c5f..89cff16405 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -405,6 +405,7 @@ integration_tests = [\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n   't3454-history-drop.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..9c362f3094\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,569 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+stage_file () {\n+\tprintf \"%s\\n\" \"$1\" >file &&\n+\tgit add file\n+}\n+\n+commit_with_message () {\n+\tprintf \"%b\" \"$1\" >msg &&\n+\tgit commit --allow-empty -qF msg\n+}\n+\n+check_commit_count () {\n+\tgit rev-list --count \"$1\" >actual &&\n+\techo \"$2\" >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_subjects () {\n+\tgit log --format=\"%s\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_messages () {\n+\tgit log --format=\"%B\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\ttest_commit --no-tag one other x &&\n+\ttest_commit --no-tag two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..HEAD ^keep &&\n+\n+\tgit reflog -1 --format=%gs >actual &&\n+\techo \"squash: updating start..HEAD ^keep\" >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n+\tgit checkout \"$main\" &&\n+\n+\tgit history squash start..off-history &&\n+\n+\tcheck_commit_count start..off-history 1 &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tone\n+\tEOF\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'sanitizes rev-list walk options, before and after --' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --date-order start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash -- --reverse start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start..@~1 &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$root..\" &&\n+\n+\tcheck_commit_count \"$root..HEAD\" 1 &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+\n+test_expect_success 'folds fixups whose target is in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag target file b &&\n+\tgit commit --allow-empty -m \"fixup! target\" &&\n+\tgit commit --allow-empty -m \"fixup! target\" &&\n+\ttest_commit --no-tag later file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\ttarget\n+\tEOF\n+'\n+\n+test_expect_success 'refuses a below-range fixup! after an in-range commit' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag inside file b &&\n+\ttest_commit --no-tag \"fixup! outside\" file c &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'combines a run of fixups for one commit below the range' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! base\" &&\n+\tstage_file c && git commit -m \"fixup! base\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tfixup! base\n+\tEOF\n+'\n+\n+test_expect_success 'combining below-range fixups keeps the last amend! message' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! base\" &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! base\\n\\namended body\\n\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamend! base\n+\n+\tamended body\n+\n+\tEOF\n+'\n+\n+test_expect_success 'refuses fixups for two different commits below the range' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! aaa\" &&\n+\tstage_file c && git commit -m \"fixup! bbb\" &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'the last amend! for the oldest commit replaces its message' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag marker-oldest file b &&\n+\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n+\tcommit_with_message \"amend! marker-oldest\\n\\nearlier message\\n\" &&\n+\tcommit_with_message \\\n+\t\t\"amend! marker-oldest\\n\\namended subject\\n\\namended body\\n\" &&\n+\ttest_commit --no-tag marker-later file c &&\n+\tcommit_with_message \"amend! marker-later\\n\\nwrong message\\n\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended subject\n+\n+\tamended body\n+\n+\tEOF\n+'\n+\n+test_expect_success 'preserves authorship of the oldest commit' '\n+\tgit reset --hard start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit --no-tag oldest file b &&\n+\ttest_commit newest file c &&\n+\n+\tgit history squash start.. &&\n+\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"error: .* points into the squashed range\" err &&\n+\ttest_grep \"hint: .*--update-refs=head\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\tmid_before=$(git rev-parse mid) &&\n+\n+\tgit history squash --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'refuses to fold a range a tag points into' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"refs/tags/mark\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit tag -d mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tbefore-side\n+\tEOF\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash \"$base..\" &&\n+\n+\tcheck_commit_count \"$base..HEAD\" 1 &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"at-merge\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"548653","messageId":"41156c9afb97512ebd4b54f5e21e31dc3c612a26.1784536024.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com","subject":"[PATCH v10 4/5] sequencer: share the squash message marker helpers and flags","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-20T08:27:03Z","receivedAt":"2026-07-20T08:27:11Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). Also move the\ntodo_item_flags enum to the header, so a caller reading the output of\ntodo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\nfrom a plain fixup!. A later change reuses all of this to give \"git\nhistory squash --reedit-message\" the same template.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 70 +++++++++++++++++++++++++++++------------------------\n sequencer.h | 30 +++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 31 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 1355a99a09..3c704fd5ab 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)\n \t}\n }\n \n-enum todo_item_flags {\n-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n-};\n-\n static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 64a9c7fb1b..b01f897020 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -119,6 +119,13 @@ enum todo_command {\n \tTODO_COMMENT\n };\n \n+/* Bits for the \"flags\" member of struct todo_item */\n+enum todo_item_flags {\n+\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n+\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n+\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n+};\n+\n struct todo_item {\n \tenum todo_command command;\n \tstruct commit *commit;\n@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"548655","messageId":"cdbc183428509b130bd303b8962aa06d578cf2e9.1784536024.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com","subject":"[PATCH v10 5/5] history: re-edit a squash with every message","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-20T08:27:04Z","receivedAt":"2026-07-20T08:27:12Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nBy default \"git history squash\" reuses the oldest commit's message, or\nthe replacement body from an amend! commit targeting it. When\n--reedit-message is given it only reopened that selected message, so the\nmessages of the other commits in the range were lost.\n\nGather the message of every commit in the range and build the same editor\ntemplate that \"git rebase -i --autosquash\" shows for a squash, reusing\nadd_squash_combination_header(), add_squash_message_header() and\nsquash_subject_comment_len(). Feed the range through\ntodo_list_rearrange_squash() so that each fixup!, squash! or amend! is\ngrouped under the commit it targets rather than shown in commit order,\nexactly as autosquash would arrange them.\n\nOnly the message text differs, the changes are always folded in. A fixup!\nmessage is commented out in full under a \"will be skipped\" header, a\nsquash! keeps its body with only the marker subject commented, and an\namend! replaces its target's message unless a squash! already folded into\nthat target, in which case it behaves like a squash!.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  22 +++-\n builtin/history.c              | 104 +++++++++++++++++\n t/t3455-history-squash.sh      | 201 +++++++++++++++++++++++++++++++++\n 3 files changed, 321 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex e1e930f355..6f3b031d2a 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -134,11 +134,12 @@ already on `topic`. Rev-list options may also be given, but any that would\n change how the range is walked are overridden with a warning.\n +\n The oldest commit's message is preserved by default, except that an `amend!`\n-commit targeting it replaces its message. Specify `--reedit-message` to edit\n-the resulting message. A merge commit inside the range is folded like any\n-other, but the range must have a single base, so a range that reaches more\n-than one entry point (for example a side branch that forked before the range\n-and was later merged into it) is rejected.\n+commit targeting it replaces its message. With `--reedit-message`, an editor\n+opens pre-filled with the messages of all the folded commits so you can\n+combine them. A merge commit inside the range is folded like any other, but\n+the range must have a single base, so a range that reaches more than one entry\n+point (for example a side branch that forked before the range and was later\n+merged into it) is rejected.\n +\n A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n targets is also in the range, so the fold does not silently absorb a\n@@ -146,6 +147,14 @@ marker meant for a commit outside it. As an exception, a range made up entirely\n of markers for one target is combined into a single commit, keeping the last\n `amend!` message if there is one.\n +\n+With `--reedit-message` the template mirrors `git rebase -i --autosquash`:\n+each `fixup!`, `squash!`, or `amend!` is grouped under the commit it\n+targets rather than shown in commit order. A `fixup!` message is dropped\n+(commented out in full), a `squash!` keeps its body with only the marker\n+subject commented, and an `amend!` replaces its target's message, unless\n+a `squash!` folded into that target first, in which case it keeps its\n+body like a `squash!`.\n++\n A branch or tag that points at a commit inside the range would be left\n dangling once those commits are folded away, so with the default\n `--update-refs=branches` the command refuses. Rerun with\n@@ -162,7 +171,8 @@ OPTIONS\n \tref updates is generally safe.\n \n `--reedit-message`::\n-\tOpen an editor to modify the rewritten commit's message.\n+\tOpen an editor to modify the rewritten commit's message. For `squash`\n+\tthe editor is pre-filled with the messages of all the folded commits.\n \n `--empty=(drop|keep|abort)`::\n \tControl what happens when a commit becomes empty as a result of the\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 423c8beaaf..2542ea33a0 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1255,6 +1255,102 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n+static bool amend_replaces_target(struct todo_list *todo, int target)\n+{\n+\tint i;\n+\n+\tfor (i = target + 1; i < todo->nr &&\n+\t\t\t     todo->items[i].command != TODO_PICK; i++) {\n+\t\tif (todo->items[i].command == TODO_SQUASH)\n+\t\t\treturn false;\n+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tint i, nr_commits, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs)))\n+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n+\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not prepare the squash message\"));\n+\t\tgoto out;\n+\t}\n+\n+\tnr_commits = todo.nr;\n+\tfor (i = 0; i < nr_commits; i++) {\n+\t\tstruct todo_item *item = &todo.items[i];\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tbool skip, squashing;\n+\n+\t\tsquashing = item->command == TODO_SQUASH ||\n+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n+\t\tif (item->command == TODO_PICK)\n+\t\t\tskip = amend_replaces_target(&todo, i);\n+\t\telse\n+\t\t\tskip = !squashing;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tif (skip)\n+\t\t\tcommented_len = strlen(body);\n+\t\telse if (squashing)\n+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n+\t\telse\n+\t\t\tcommented_len = 0;\n+\n+\t\tif (!i)\n+\t\t\tadd_squash_combination_header(out, nr_commits);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, i + 1, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1338,6 +1434,14 @@ static int cmd_history_squash(int argc,\n \t\t}\n \t}\n \n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tstrbuf_reset(&message);\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t\tmessage_template = message.buf;\n+\t}\n+\n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n \t\tgoto out;\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 9c362f3094..6fce924327 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -271,6 +271,207 @@ test_expect_success 'preserves authorship of the oldest commit' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--reedit-message offers every folded-in message' '\n+\tgit reset --hard start &&\n+\tstage_file b &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tcombined\n+\tEOF\n+'\n+\n+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\tstage_file e &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tmark-base\n+\n+\tsquash remark\n+\n+\tamended message\n+\n+\tEOF\n+'\n+\n+test_expect_success '--reedit-message groups fixups under their targets' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag alpha file a1 &&\n+\ttest_commit --no-tag beta file b1 &&\n+\tstage_file a2 &&\n+\tcommit_with_message \"fixup! alpha\\n\" &&\n+\tstage_file b2 &&\n+\tcommit_with_message \"fixup! beta\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\talpha\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! alpha\n+\n+\t# This is the commit message #3:\n+\n+\tbeta\n+\n+\t# The commit message #4 will be skipped:\n+\n+\t# fixup! beta\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited\n+'\n+\n+test_expect_success '--reedit-message lets amend! replace its target message' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --reedit-message start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# The 1st commit message will be skipped:\n+\n+\t# mark-base\n+\n+\t# This is the commit message #2:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended message\n+\n+\tsquash remark\n+\n+\tEOF\n+'\n+\n+test_expect_success '--reedit-message aborts on an empty message' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\ttest_must_fail git history squash --reedit-message start.. &&\n+\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n test_expect_success '--update-refs=head only moves HEAD' '\n \tgit reset --hard three &&\n \tgit branch -f other HEAD &&\n-- \ngitgitgadget\n"},{"id":"548709","messageId":"DK3V1Y7PRASL.2UPWZ8I2MZA1N@lfurio.us","threadId":"65812","inReplyTo":"pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com","subject":"Re: [PATCH v10 0/5] history: add squash subcommand to fold a range","fromName":"Matt Hunter","fromEmail":"m@lfurio.us","sentAt":"2026-07-21T01:33:25Z","receivedAt":"2026-07-21T01:33:33Z","isPatch":true,"body":"On Mon Jul 20, 2026 at 4:26 AM EDT, Harald Nordgren via GitGitGadget wrote:\n> Adds git history squash <revision-range> to fold a range of commits.\n>\n> Changes in v10:\n>\n>  * Record the full revision expression in squash reflog.\n>  * Preserve the boundary-walk invariant when sanitizing rev-list options.\n>  * Clarify amend! and --reedit-message documentation.\n>\n\nv10 looks good to me!\nThanks!\n"},{"id":"549378","messageId":"pull.2337.v11.git.git.1785567209.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v10.git.git.1784536024.gitgitgadget@gmail.com","subject":"[PATCH v11 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-01T06:53:25Z","receivedAt":"2026-08-01T06:53:32Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v11:\n\n * Make message editing the default with the autosquash-style template, add\n   --no-edit instead of squash-specific --reedit-message.\n * Validate one actual boundary and tip, rejecting root-reaching and\n   multi-tip ranges.\n * Protect only interior local branches, leaving tags and remote-tracking\n   refs unchanged.\n * Move sequencer preparation before squash and fold the later\n   message-editing patch into the feature commit.\n\nChanges in v10:\n\n * Record the full revision expression in squash reflog.\n * Preserve the boundary-walk invariant when sanitizing rev-list options.\n * Clarify amend! and --reedit-message documentation.\n\nChanges in v9:\n\n * Use the last amend! targeting the oldest folded commit as the default\n   squashed message. Ignore amend! markers targeting later commits while\n   selecting that replacement message.\n * Improve tests.\n\nChanges in v8:\n\n * --reedit-message now builds the same editor template as git rebase -i\n   --autosquash: fixup!, squash! and amend! commits are grouped under the\n   commit they target instead of shown in commit order, and an amend!\n   replaces its target's message.\n * A fixup!, squash! or amend! is refused only when its target is outside\n   the range, so several fixups for an in-range commit fold together. A\n   range that is entirely markers for one below-range target is combined\n   into a single commit, keeping the last amend! message.\n * Merges inside the range are folded when the range has a single base, with\n   no dedicated opt-in flag, --ancestry-path ensures only commits descended\n   from the base are folded, and a range reaching more than one base is\n   rejected.\n * Rev-list options are accepted and sanitized the way git replay does,\n   forcing the walk order back with a warning, which also fixes git history\n   squash -- --reverse slipping past the previous option check.\n * Kept this as an explicit squash subcommand rather than making\n   --reedit-message the default or renaming the command.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (4):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  sequencer: share the squash message marker helpers and flags\n  history: add squash subcommand to fold a range\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  57 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 578 +++++++++++++++++++++--\n sequencer.c                      |  70 +--\n sequencer.h                      |  30 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 758 +++++++++++++++++++++++++++++++\n 9 files changed, 1429 insertions(+), 71 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 13c7afec212fc97ce257d15601659314c6673d6c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v11\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v11\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v10:\n\n 1:  f848103497 = 1:  dd1deb5e5b history: extract helper for a commit's parent tree\n 2:  49dadc3410 = 2:  7309f64485 history: give commit_tree_ext a message template\n 4:  41156c9afb ! 3:  0762589e22 sequencer: share the squash message marker helpers and flags\n     @@ Commit message\n          todo_item_flags enum to the header, so a caller reading the output of\n          todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\n          from a plain fixup!. A later change reuses all of this to give \"git\n     -    history squash --reedit-message\" the same template.\n     +    history squash\" the same template.\n      \n          No change in behavior.\n      \n 3:  6b5b2c93f2 ! 4:  7abda3643c history: add squash subcommand to fold a range\n     @@ Commit message\n          a \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n      \n          Add \"git history squash <revision-range>\" to do this directly. It folds\n     -    every commit in the range into the oldest one, keeping that commit's\n     -    authorship and taking the tree of the newest commit, then replays the\n     -    commits above the range on top. The squashed message comes from the\n     -    oldest commit by default, or from the body of the last amend! commit\n     -    targeting it. An editor opens with the selected message when\n     -    --reedit-message is given. A fixup!, squash! or amend! commit is refused\n     -    unless the commit it targets is also in the range, so the fold does not\n     -    silently absorb a marker meant for a commit outside it. The check runs\n     -    the range through todo_list_rearrange_squash(), which leaves such a\n     -    marker as a plain pick. Markers whose target is in the range fold in as\n     -    usual. As an exception, a range made up entirely of markers for one\n     -    target is combined anyway, taking its message from the last amend! if\n     -    there is one, so a batch of fixups for the same commit can be collapsed.\n     +    every commit in the range into one commit, preserving the authorship of\n     +    the oldest commit and taking the tree of the tip, then replays commits\n     +    above the range. The editor opens with every folded message in the same\n     +    template used by \"git rebase -i --autosquash\". With --no-edit, the\n     +    oldest message is kept, or the last amend! replacement that targets it.\n     +\n     +    A fixup!, squash! or amend! commit is refused unless its target is also\n     +    in the range, so the fold does not silently absorb a marker intended for\n     +    another commit. As an exception, a range made up entirely of markers for\n     +    one target is combined, allowing a batch of related fixups to be\n     +    consolidated.\n      \n          The range is read like the arguments to \"git rev-list\", so several\n          revisions such as \"HEAD~3..HEAD ^topic\" may be given, and rev-list\n     -    options are accepted too. As \"git replay\" does, the walk options the fold\n     -    relies on are forced after setup_revisions() and a warning is printed if\n     -    an option changed them, so the first commit returned is the range's\n     -    oldest and its parent is the base regardless of what the user passed\n     -    (including after a \"--\"). A merge inside the range is folded when its\n     -    other parent is reachable from the base, otherwise the range has more\n     -    than one base and is rejected. By default the command also refuses when a\n     -    ref points at a commit that the fold would discard. Use --update-refs=head\n     -    to rewrite only the current branch instead.\n     +    options are accepted too. Walk-order options needed by the fold are\n     +    restored after setup_revisions(), with a warning when an option changed\n     +    them. The selected graph must have one actual boundary and one actual\n     +    tip and must not reach a root. A merge inside the range is folded when\n     +    all selected history reaches the same boundary; otherwise the range has\n     +    more than one base and is rejected.\n     +\n     +    By default the command refuses when a local branch points inside the\n     +    range, because that branch cannot be replayed as a descendant of the\n     +    result. Tags and remote-tracking refs are left unchanged. Use\n     +    --update-refs=head to leave interior branches unchanged as well.\n      \n          Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n     @@ Documentation/git-history.adoc: git history drop <commit> [--dry-run] [--update-\n       git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n       git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n       git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n     -+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\n     ++git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\n       \n       DESCRIPTION\n       -----------\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +already on `topic`. Rev-list options may also be given, but any that would\n      +change how the range is walked are overridden with a warning.\n      ++\n     -+The oldest commit's message is preserved by default, except that an `amend!`\n     -+commit targeting it replaces its message. Specify `--reedit-message` to edit\n     -+the resulting message. A merge commit inside the range is folded like any\n     -+other, but the range must have a single base, so a range that reaches more\n     ++An editor opens pre-filled with the messages of all the folded commits so\n     ++you can combine them. With `--no-edit`, the oldest commit's message is\n     ++preserved instead, except that an `amend!` commit targeting it replaces its\n     ++message. A merge commit inside the range is folded like any other, but the\n     ++range must have a single base and a single tip. A range that reaches more\n      +than one entry point (for example a side branch that forked before the range\n     -+and was later merged into it) is rejected.\n     ++and was later merged into it), or that selects two unmerged tips, is rejected.\n      ++\n      +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n      +targets is also in the range, so the fold does not silently absorb a\n      +marker meant for a commit outside it. As an exception, a range made up entirely\n     -+of markers for one target is combined into a single commit, keeping the last\n     -+`amend!` message if there is one.\n     ++of markers for one target is combined into a single commit. With `--no-edit`,\n     ++the last `amend!` message is used if there is one.\n      ++\n     -+A branch or tag that points at a commit inside the range would be left\n     -+dangling once those commits are folded away, so with the default\n     -+`--update-refs=branches` the command refuses. Rerun with\n     -+`--update-refs=head` to rewrite only the current branch and leave such\n     -+refs pointing at the old commits.\n     ++The template mirrors `git rebase -i --autosquash`: each `fixup!`, `squash!`,\n     ++or `amend!` is grouped under the commit it targets rather than shown in\n     ++commit order. A `fixup!` message is dropped (commented out in full), a\n     ++`squash!` keeps its body with only the marker subject commented, and an\n     ++`amend!` replaces its target's message, unless a `squash!` folded into that\n     ++target first, in which case it keeps its body like a `squash!`.\n     +++\n     ++A local branch that points at a commit inside the range cannot be rewritten\n     ++as a descendant of the result, so with the default `--update-refs=branches`\n     ++the command refuses. Rerun with `--update-refs=head` to rewrite only the\n     ++current branch and leave such branches pointing at the old commits. Tags and\n     ++remote-tracking refs are always left unchanged.\n      +\n       OPTIONS\n       -------\n       \n      @@ Documentation/git-history.adoc: OPTIONS\n     + \tobjects will be written into the repository, so applying these printed\n       \tref updates is generally safe.\n       \n     ++`--edit`::\n     ++`--no-edit`::\n     ++\tFor `squash`, open an editor to combine the messages of the folded commits.\n     ++\tThis is the default; use `--no-edit` to keep the selected message\n     ++\twithout opening an editor.\n     ++\n       `--reedit-message`::\n     --\tOpen an editor to modify the target commit's message.\n     -+\tOpen an editor to modify the rewritten commit's message.\n     + \tOpen an editor to modify the target commit's message.\n       \n     - `--empty=(drop|keep|abort)`::\n     - \tControl what happens when a commit becomes empty as a result of the\n      \n       ## advice.c ##\n      @@ advice.c: static struct {\n     @@ builtin/history.c\n       #define GIT_HISTORY_SPLIT_USAGE \\\n       \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n      +#define GIT_HISTORY_SQUASH_USAGE \\\n     -+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>\")\n     ++\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\")\n       \n       static void change_data_free(void *util, const char *str UNUSED)\n       {\n     @@ builtin/history.c: out:\n      +{\n      +\tstruct rev_info revs;\n      +\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n     -+\tstruct commit_list *boundaries = NULL, *b;\n     ++\tstruct commit_list *boundaries = NULL, *commits = NULL, *iter,\n     ++\t\t**commits_tail = &commits;\n     ++\tstruct oidset selected = OIDSET_INIT, has_children = OIDSET_INIT;\n      +\tstruct strvec args = STRVEC_INIT;\n     ++\tbool reaches_root = false;\n      +\tsize_t i;\n      +\tint ret;\n      +\n     @@ builtin/history.c: out:\n      +\t\trevs.boundary = 1;\n      +\t}\n      +\n     -+\t/*\n     -+\t * A squash needs a base to reparent onto, so the range has to exclude\n     -+\t * something, as in \"<base>..<tip>\". A revision range with no such\n     -+\t * bottom commit cannot be squashed.\n     -+\t */\n     ++\t/* A squash range must name a bottom revision to reparent onto. */\n      +\tfor (i = 0; i < revs.cmdline.nr; i++)\n     -+\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n     ++\t\tif (revs.cmdline.rev[i].flags & BOTTOM)\n      +\t\t\tbreak;\n      +\tif (i == revs.cmdline.nr) {\n      +\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n     @@ builtin/history.c: out:\n      +\t\tgoto out;\n      +\t}\n      +\n     -+\t/*\n     -+\t * Set boundary commits aside for the base check below, and put every\n     -+\t * in-range commit but the tip into the interior set. A ref pointing\n     -+\t * at an interior commit would dangle once the range is folded away.\n     -+\t */\n     ++\t/* Set boundary commits aside for the base check below. */\n      +\twhile ((commit = get_revision(&revs))) {\n      +\t\tif (commit->object.flags & BOUNDARY) {\n      +\t\t\tcommit_list_insert(commit, &boundaries);\n     @@ builtin/history.c: out:\n      +\t\t}\n      +\t\tif (!oldest)\n      +\t\t\toldest = commit;\n     -+\t\tif (tip)\n     -+\t\t\toidset_insert(interior_out, &tip->object.oid);\n     -+\t\ttip = commit;\n     ++\t\toidset_insert(&selected, &commit->object.oid);\n     ++\t\tcommits_tail = commit_list_append(commit, commits_tail);\n      +\t}\n      +\n      +\tif (!oldest) {\n      +\t\tret = error(_(\"the revision range is empty\"));\n      +\t\tgoto out;\n     -+\t} else if (oldest == tip) {\n     ++\t} else if (!commits->next) {\n      +\t\tret = error(_(\"the revision range holds a single commit; \"\n      +\t\t\t      \"nothing to squash\"));\n      +\t\tgoto out;\n     -+\t} else if (!oldest->parents) {\n     -+\t\tBUG(\"an in-range commit must have a parent\");\n      +\t}\n     -+\tbase = oldest->parents->item;\n      +\n      +\t/*\n     -+\t * A boundary other than the base is an in-range commit reaching a\n     -+\t * commit outside the range, so the range has more than one base.\n     ++\t * Find the selected commits that have selected children. The only\n     ++\t * remaining commit must be the tip whose tree becomes the result.\n      +\t */\n     -+\tfor (b = boundaries; b; b = b->next) {\n     -+\t\tif (b->item != base) {\n     ++\tfor (iter = commits; iter; iter = iter->next) {\n     ++\t\tstruct commit_list *p;\n     ++\n     ++\t\tif (!iter->item->parents)\n     ++\t\t\treaches_root = true;\n     ++\t\tfor (p = iter->item->parents; p; p = p->next)\n     ++\t\t\tif (oidset_contains(&selected, &p->item->object.oid))\n     ++\t\t\t\toidset_insert(&has_children,\n     ++\t\t\t\t\t      &p->item->object.oid);\n     ++\t}\n     ++\tif (reaches_root) {\n     ++\t\tret = error(_(\"the revision range reaches a root commit; \"\n     ++\t\t\t      \"cannot squash\"));\n     ++\t\tgoto out;\n     ++\t}\n     ++\tfor (iter = commits; iter; iter = iter->next) {\n     ++\t\tif (oidset_contains(&has_children, &iter->item->object.oid))\n     ++\t\t\tcontinue;\n     ++\t\tif (tip) {\n     ++\t\t\tret = error(_(\"the revision range has more than one tip; \"\n     ++\t\t\t\t      \"cannot squash\"));\n     ++\t\t\tgoto out;\n     ++\t\t}\n     ++\t\ttip = iter->item;\n     ++\t}\n     ++\n     ++\t/* The range must reach exactly one commit outside it. */\n     ++\tif (!boundaries) {\n     ++\t\tret = error(_(\"the revision range has no base; cannot squash\"));\n     ++\t\tgoto out;\n     ++\t}\n     ++\tbase = boundaries->item;\n     ++\tfor (iter = boundaries; iter; iter = iter->next) {\n     ++\t\tif (iter->item != base) {\n      +\t\t\tret = error(_(\"the revision range has more than one base; \"\n      +\t\t\t\t      \"cannot squash\"));\n      +\t\t\tgoto out;\n      +\t\t}\n      +\t}\n      +\n     ++\tfor (iter = commits; iter; iter = iter->next)\n     ++\t\tif (iter->item != tip)\n     ++\t\t\toidset_insert(interior_out, &iter->item->object.oid);\n     ++\n      +\t*base_out = base;\n      +\t*oldest_out = oldest;\n      +\t*tip_out = tip;\n     @@ builtin/history.c: out:\n      +\n      +out:\n      +\tcommit_list_free(boundaries);\n     ++\tcommit_list_free(commits);\n     ++\toidset_clear(&selected);\n     ++\toidset_clear(&has_children);\n      +\treset_revision_walk();\n      +\trelease_revisions(&revs);\n      +\tstrvec_clear(&args);\n     @@ builtin/history.c: out:\n      +static int reject_dangling_fixups(struct repository *repo,\n      +\t\t\t\t  struct commit *base,\n      +\t\t\t\t  struct commit *tip,\n     -+\t\t\t\t  struct commit *oldest,\n     -+\t\t\t\t  struct commit **msg_source,\n      +\t\t\t\t  struct commit **amend_source)\n      +{\n      +\tstruct todo_list todo = TODO_LIST_INIT;\n     @@ builtin/history.c: out:\n      +\tbool past_oldest_group = false;\n      +\tint i, ret, nr_dangling = 0;\n      +\n     -+\t*msg_source = oldest;\n      +\t*amend_source = NULL;\n      +\n      +\trepo_init_revisions(repo, &revs, NULL);\n     @@ builtin/history.c: out:\n      +\t\t\t      \"range\"), dangling_subject);\n      +\t} else {\n      +\t\tif (last_amend)\n     -+\t\t\t*msg_source = last_amend;\n     ++\t\t\t*amend_source = last_amend;\n      +\t\tret = 0;\n      +\t}\n      +\n     @@ builtin/history.c: out:\n      +\treturn 0;\n      +}\n      +\n     ++static bool amend_replaces_target(struct todo_list *todo, int target)\n     ++{\n     ++\tint i;\n     ++\n     ++\tfor (i = target + 1; i < todo->nr &&\n     ++\t\t\t     todo->items[i].command != TODO_PICK; i++) {\n     ++\t\tif (todo->items[i].command == TODO_SQUASH)\n     ++\t\t\treturn false;\n     ++\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n     ++\t\t\treturn true;\n     ++\t}\n     ++\treturn false;\n     ++}\n     ++\n     ++static int build_squash_message(struct repository *repo,\n     ++\t\t\t\tstruct commit *base,\n     ++\t\t\t\tstruct commit *tip,\n     ++\t\t\t\tstruct strbuf *out)\n     ++{\n     ++\tstruct rev_info revs;\n     ++\tstruct commit *commit;\n     ++\tstruct strvec args = STRVEC_INIT;\n     ++\tstruct todo_list todo = TODO_LIST_INIT;\n     ++\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n     ++\tint i, nr_commits, ret;\n     ++\n     ++\trepo_init_revisions(repo, &revs, NULL);\n     ++\tstrvec_push(&args, \"ignored\");\n     ++\tstrvec_push(&args, \"--reverse\");\n     ++\tstrvec_push(&args, \"--topo-order\");\n     ++\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n     ++\t\t     oid_to_hex(&tip->object.oid));\n     ++\tsetup_revisions_from_strvec(&args, &revs, NULL);\n     ++\n     ++\tif (prepare_revision_walk(&revs) < 0) {\n     ++\t\tret = error(_(\"error preparing revisions\"));\n     ++\t\tgoto out;\n     ++\t}\n     ++\n     ++\twhile ((commit = get_revision(&revs)))\n     ++\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n     ++\t\t\t    oid_to_hex(&commit->object.oid));\n     ++\n     ++\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n     ++\t    todo_list_rearrange_squash(&todo) < 0) {\n     ++\t\tret = error(_(\"could not prepare the squash message\"));\n     ++\t\tgoto out;\n     ++\t}\n     ++\n     ++\tnr_commits = todo.nr;\n     ++\tfor (i = 0; i < nr_commits; i++) {\n     ++\t\tstruct todo_item *item = &todo.items[i];\n     ++\t\tconst char *message, *body;\n     ++\t\tsize_t commented_len;\n     ++\t\tbool skip, squashing;\n     ++\n     ++\t\tsquashing = item->command == TODO_SQUASH ||\n     ++\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n     ++\t\tif (item->command == TODO_PICK)\n     ++\t\t\tskip = amend_replaces_target(&todo, i);\n     ++\t\telse\n     ++\t\t\tskip = !squashing;\n     ++\n     ++\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n     ++\t\tfind_commit_subject(message, &body);\n     ++\n     ++\t\tif (skip)\n     ++\t\t\tcommented_len = strlen(body);\n     ++\t\telse if (squashing)\n     ++\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n     ++\t\telse\n     ++\t\t\tcommented_len = 0;\n     ++\n     ++\t\tif (!i)\n     ++\t\t\tadd_squash_combination_header(out, nr_commits);\n     ++\t\tstrbuf_addch(out, '\\n');\n     ++\t\tadd_squash_message_header(out, i + 1, skip);\n     ++\t\tstrbuf_addstr(out, \"\\n\\n\");\n     ++\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n     ++\t\tstrbuf_addstr(out, body + commented_len);\n     ++\t\tstrbuf_complete_line(out);\n     ++\n     ++\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n     ++\t}\n     ++\n     ++\tret = 0;\n     ++\n     ++out:\n     ++\ttodo_list_release(&todo);\n     ++\treplay_opts_release(&opts);\n     ++\treset_revision_walk();\n     ++\trelease_revisions(&revs);\n     ++\tstrvec_clear(&args);\n     ++\treturn ret;\n     ++}\n     ++\n      +static int cmd_history_squash(int argc,\n      +\t\t\t      const char **argv,\n      +\t\t\t      const char *prefix,\n     @@ builtin/history.c: out:\n      +\tenum ref_action action = REF_ACTION_DEFAULT;\n      +\tenum commit_tree_flags flags = 0;\n      +\tint dry_run = 0;\n     ++\tint edit = 1;\n      +\tstruct option options[] = {\n      +\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n      +\t\t\t       N_(\"control which refs should be updated\"),\n      +\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n      +\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n      +\t\t\t N_(\"perform a dry-run without updating any refs\")),\n     -+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n     -+\t\t\tN_(\"open an editor to modify the commit message\"),\n     -+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n     ++\t\tOPT_BOOL('e', \"edit\", &edit,\n     ++\t\t\t N_(\"edit the commit message\")),\n      +\t\tOPT_END(),\n      +\t};\n      +\tstruct strbuf reflog_msg = STRBUF_INIT;\n      +\tstruct strbuf message = STRBUF_INIT;\n      +\tstruct oidset interior = OIDSET_INIT;\n     -+\tstruct commit *base, *oldest, *tip, *rewritten, *msg_source,\n     -+\t\t*amend_source;\n     ++\tstruct commit *base = NULL, *oldest = NULL, *tip = NULL, *rewritten,\n     ++\t\t*amend_source = NULL;\n      +\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n      +\tconst char *message_template = NULL;\n      +\tstruct commit_list *parents = NULL;\n     @@ builtin/history.c: out:\n      +\tif (ret < 0)\n      +\t\tgoto out;\n      +\n     -+\tret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,\n     -+\t\t\t\t     &amend_source);\n     ++\tret = reject_dangling_fixups(repo, base, tip, &amend_source);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n     -+\tif (amend_source) {\n     ++\tif (!edit && amend_source) {\n      +\t\tconst char *amend_message, *body;\n      +\n      +\t\tamend_message = repo_logmsg_reencode(repo, amend_source,\n     @@ builtin/history.c: out:\n      +\tif (action == REF_ACTION_BRANCHES) {\n      +\t\tstruct interior_ref_cb cb = { .interior = &interior };\n      +\n     -+\t\trefs_for_each_ref(get_main_ref_store(repo),\n     -+\t\t\t\t  find_interior_ref, &cb);\n     ++\t\trefs_for_each_branch_ref(get_main_ref_store(repo),\n     ++\t\t\t\t\t find_interior_ref, &cb);\n      +\t\tif (cb.name) {\n      +\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n      +\t\t\t\t    cb.name);\n     @@ builtin/history.c: out:\n      +\t\t}\n      +\t}\n      +\n     ++\tif (edit) {\n     ++\t\tret = build_squash_message(repo, base, tip, &message);\n     ++\t\tif (ret < 0)\n     ++\t\t\tgoto out;\n     ++\t\tmessage_template = message.buf;\n     ++\t\tflags |= COMMIT_TREE_EDIT_MESSAGE;\n     ++\t}\n     ++\n      +\tret = setup_revwalk(repo, action, tip, &revs);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n     @@ builtin/history.c: out:\n      +\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n      +\tcommit_list_append(base, &parents);\n      +\n     -+\tret = commit_tree_ext(repo, \"squash\", msg_source, message_template,\n     ++\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n      +\t\t\t      parents,\n      +\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n      +\tif (ret < 0) {\n     @@ t/t3455-history-squash.sh (new)\n      +test_expect_success 'setup linear history touching two files' '\n      +\ttest_commit base file a &&\n      +\tgit tag start &&\n     -+\ttest_commit --no-tag one other x &&\n     ++\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n     ++\t\ttest_commit --no-tag one other x &&\n      +\ttest_commit --no-tag two file c &&\n      +\ttest_commit three file d\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -f keep HEAD~2 &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash start..HEAD ^keep &&\n     ++\tgit history squash --no-edit start..HEAD ^keep &&\n      +\n      +\tgit reflog -1 --format=%gs >actual &&\n      +\techo \"squash: updating start..HEAD ^keep\" >expect &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D keep\n      +'\n      +\n     ++test_expect_success 'refuses a range with more than one tip' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\ttest_commit --no-tag main-one file b &&\n     ++\ttest_commit --no-tag main-two file c &&\n     ++\tgit checkout -b other-tip start &&\n     ++\ttest_commit --no-tag other-tip other d &&\n     ++\tgit checkout \"$main\" &&\n     ++\tmain_before=$(git rev-parse HEAD) &&\n     ++\tother_before=$(git rev-parse other-tip) &&\n     ++\n     ++\ttest_must_fail git history squash ^start HEAD other-tip 2>err &&\n     ++\ttest_grep \"more than one tip\" err &&\n     ++\ttest_cmp_rev \"$main_before\" HEAD &&\n     ++\ttest_cmp_rev \"$other_before\" other-tip &&\n     ++\n     ++\tgit branch -D other-tip\n     ++'\n     ++\n     ++test_expect_success 'refuses a range that reaches a root commit' '\n     ++\tgit reset --hard three &&\n     ++\troot=$(printf \"unrelated root\\n\" |\n     ++\t\tgit commit-tree \"$(git rev-parse HEAD^{tree})\") &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash --ancestry-path=\"$root\" \\\n     ++\t\t^start HEAD \"$root\" 2>err &&\n     ++\ttest_grep \"reaches a root commit\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD\n     ++'\n     ++\n      +test_expect_success 'squashes a branch the current branch is not on' '\n      +\tgit reset --hard three &&\n      +\tmain=$(git symbolic-ref --short HEAD) &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_commit --no-tag off-two off b &&\n      +\tgit checkout \"$main\" &&\n      +\n     -+\tgit history squash start..off-history &&\n     ++\tgit history squash --no-edit start..off-history &&\n      +\n      +\tcheck_commit_count start..off-history 1 &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D off-history\n      +'\n      +\n     -+test_expect_success 'squashes a range into a single commit without changing the tree' '\n     ++test_expect_success 'squashes a range preserving its tree and oldest authorship' '\n      +\tgit reset --hard three &&\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tcheck_log_subjects -1 <<-\\EOF &&\n      +\tone\n      +\tEOF\n     ++\tgit log -1 --format=\"%an <%ae>\" >actual &&\n     ++\techo \"Squasher <squash@example.com>\" >expect &&\n     ++\ttest_cmp expect actual &&\n      +\tgit reflog >reflog &&\n      +\ttest_grep \"squash: updating\" reflog\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit reset --hard three &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash --date-order start.. 2>err &&\n     ++\tgit history squash --no-edit --date-order start.. 2>err &&\n      +\ttest_grep \"ignoring rev-list options\" err &&\n      +\ttest_cmp_rev start HEAD^ &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n      +\n      +\tgit reset --hard three &&\n     -+\tgit history squash -- --reverse start.. 2>err &&\n     ++\tgit history squash --no-edit -- --reverse start.. 2>err &&\n      +\ttest_grep \"ignoring rev-list options\" err &&\n      +\ttest_cmp_rev start HEAD^ &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit reset --hard three &&\n      +\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash start..@~1 &&\n     ++\tgit history squash --no-edit start..@~1 &&\n      +\n      +\tcheck_log_subjects start..HEAD <<-\\EOF &&\n      +\tthree\n     @@ t/t3455-history-squash.sh (new)\n      +\troot=$(git rev-list --max-parents=0 HEAD) &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash \"$root..\" &&\n     ++\tgit history squash --no-edit \"$root..\" &&\n      +\n      +\tcheck_commit_count \"$root..HEAD\" 1 &&\n      +\ttest_cmp_rev \"$root\" HEAD^ &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit commit --allow-empty -m \"fixup! target\" &&\n      +\ttest_commit --no-tag later file c &&\n      +\n     -+\tgit history squash start.. &&\n     ++\tgit history squash --no-edit start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\tcheck_log_subjects -1 <<-\\EOF\n     @@ t/t3455-history-squash.sh (new)\n      +\tstage_file b && git commit -m \"fixup! base\" &&\n      +\tstage_file c && git commit -m \"fixup! base\" &&\n      +\n     -+\tgit history squash start.. &&\n     ++\tgit history squash --no-edit start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\tcheck_log_subjects -1 <<-\\EOF\n     @@ t/t3455-history-squash.sh (new)\n      +\tEOF\n      +'\n      +\n     -+test_expect_success 'combining below-range fixups keeps the last amend! message' '\n     ++test_expect_success 'combining below-range markers offers every message' '\n      +\tgit reset --hard start &&\n      +\tstage_file b && git commit -m \"fixup! base\" &&\n      +\tstage_file c &&\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\tcheck_log_messages -1 <<-\\EOF\n     ++\tfixup! base\n     ++\n      +\tamend! base\n      +\n      +\tamended body\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp_rev \"$head_before\" HEAD\n      +'\n      +\n     -+test_expect_success 'the last amend! for the oldest commit replaces its message' '\n     ++test_expect_success 'does not discard squash! or amend! message bodies' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag marker-oldest file b &&\n      +\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\tcheck_log_messages -1 <<-\\EOF\n     ++\tmarker-oldest\n     ++\n     ++\tearlier message\n     ++\n      +\tamended subject\n      +\n      +\tamended body\n      +\n     ++\twrong message\n     ++\n      +\tEOF\n      +'\n      +\n     -+test_expect_success 'preserves authorship of the oldest commit' '\n     ++test_expect_success '--no-edit keeps the selected message without an editor' '\n      +\tgit reset --hard start &&\n     -+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n     -+\t\ttest_commit --no-tag oldest file b &&\n     -+\ttest_commit newest file c &&\n     ++\ttest_commit --no-tag no-edit-target file b &&\n     ++\tgit commit --allow-empty -m \"squash! no-edit-target\" &&\n     ++\tcommit_with_message \"amend! no-edit-target\\n\\namended subject\\n\\namended body\\n\" &&\n     ++\n     ++\twrite_script editor <<-\\EOF &&\n     ++\texit 1\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n     ++\tgit history squash --no-edit start.. &&\n     ++\n     ++\tcheck_log_messages -1 <<-\\EOF &&\n     ++\tamended subject\n     ++\n     ++\tamended body\n     ++\n     ++\tEOF\n     ++\ttest_set_editor :\n     ++'\n     ++\n     ++test_expect_success 'edits every message and aborts on an empty result' '\n     ++\tgit reset --hard start &&\n     ++\tstage_file b &&\n     ++\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n     ++\ttest_commit --no-tag re-two file c &&\n     ++\ttest_commit re-three file d &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\twrite_script empty-editor <<-\\EOF &&\n     ++\t>\"$1\"\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/empty-editor\" &&\n     ++\ttest_must_fail git history squash start.. 2>err &&\n     ++\ttest_grep \"Aborting commit due to empty commit message\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n     ++\twrite_script editor <<-\\EOF &&\n     ++\tcat \"$1\" >edited &&\n     ++\techo combined >\"$1\"\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n      +\tgit history squash start.. &&\n      +\n     -+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n     -+\techo \"Squasher <squash@example.com>\" >expect &&\n     -+\ttest_cmp expect actual\n     ++\tcat >expect <<-EOF &&\n     ++\t# This is a combination of 3 commits.\n     ++\t# This is the 1st commit message:\n     ++\n     ++\tre-one subject\n     ++\n     ++\tre-one body line\n     ++\n     ++\t# This is the commit message #2:\n     ++\n     ++\tre-two\n     ++\n     ++\t# This is the commit message #3:\n     ++\n     ++\tre-three\n     ++\n     ++\t# Please enter the commit message for the squash changes. Lines starting\n     ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     ++\t# Changes to be committed:\n     ++\t#\tmodified:   file\n     ++\t#\n     ++\tEOF\n     ++\ttest_cmp expect edited &&\n     ++\tcheck_log_subjects -1 <<-\\EOF\n     ++\tcombined\n     ++\tEOF\n      +'\n      +\n     -+test_expect_success '--update-refs=head only moves HEAD' '\n     -+\tgit reset --hard three &&\n     -+\tgit branch -f other HEAD &&\n     -+\tother_before=$(git rev-parse other) &&\n     ++test_expect_success 'handles fixup!, squash! and amend! messages like rebase' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag mark-base file b &&\n     ++\tstage_file c &&\n     ++\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n     ++\tstage_file d &&\n     ++\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n     ++\tstage_file e &&\n     ++\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n     ++\n     ++\twrite_script editor <<-\\EOF &&\n     ++\tcat \"$1\" >edited\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n     ++\tgit history squash start.. &&\n      +\n     -+\tgit history squash --update-refs=head start.. &&\n     ++\tcat >expect <<-EOF &&\n     ++\t# This is a combination of 4 commits.\n     ++\t# This is the 1st commit message:\n      +\n     -+\tcheck_commit_count start..HEAD 1 &&\n     -+\ttest_cmp_rev \"$other_before\" other\n     ++\tmark-base\n     ++\n     ++\t# The commit message #2 will be skipped:\n     ++\n     ++\t# fixup! mark-base\n     ++\t#\n     ++\t# fixup body\n     ++\n     ++\t# This is the commit message #3:\n     ++\n     ++\t# squash! mark-base\n     ++\n     ++\tsquash remark\n     ++\n     ++\t# This is the commit message #4:\n     ++\n     ++\t# amend! mark-base\n     ++\n     ++\tamended message\n     ++\n     ++\t# Please enter the commit message for the squash changes. Lines starting\n     ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     ++\t# Changes to be committed:\n     ++\t#\tmodified:   file\n     ++\t#\n     ++\tEOF\n     ++\ttest_cmp expect edited &&\n     ++\tcheck_log_messages -1 <<-\\EOF\n     ++\tmark-base\n     ++\n     ++\tsquash remark\n     ++\n     ++\tamended message\n     ++\n     ++\tEOF\n     ++'\n     ++\n     ++test_expect_success 'groups fixups under their targets in the editor' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag alpha file a1 &&\n     ++\ttest_commit --no-tag beta file b1 &&\n     ++\tstage_file a2 &&\n     ++\tcommit_with_message \"fixup! alpha\\n\" &&\n     ++\tstage_file b2 &&\n     ++\tcommit_with_message \"fixup! beta\\n\" &&\n     ++\n     ++\twrite_script editor <<-\\EOF &&\n     ++\tcat \"$1\" >edited\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n     ++\tgit history squash start.. &&\n     ++\n     ++\tcat >expect <<-EOF &&\n     ++\t# This is a combination of 4 commits.\n     ++\t# This is the 1st commit message:\n     ++\n     ++\talpha\n     ++\n     ++\t# The commit message #2 will be skipped:\n     ++\n     ++\t# fixup! alpha\n     ++\n     ++\t# This is the commit message #3:\n     ++\n     ++\tbeta\n     ++\n     ++\t# The commit message #4 will be skipped:\n     ++\n     ++\t# fixup! beta\n     ++\n     ++\t# Please enter the commit message for the squash changes. Lines starting\n     ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     ++\t# Changes to be committed:\n     ++\t#\tmodified:   file\n     ++\t#\n     ++\tEOF\n     ++\ttest_cmp expect edited\n     ++'\n     ++\n     ++test_expect_success 'lets amend! replace its target message in the editor' '\n     ++\tgit reset --hard start &&\n     ++\ttest_commit --no-tag mark-base file b &&\n     ++\tstage_file c &&\n     ++\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n     ++\tstage_file d &&\n     ++\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n     ++\n     ++\twrite_script editor <<-\\EOF &&\n     ++\tcat \"$1\" >edited\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n     ++\tgit history squash start.. &&\n     ++\n     ++\tcat >expect <<-EOF &&\n     ++\t# This is a combination of 3 commits.\n     ++\t# The 1st commit message will be skipped:\n     ++\n     ++\t# mark-base\n     ++\n     ++\t# This is the commit message #2:\n     ++\n     ++\t# amend! mark-base\n     ++\n     ++\tamended message\n     ++\n     ++\t# This is the commit message #3:\n     ++\n     ++\t# squash! mark-base\n     ++\n     ++\tsquash remark\n     ++\n     ++\t# Please enter the commit message for the squash changes. Lines starting\n     ++\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     ++\t# Changes to be committed:\n     ++\t#\tmodified:   file\n     ++\t#\n     ++\tEOF\n     ++\ttest_cmp expect edited &&\n     ++\tcheck_log_messages -1 <<-\\EOF\n     ++\tamended message\n     ++\n     ++\tsquash remark\n     ++\n     ++\tEOF\n      +'\n      +\n     -+test_expect_success 'refuses to fold a range a ref points into' '\n     ++test_expect_success 'handles branches pointing at the squashed range' '\n      +\tgit reset --hard three &&\n     ++\tgit branch -f other HEAD &&\n      +\tgit branch -f mid HEAD~1 &&\n     ++\tother_before=$(git rev-parse other) &&\n     ++\tmid_before=$(git rev-parse mid) &&\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\n      +\ttest_must_fail git history squash start.. 2>err &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_grep \"hint: .*--update-refs=head\" err &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n     -+\tgit branch -D mid\n     -+'\n     -+\n     -+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n     -+\tgit reset --hard three &&\n     -+\tgit branch -f mid HEAD~1 &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     -+\n      +\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n      +\t\thistory squash start.. 2>err &&\n      +\ttest_grep \"points into the squashed range\" err &&\n      +\ttest_grep ! \"hint:\" err &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n     -+\tgit branch -D mid\n     -+'\n     -+\n     -+test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n     -+\tgit reset --hard three &&\n     -+\tgit branch -f mid HEAD~1 &&\n     -+\tmid_before=$(git rev-parse mid) &&\n     -+\n     -+\tgit history squash --update-refs=head start.. &&\n     ++\tgit history squash --no-edit --update-refs=head start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n     ++\ttest_cmp_rev \"$other_before\" other &&\n      +\ttest_cmp_rev \"$mid_before\" mid &&\n      +\n     -+\tgit branch -D mid\n     ++\tgit branch -D other mid\n      +'\n      +\n     -+test_expect_success 'refuses to fold a range a tag points into' '\n     ++test_expect_success 'leaves tags and remote-tracking refs unchanged' '\n      +\tgit reset --hard three &&\n      +\tgit tag -f mark HEAD~1 &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     ++\tgit update-ref refs/remotes/origin/mark HEAD~1 &&\n     ++\tmark_before=$(git rev-parse mark) &&\n      +\n     -+\ttest_must_fail git history squash start.. 2>err &&\n     -+\ttest_grep \"refs/tags/mark\" err &&\n     -+\ttest_grep \"points into the squashed range\" err &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\tgit history squash --no-edit start.. &&\n     ++\n     ++\ttest_cmp_rev \"$mark_before\" mark &&\n     ++\ttest_cmp_rev \"$mark_before\" refs/remotes/origin/mark &&\n      +\n     -+\tgit tag -d mark\n     ++\tgit tag -d mark &&\n     ++\tgit update-ref -d refs/remotes/origin/mark\n      +'\n      +\n     -+test_expect_success 'squashes a range whose internal merge has a single base' '\n     ++test_expect_success 'handles a branch pointing at an internal merge' '\n      +\tgit reset --hard start &&\n      +\tmain=$(git symbolic-ref --short HEAD) &&\n      +\ttest_commit --no-tag before-side file b &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_commit --no-tag after-side file c &&\n      +\tgit merge --no-ff -m merge inner-side &&\n      +\tgit branch -D inner-side &&\n     ++\tgit branch at-merge HEAD &&\n      +\ttest_commit --no-tag after-merge file d &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash start.. &&\n     ++\ttest_must_fail git history squash start.. 2>err &&\n     ++\ttest_grep \"at-merge\" err &&\n     ++\ttest_grep \"points into the squashed range\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\tgit branch -D at-merge &&\n     ++\n     ++\tgit history squash --no-edit start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\tcheck_log_subjects -1 <<-\\EOF &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_commit --no-tag base-fork-tail file c &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash start.. &&\n     ++\tgit history squash --no-edit start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\ttest_cmp_rev start HEAD^ &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D tipmerge-side &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash start.. &&\n     ++\tgit history squash --no-edit start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_commit --no-tag basemerge-two file d &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash \"$base..\" &&\n     ++\tgit history squash --no-edit \"$base..\" &&\n      +\n      +\tcheck_commit_count \"$base..HEAD\" 1 &&\n      +\ttest_cmp_rev \"$base\" HEAD^ &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n      +'\n      +\n     -+test_expect_success 'folds a range with two interior merges' '\n     -+\tgit reset --hard start &&\n     -+\tmain=$(git symbolic-ref --short HEAD) &&\n     -+\ttest_commit --no-tag two-merge-a file a1 &&\n     -+\tgit checkout -b two-merge-s1 &&\n     -+\ttest_commit --no-tag two-merge-s1 s1 x &&\n     -+\tgit checkout \"$main\" &&\n     -+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n     -+\ttest_commit --no-tag two-merge-b file b1 &&\n     -+\tgit checkout -b two-merge-s2 &&\n     -+\ttest_commit --no-tag two-merge-s2 s2 y &&\n     -+\tgit checkout \"$main\" &&\n     -+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n     -+\tgit branch -D two-merge-s1 two-merge-s2 &&\n     -+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     -+\n     -+\tgit history squash start.. &&\n     -+\n     -+\tcheck_commit_count start..HEAD 1 &&\n     -+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     -+\ttest_path_is_file s1 &&\n     -+\ttest_path_is_file s2\n     -+'\n     -+\n      +test_expect_success 'folds a range with a nested merge' '\n      +\tgit reset --hard start &&\n      +\tmain=$(git symbolic-ref --short HEAD) &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D nested-outer nested-inner &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash start.. &&\n     ++\tgit history squash --no-edit start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D octo-1 octo-2 &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash start.. &&\n     ++\tgit history squash --no-edit start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D desc-above &&\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\n     -+\ttest_must_fail git history squash start..desc-tip 2>err &&\n     ++\ttest_must_fail git history squash --no-edit start..desc-tip 2>err &&\n      +\ttest_grep \"merge commits is not supported\" err &&\n      +\ttest_cmp_rev \"$head_before\" HEAD\n      +'\n      +\n     -+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n     -+\tgit reset --hard start &&\n     -+\tmain=$(git symbolic-ref --short HEAD) &&\n     -+\ttest_commit --no-tag refmerge-base file b &&\n     -+\tgit checkout -b refmerge-side &&\n     -+\ttest_commit --no-tag refmerge-side side x &&\n     -+\tgit checkout \"$main\" &&\n     -+\ttest_commit --no-tag refmerge-main file c &&\n     -+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n     -+\tgit branch -D refmerge-side &&\n     -+\tgit branch at-merge HEAD &&\n     -+\ttest_commit --no-tag refmerge-tail file d &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     -+\n     -+\ttest_must_fail git history squash start.. 2>err &&\n     -+\ttest_grep \"at-merge\" err &&\n     -+\ttest_grep \"points into the squashed range\" err &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD &&\n     -+\n     -+\tgit branch -D at-merge\n     -+'\n     -+\n      +test_done\n 5:  cdbc183428 < -:  ---------- history: re-edit a squash with every message\n\n-- \ngitgitgadget\n"},{"id":"549377","messageId":"dd1deb5e5b52a1c585c3f96e6140cbb89fe8d8d8.1785567209.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v11.git.git.1785567209.gitgitgadget@gmail.com","subject":"[PATCH v11 1/4] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-01T06:53:26Z","receivedAt":"2026-08-01T06:53:33Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex d28c1f08bb..673744a55a 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -164,6 +164,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -171,21 +190,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -475,18 +484,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -830,16 +831,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"549379","messageId":"7309f6448527fcdb2aacf3f73cf5c16ba8238976.1785567209.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v11.git.git.1785567209.gitgitgadget@gmail.com","subject":"[PATCH v11 2/4] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-01T06:53:27Z","receivedAt":"2026-08-01T06:53:35Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 673744a55a..b592b98393 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -108,6 +108,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -675,7 +679,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -886,7 +890,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -903,7 +907,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"549380","messageId":"0762589e22f7bacfd436a2fe7c2d09c499701930.1785567209.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v11.git.git.1785567209.gitgitgadget@gmail.com","subject":"[PATCH v11 3/4] sequencer: share the squash message marker helpers and flags","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-01T06:53:28Z","receivedAt":"2026-08-01T06:53:37Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). Also move the\ntodo_item_flags enum to the header, so a caller reading the output of\ntodo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\nfrom a plain fixup!. A later change reuses all of this to give \"git\nhistory squash\" the same template.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 70 +++++++++++++++++++++++++++++------------------------\n sequencer.h | 30 +++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 31 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 82ab3c536f..642dfa9d91 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)\n \t}\n }\n \n-enum todo_item_flags {\n-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n-};\n-\n static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 64a9c7fb1b..b01f897020 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -119,6 +119,13 @@ enum todo_command {\n \tTODO_COMMENT\n };\n \n+/* Bits for the \"flags\" member of struct todo_item */\n+enum todo_item_flags {\n+\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n+\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n+\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n+};\n+\n struct todo_item {\n \tenum todo_command command;\n \tstruct commit *commit;\n@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"549381","messageId":"7abda3643cd8811dd2259040f2ce7a1cafe5373b.1785567209.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v11.git.git.1785567209.gitgitgadget@gmail.com","subject":"[PATCH v11 4/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-01T06:53:29Z","receivedAt":"2026-08-01T06:53:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery commit in the range into one commit, preserving the authorship of\nthe oldest commit and taking the tree of the tip, then replays commits\nabove the range. The editor opens with every folded message in the same\ntemplate used by \"git rebase -i --autosquash\". With --no-edit, the\noldest message is kept, or the last amend! replacement that targets it.\n\nA fixup!, squash! or amend! commit is refused unless its target is also\nin the range, so the fold does not silently absorb a marker intended for\nanother commit. As an exception, a range made up entirely of markers for\none target is combined, allowing a batch of related fixups to be\nconsolidated.\n\nThe range is read like the arguments to \"git rev-list\", so several\nrevisions such as \"HEAD~3..HEAD ^topic\" may be given, and rev-list\noptions are accepted too. Walk-order options needed by the fold are\nrestored after setup_revisions(), with a warning when an option changed\nthem. The selected graph must have one actual boundary and one actual\ntip and must not reach a root. A merge inside the range is folded when\nall selected history reaches the same boundary; otherwise the range has\nmore than one base and is rejected.\n\nBy default the command refuses when a local branch points inside the\nrange, because that branch cannot be replayed as a descendant of the\nresult. Tags and remote-tracking refs are left unchanged. Use\n--update-refs=head to leave interior branches unchanged as well.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  57 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 504 ++++++++++++++++++++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 758 +++++++++++++++++++++++++++++++\n 7 files changed, 1324 insertions(+), 2 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 81f80a9274..e2a3487778 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -59,6 +59,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 28b477cd37..12cf06c2c5 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -43,8 +44,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold a\n+merge that lies inside the range, as long as the range has a single base.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -113,6 +117,49 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's authorship and\n+\ttakes the tree of the range's newest commit, so the whole range\n+\tcollapses into a single commit. Commits above the range are replayed\n+\ton top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. Several revisions may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`. Rev-list options may also be given, but any that would\n+change how the range is walked are overridden with a warning.\n++\n+An editor opens pre-filled with the messages of all the folded commits so\n+you can combine them. With `--no-edit`, the oldest commit's message is\n+preserved instead, except that an `amend!` commit targeting it replaces its\n+message. A merge commit inside the range is folded like any other, but the\n+range must have a single base and a single tip. A range that reaches more\n+than one entry point (for example a side branch that forked before the range\n+and was later merged into it), or that selects two unmerged tips, is rejected.\n++\n+A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n+targets is also in the range, so the fold does not silently absorb a\n+marker meant for a commit outside it. As an exception, a range made up entirely\n+of markers for one target is combined into a single commit. With `--no-edit`,\n+the last `amend!` message is used if there is one.\n++\n+The template mirrors `git rebase -i --autosquash`: each `fixup!`, `squash!`,\n+or `amend!` is grouped under the commit it targets rather than shown in\n+commit order. A `fixup!` message is dropped (commented out in full), a\n+`squash!` keeps its body with only the marker subject commented, and an\n+`amend!` replaces its target's message, unless a `squash!` folded into that\n+target first, in which case it keeps its body like a `squash!`.\n++\n+A local branch that points at a commit inside the range cannot be rewritten\n+as a descendant of the result, so with the default `--update-refs=branches`\n+the command refuses. Rerun with `--update-refs=head` to rewrite only the\n+current branch and leave such branches pointing at the old commits. Tags and\n+remote-tracking refs are always left unchanged.\n+\n OPTIONS\n -------\n \n@@ -122,6 +169,12 @@ OPTIONS\n \tobjects will be written into the repository, so applying these printed\n \tref updates is generally safe.\n \n+`--edit`::\n+`--no-edit`::\n+\tFor `squash`, open an editor to combine the messages of the folded commits.\n+\tThis is the default; use `--no-edit` to keep the selected message\n+\twithout opening an editor.\n+\n `--reedit-message`::\n \tOpen an editor to modify the target commit's message.\n \ndiff --git a/advice.c b/advice.c\nindex 63bf8b0c5f..401d047391 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 66f6cd6a77..3f0b4f0485 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex b592b98393..43714fcb56 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -34,6 +35,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -1004,6 +1007,505 @@ out:\n \treturn ret;\n }\n \n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tconst char **argv,\n+\t\t\t\tstruct commit **base_out,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tstruct oidset *interior_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct commit_list *boundaries = NULL, *commits = NULL, *iter,\n+\t\t**commits_tail = &commits;\n+\tstruct oidset selected = OIDSET_INIT, has_children = OIDSET_INIT;\n+\tstruct strvec args = STRVEC_INIT;\n+\tbool reaches_root = false;\n+\tsize_t i;\n+\tint ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\trevs.reverse = 1;\n+\trevs.topo_order = 1;\n+\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\trevs.simplify_history = 0;\n+\trevs.boundary = 1;\n+\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--ancestry-path\");\n+\tstrvec_pushv(&args, argv);\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\tif (args.nr != 1) {\n+\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n+\t\tgoto out;\n+\t}\n+\n+\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n+\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n+\t    revs.simplify_history != 0 || revs.boundary != 1) {\n+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n+\t\t\t  \"range is walked\"));\n+\t\trevs.reverse = 1;\n+\t\trevs.topo_order = 1;\n+\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\t\trevs.simplify_history = 0;\n+\t\trevs.boundary = 1;\n+\t}\n+\n+\t/* A squash range must name a bottom revision to reparent onto. */\n+\tfor (i = 0; i < revs.cmdline.nr; i++)\n+\t\tif (revs.cmdline.rev[i].flags & BOTTOM)\n+\t\t\tbreak;\n+\tif (i == revs.cmdline.nr) {\n+\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\t/* Set boundary commits aside for the base check below. */\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tif (commit->object.flags & BOUNDARY) {\n+\t\t\tcommit_list_insert(commit, &boundaries);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\toidset_insert(&selected, &commit->object.oid);\n+\t\tcommits_tail = commit_list_append(commit, commits_tail);\n+\t}\n+\n+\tif (!oldest) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t} else if (!commits->next) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Find the selected commits that have selected children. The only\n+\t * remaining commit must be the tip whose tree becomes the result.\n+\t */\n+\tfor (iter = commits; iter; iter = iter->next) {\n+\t\tstruct commit_list *p;\n+\n+\t\tif (!iter->item->parents)\n+\t\t\treaches_root = true;\n+\t\tfor (p = iter->item->parents; p; p = p->next)\n+\t\t\tif (oidset_contains(&selected, &p->item->object.oid))\n+\t\t\t\toidset_insert(&has_children,\n+\t\t\t\t\t      &p->item->object.oid);\n+\t}\n+\tif (reaches_root) {\n+\t\tret = error(_(\"the revision range reaches a root commit; \"\n+\t\t\t      \"cannot squash\"));\n+\t\tgoto out;\n+\t}\n+\tfor (iter = commits; iter; iter = iter->next) {\n+\t\tif (oidset_contains(&has_children, &iter->item->object.oid))\n+\t\t\tcontinue;\n+\t\tif (tip) {\n+\t\t\tret = error(_(\"the revision range has more than one tip; \"\n+\t\t\t\t      \"cannot squash\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t\ttip = iter->item;\n+\t}\n+\n+\t/* The range must reach exactly one commit outside it. */\n+\tif (!boundaries) {\n+\t\tret = error(_(\"the revision range has no base; cannot squash\"));\n+\t\tgoto out;\n+\t}\n+\tbase = boundaries->item;\n+\tfor (iter = boundaries; iter; iter = iter->next) {\n+\t\tif (iter->item != base) {\n+\t\t\tret = error(_(\"the revision range has more than one base; \"\n+\t\t\t\t      \"cannot squash\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tfor (iter = commits; iter; iter = iter->next)\n+\t\tif (iter->item != tip)\n+\t\t\toidset_insert(interior_out, &iter->item->object.oid);\n+\n+\t*base_out = base;\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tcommit_list_free(boundaries);\n+\tcommit_list_free(commits);\n+\toidset_clear(&selected);\n+\toidset_clear(&has_children);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+static const char *autosquash_target(const char *subject)\n+{\n+\tconst char *rest;\n+\n+\twhile (skip_prefix(subject, \"fixup! \", &rest) ||\n+\t       skip_prefix(subject, \"squash! \", &rest) ||\n+\t       skip_prefix(subject, \"amend! \", &rest))\n+\t\tsubject = rest;\n+\treturn subject;\n+}\n+\n+static int reject_dangling_fixups(struct repository *repo,\n+\t\t\t\t  struct commit *base,\n+\t\t\t\t  struct commit *tip,\n+\t\t\t\t  struct commit **amend_source)\n+{\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *last_amend = NULL;\n+\tstruct strvec args = STRVEC_INIT;\n+\tchar *dangling_subject = NULL, *dangling_target = NULL;\n+\tbool mixed_target = false, all_fixups_one_target;\n+\tbool past_oldest_group = false;\n+\tint i, ret, nr_dangling = 0;\n+\n+\t*amend_source = NULL;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\twhile ((commit = get_revision(&revs)))\n+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n+\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not check the range for fixups\"));\n+\t\tgoto out;\n+\t}\n+\n+\tfor (i = 0; i < todo.nr; i++) {\n+\t\tconst char *message, *subject_start, *target;\n+\t\tchar *subject;\n+\t\tsize_t sublen;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, todo.items[i].commit,\n+\t\t\t\t\t       NULL, NULL);\n+\t\tsublen = find_commit_subject(message, &subject_start);\n+\n+\t\tif (todo.items[i].command != TODO_PICK) {\n+\t\t\tif (!past_oldest_group &&\n+\t\t\t    starts_with(subject_start, \"amend! \"))\n+\t\t\t\t*amend_source = todo.items[i].commit;\n+\t\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (i)\n+\t\t\tpast_oldest_group = true;\n+\n+\t\tsubject = xmemdupz(subject_start, sublen);\n+\t\ttarget = autosquash_target(subject);\n+\t\tif (target != subject) {\n+\t\t\tnr_dangling++;\n+\t\t\tif (!dangling_target) {\n+\t\t\t\tdangling_target = xstrdup(target);\n+\t\t\t\tdangling_subject = xstrdup(subject);\n+\t\t\t} else if (strcmp(dangling_target, target)) {\n+\t\t\t\tmixed_target = true;\n+\t\t\t}\n+\t\t\tif (starts_with(subject, \"amend! \"))\n+\t\t\t\tlast_amend = todo.items[i].commit;\n+\t\t}\n+\t\tfree(subject);\n+\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n+\t}\n+\n+\tall_fixups_one_target = nr_dangling == todo.nr && !mixed_target;\n+\tif (nr_dangling && !all_fixups_one_target) {\n+\t\tret = error(_(\"cannot squash '%s': its target is not in the \"\n+\t\t\t      \"range\"), dangling_subject);\n+\t} else {\n+\t\tif (last_amend)\n+\t\t\t*amend_source = last_amend;\n+\t\tret = 0;\n+\t}\n+\n+out:\n+\tfree(dangling_subject);\n+\tfree(dangling_target);\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+struct interior_ref_cb {\n+\tconst struct oidset *interior;\n+\tconst char *name;\n+};\n+\n+static int find_interior_ref(const struct reference *ref, void *cb_data)\n+{\n+\tstruct interior_ref_cb *data = cb_data;\n+\n+\tif (oidset_contains(data->interior, ref->oid)) {\n+\t\tdata->name = xstrdup(ref->name);\n+\t\treturn 1;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static bool amend_replaces_target(struct todo_list *todo, int target)\n+{\n+\tint i;\n+\n+\tfor (i = target + 1; i < todo->nr &&\n+\t\t\t     todo->items[i].command != TODO_PICK; i++) {\n+\t\tif (todo->items[i].command == TODO_SQUASH)\n+\t\t\treturn false;\n+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *base,\n+\t\t\t\tstruct commit *tip,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit;\n+\tstruct strvec args = STRVEC_INIT;\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tint i, nr_commits, ret;\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tstrvec_push(&args, \"ignored\");\n+\tstrvec_push(&args, \"--reverse\");\n+\tstrvec_push(&args, \"--topo-order\");\n+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n+\t\t     oid_to_hex(&tip->object.oid));\n+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\n+\twhile ((commit = get_revision(&revs)))\n+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n+\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not prepare the squash message\"));\n+\t\tgoto out;\n+\t}\n+\n+\tnr_commits = todo.nr;\n+\tfor (i = 0; i < nr_commits; i++) {\n+\t\tstruct todo_item *item = &todo.items[i];\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tbool skip, squashing;\n+\n+\t\tsquashing = item->command == TODO_SQUASH ||\n+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n+\t\tif (item->command == TODO_PICK)\n+\t\t\tskip = amend_replaces_target(&todo, i);\n+\t\telse\n+\t\t\tskip = !squashing;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tif (skip)\n+\t\t\tcommented_len = strlen(body);\n+\t\telse if (squashing)\n+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n+\t\telse\n+\t\t\tcommented_len = 0;\n+\n+\t\tif (!i)\n+\t\t\tadd_squash_combination_header(out, nr_commits);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, i + 1, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\tstrvec_clear(&args);\n+\treturn ret;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tint edit = 1;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BOOL('e', \"edit\", &edit,\n+\t\t\t N_(\"edit the commit message\")),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct strbuf message = STRBUF_INIT;\n+\tstruct oidset interior = OIDSET_INIT;\n+\tstruct commit *base = NULL, *oldest = NULL, *tip = NULL, *rewritten,\n+\t\t*amend_source = NULL;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tconst char *message_template = NULL;\n+\tstruct commit_list *parents = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage,\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT);\n+\tif (!argc) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n+\t\t\t\t   &interior);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = reject_dangling_fixups(repo, base, tip, &amend_source);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\tif (!edit && amend_source) {\n+\t\tconst char *amend_message, *body;\n+\n+\t\tamend_message = repo_logmsg_reencode(repo, amend_source,\n+\t\t\t\t\t\t     NULL, NULL);\n+\t\tfind_commit_subject(amend_message, &body);\n+\t\tbody = skip_blank_lines(body + commit_subject_length(body));\n+\t\tstrbuf_addstr(&message, body);\n+\t\tmessage_template = message.buf;\n+\t\trepo_unuse_commit_buffer(repo, amend_source, amend_message);\n+\t}\n+\n+\tif (action == REF_ACTION_BRANCHES) {\n+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n+\n+\t\trefs_for_each_branch_ref(get_main_ref_store(repo),\n+\t\t\t\t\t find_interior_ref, &cb);\n+\t\tif (cb.name) {\n+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n+\t\t\t\t    cb.name);\n+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t\t    \"untouched.\"));\n+\t\t\tfree((char *)cb.name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tif (edit) {\n+\t\tret = build_squash_message(repo, base, tip, &message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t\tmessage_template = message.buf;\n+\t\tflags |= COMMIT_TREE_EDIT_MESSAGE;\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\tcommit_list_append(base, &parents);\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n+\t\t\t      parents,\n+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n+\tstrbuf_join_argv(&reflog_msg, argc, argv, ' ');\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\tstrbuf_release(&message);\n+\toidset_clear(&interior);\n+\tcommit_list_free(parents);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int update_worktree(struct repository *repo,\n \t\t\t   const struct commit *old_head,\n \t\t\t   const struct commit *new_head,\n@@ -1192,6 +1694,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -1200,6 +1703,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/meson.build b/t/meson.build\nindex d8161c368b..797c5a75d1 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -405,6 +405,7 @@ integration_tests = [\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n   't3454-history-drop.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..0461046d85\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,758 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+stage_file () {\n+\tprintf \"%s\\n\" \"$1\" >file &&\n+\tgit add file\n+}\n+\n+commit_with_message () {\n+\tprintf \"%b\" \"$1\" >msg &&\n+\tgit commit --allow-empty -qF msg\n+}\n+\n+check_commit_count () {\n+\tgit rev-list --count \"$1\" >actual &&\n+\techo \"$2\" >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_subjects () {\n+\tgit log --format=\"%s\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_messages () {\n+\tgit log --format=\"%B\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a &&\n+\tgit tag start &&\n+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n+\t\ttest_commit --no-tag one other x &&\n+\ttest_commit --no-tag two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..HEAD ^keep &&\n+\n+\tgit reflog -1 --format=%gs >actual &&\n+\techo \"squash: updating start..HEAD ^keep\" >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'refuses a range with more than one tip' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag main-one file b &&\n+\ttest_commit --no-tag main-two file c &&\n+\tgit checkout -b other-tip start &&\n+\ttest_commit --no-tag other-tip other d &&\n+\tgit checkout \"$main\" &&\n+\tmain_before=$(git rev-parse HEAD) &&\n+\tother_before=$(git rev-parse other-tip) &&\n+\n+\ttest_must_fail git history squash ^start HEAD other-tip 2>err &&\n+\ttest_grep \"more than one tip\" err &&\n+\ttest_cmp_rev \"$main_before\" HEAD &&\n+\ttest_cmp_rev \"$other_before\" other-tip &&\n+\n+\tgit branch -D other-tip\n+'\n+\n+test_expect_success 'refuses a range that reaches a root commit' '\n+\tgit reset --hard three &&\n+\troot=$(printf \"unrelated root\\n\" |\n+\t\tgit commit-tree \"$(git rev-parse HEAD^{tree})\") &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash --ancestry-path=\"$root\" \\\n+\t\t^start HEAD \"$root\" 2>err &&\n+\ttest_grep \"reaches a root commit\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n+\tgit checkout \"$main\" &&\n+\n+\tgit history squash --no-edit start..off-history &&\n+\n+\tcheck_commit_count start..off-history 1 &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range preserving its tree and oldest authorship' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tone\n+\tEOF\n+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\techo \"Squasher <squash@example.com>\" >expect &&\n+\ttest_cmp expect actual &&\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'sanitizes rev-list walk options, before and after --' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --date-order start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit -- --reverse start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..@~1 &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$root..\" &&\n+\n+\tcheck_commit_count \"$root..HEAD\" 1 &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+\n+test_expect_success 'folds fixups whose target is in the range' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag target file b &&\n+\tgit commit --allow-empty -m \"fixup! target\" &&\n+\tgit commit --allow-empty -m \"fixup! target\" &&\n+\ttest_commit --no-tag later file c &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\ttarget\n+\tEOF\n+'\n+\n+test_expect_success 'refuses a below-range fixup! after an in-range commit' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag inside file b &&\n+\ttest_commit --no-tag \"fixup! outside\" file c &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'combines a run of fixups for one commit below the range' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! base\" &&\n+\tstage_file c && git commit -m \"fixup! base\" &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tfixup! base\n+\tEOF\n+'\n+\n+test_expect_success 'combining below-range markers offers every message' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! base\" &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! base\\n\\namended body\\n\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tfixup! base\n+\n+\tamend! base\n+\n+\tamended body\n+\n+\tEOF\n+'\n+\n+test_expect_success 'refuses fixups for two different commits below the range' '\n+\tgit reset --hard start &&\n+\tstage_file b && git commit -m \"fixup! aaa\" &&\n+\tstage_file c && git commit -m \"fixup! bbb\" &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"target is not in the range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'does not discard squash! or amend! message bodies' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag marker-oldest file b &&\n+\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n+\tcommit_with_message \"amend! marker-oldest\\n\\nearlier message\\n\" &&\n+\tcommit_with_message \\\n+\t\t\"amend! marker-oldest\\n\\namended subject\\n\\namended body\\n\" &&\n+\ttest_commit --no-tag marker-later file c &&\n+\tcommit_with_message \"amend! marker-later\\n\\nwrong message\\n\" &&\n+\n+\tgit history squash start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tmarker-oldest\n+\n+\tearlier message\n+\n+\tamended subject\n+\n+\tamended body\n+\n+\twrong message\n+\n+\tEOF\n+'\n+\n+test_expect_success '--no-edit keeps the selected message without an editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag no-edit-target file b &&\n+\tgit commit --allow-empty -m \"squash! no-edit-target\" &&\n+\tcommit_with_message \"amend! no-edit-target\\n\\namended subject\\n\\namended body\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_log_messages -1 <<-\\EOF &&\n+\tamended subject\n+\n+\tamended body\n+\n+\tEOF\n+\ttest_set_editor :\n+'\n+\n+test_expect_success 'edits every message and aborts on an empty result' '\n+\tgit reset --hard start &&\n+\tstage_file b &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script empty-editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/empty-editor\" &&\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"Aborting commit due to empty commit message\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tcombined\n+\tEOF\n+'\n+\n+test_expect_success 'handles fixup!, squash! and amend! messages like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\tstage_file e &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tmark-base\n+\n+\tsquash remark\n+\n+\tamended message\n+\n+\tEOF\n+'\n+\n+test_expect_success 'groups fixups under their targets in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag alpha file a1 &&\n+\ttest_commit --no-tag beta file b1 &&\n+\tstage_file a2 &&\n+\tcommit_with_message \"fixup! alpha\\n\" &&\n+\tstage_file b2 &&\n+\tcommit_with_message \"fixup! beta\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\talpha\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! alpha\n+\n+\t# This is the commit message #3:\n+\n+\tbeta\n+\n+\t# The commit message #4 will be skipped:\n+\n+\t# fixup! beta\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited\n+'\n+\n+test_expect_success 'lets amend! replace its target message in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# The 1st commit message will be skipped:\n+\n+\t# mark-base\n+\n+\t# This is the commit message #2:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended message\n+\n+\tsquash remark\n+\n+\tEOF\n+'\n+\n+test_expect_success 'handles branches pointing at the squashed range' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tgit branch -f mid HEAD~1 &&\n+\tother_before=$(git rev-parse other) &&\n+\tmid_before=$(git rev-parse mid) &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"error: .* points into the squashed range\" err &&\n+\ttest_grep \"hint: .*--update-refs=head\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$other_before\" other &&\n+\ttest_cmp_rev \"$mid_before\" mid &&\n+\n+\tgit branch -D other mid\n+'\n+\n+test_expect_success 'leaves tags and remote-tracking refs unchanged' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\tgit update-ref refs/remotes/origin/mark HEAD~1 &&\n+\tmark_before=$(git rev-parse mark) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest_cmp_rev \"$mark_before\" mark &&\n+\ttest_cmp_rev \"$mark_before\" refs/remotes/origin/mark &&\n+\n+\tgit tag -d mark &&\n+\tgit update-ref -d refs/remotes/origin/mark\n+'\n+\n+test_expect_success 'handles a branch pointing at an internal merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag after-merge file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"at-merge\" err &&\n+\ttest_grep \"points into the squashed range\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\tgit branch -D at-merge &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tbefore-side\n+\tEOF\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$base..\" &&\n+\n+\tcheck_commit_count \"$base..HEAD\" 1 &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"more than one base\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash --no-edit start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_done\n-- \ngitgitgadget\n"},{"id":"549478","messageId":"cover.1785750108.git.phillip.wood@dunelm.org.uk","threadId":"65812","inReplyTo":"6b5b2c93f2e3e55bf456b86a8be61f5f85137a2c.1784536024.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-03T09:49:20Z","receivedAt":"2026-08-03T09:50:40Z","isPatch":true,"body":"From: Phillip Wood <phillip.wood@dunelm.org.uk>\n\nOn 20/07/2026 09:27, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Folding a series of commits into one required either an interactive\n> rebase where each commit after the first was hand-edited to \"fixup\", or\n> a \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n> \n> Add \"git history squash <revision-range>\" to do this directly. \n\nAs promised, here are a few suggestions in the form of fixup\ncommits. I've reworked the message generation when it's not being\nedited, but have not had time to improve the template message when\nit is being editing.  As v11 has come out (despite a suggestion to\nhold off for a few days[1]) I don't think it worth my while spending\nmore time working off a stale base.\n\nPatch 1: clean up the option parsing.\nPatch 2: checks for BOTTOM rather than UNINTERESTING commits.\nPatch 3: reject root commits.\nPatch 4: reject ranges with more than one tip.\nPatch 5: refuse to squash if there are any branches descended from\n    commits in the squashed range other than the tip of the range.\nPatch 6: never discard the message from an \"amend!\" or \"squash!\" \n    commits.\nPatch 7: fix squashing a merge. \n\nSome of these issues are addressed in v11. The last four patches\nare the most interesting in terms of improving the behavior and\nimplementation. Besides the difference in the way fixups are handled,\nthe main change is in the way the code is structured to perform a\nsingle pass over the commits being squashed, rather than multiple\npasses for checking the connectivity and generating the commit message.\n\nThese patches can be fetched with\n\n  git fetch https://github.com/phillipwood/git wip/hn/history-squash/v10-early-part\n\nnote that they do not support editing the commit message of the\nsquashed commit.\n\nThanks\n\nPhillip\n\n[1] https://lore.kernel.org/git/414ebe62-c7f6-4d44-bde2-b689e35accfc@gmail.com\n\nPhillip Wood (7):\n  fixup! history: add squash subcommand to fold a range\n  fixup! history: add squash subcommand to fold a range\n  fixup! history: add squash subcommand to fold a range\n  fixup! history: add squash subcommand to fold a range\n  fixup! history: add squash subcommand to fold a range\n  fixup! history: add squash subcommand to fold a range\n  fixup! history: add squash subcommand to fold a range\n\n builtin/history.c         | 617 +++++++++++++++++++++++---------------\n object.h                  |   1 +\n t/t3455-history-squash.sh | 385 +++++++++++++++---------\n 3 files changed, 623 insertions(+), 380 deletions(-)\n\n-- \n2.54.0.200.gfd8d68259e3\n\n"},{"id":"549479","messageId":"15bba505bf84745e221945186ed0ab4da4be22a0.1785750108.git.phillip.wood@dunelm.org.uk","threadId":"65812","inReplyTo":"cover.1785750108.git.phillip.wood@dunelm.org.uk","subject":"[PATCH v10 3.1/3.7] fixup! history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-03T09:49:21Z","receivedAt":"2026-08-03T09:50:41Z","isPatch":true,"body":"From: Phillip Wood <phillip.wood@dunelm.org.uk>\n\nRework the parsing of rev-list options\n\nThe code for setting up the options for setup_revisions() is a strange\nmix of passing options and directly setting members of struct rev_info.\nConsistently set the required options in the struct so that we don't\nhave to duplicate argv. This matches what we do in \"git replay\".\n\nWhile I don't think it is possible for the user to override\n--ancestry-path currently, add checks incase that becomes possible\nin the future.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n---\n builtin/history.c | 35 +++++++++++++++++++----------------\n 1 file changed, 19 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 423c8beaaf..1d6d934bbe 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1015,7 +1015,7 @@ static int cmd_history_split(int argc,\n  * but the range must have a single base and must not reach a root commit.\n  */\n static int resolve_squash_range(struct repository *repo,\n-\t\t\t\tconst char **argv,\n+\t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **base_out,\n \t\t\t\tstruct commit **oldest_out,\n \t\t\t\tstruct commit **tip_out,\n@@ -1024,7 +1024,6 @@ static int resolve_squash_range(struct repository *repo,\n \tstruct rev_info revs;\n \tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n \tstruct commit_list *boundaries = NULL, *b;\n-\tstruct strvec args = STRVEC_INIT;\n \tsize_t i;\n \tint ret;\n \n@@ -1034,26 +1033,31 @@ static int resolve_squash_range(struct repository *repo,\n \trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n \trevs.simplify_history = 0;\n \trevs.boundary = 1;\n+\trevs.ancestry_path = 1;\n+\trevs.limited = 1;\n+\trevs.ancestry_path_implicit_bottoms = 1;\n \n-\tstrvec_push(&args, \"ignored\");\n-\tstrvec_push(&args, \"--ancestry-path\");\n-\tstrvec_pushv(&args, argv);\n-\tsetup_revisions_from_strvec(&args, &revs, NULL);\n-\tif (args.nr != 1) {\n-\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n+\targc = setup_revisions(argc, argv, &revs, NULL);\n+\tif (argc > 1) {\n+\t\tret = error(_(\"unrecognized argument: %s\"), argv[1]);\n \t\tgoto out;\n \t}\n \n \tif (revs.reverse != 1 || revs.topo_order != 1 ||\n \t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n-\t    revs.simplify_history != 0 || revs.boundary != 1) {\n+\t    revs.simplify_history != 0 || revs.boundary != 1 ||\n+\t    revs.ancestry_path != 1 || revs.limited != 1 ||\n+\t    revs.ancestry_path_implicit_bottoms != 1) {\n \t\twarning(_(\"ignoring rev-list options that would change how the \"\n \t\t\t  \"range is walked\"));\n \t\trevs.reverse = 1;\n \t\trevs.topo_order = 1;\n \t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n \t\trevs.simplify_history = 0;\n \t\trevs.boundary = 1;\n+\t\trevs.ancestry_path = 1;\n+\t\trevs.limited = 1;\n+\t\trevs.ancestry_path_implicit_bottoms = 1;\n \t}\n \n \t/*\n@@ -1124,7 +1128,6 @@ static int resolve_squash_range(struct repository *repo,\n \tcommit_list_free(boundaries);\n \treset_revision_walk();\n \trelease_revisions(&revs);\n-\tstrvec_clear(&args);\n \treturn ret;\n }\n \n@@ -1290,8 +1293,8 @@ static int cmd_history_squash(int argc,\n \tint ret;\n \n \targc = parse_options(argc, argv, prefix, options, usage,\n-\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT);\n-\tif (!argc) {\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n+\tif (argc < 2) {\n \t\tret = error(_(\"command expects a revision range\"));\n \t\tgoto out;\n \t}\n@@ -1300,7 +1303,10 @@ static int cmd_history_squash(int argc,\n \tif (action == REF_ACTION_DEFAULT)\n \t\taction = REF_ACTION_BRANCHES;\n \n-\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n+\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n+\tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n+\n+\tret = resolve_squash_range(repo, argc, argv, &base, &oldest, &tip,\n \t\t\t\t   &interior);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1353,9 +1359,6 @@ static int cmd_history_squash(int argc,\n \t\tret = error(_(\"failed writing squashed commit\"));\n \t\tgoto out;\n \t}\n-\n-\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n-\tstrbuf_join_argv(&reflog_msg, argc, argv, ' ');\n \n \tret = handle_reference_updates(&revs, action, tip, rewritten,\n \t\t\t\t       reflog_msg.buf, dry_run,\n-- \n2.54.0.200.gfd8d68259e3\n\n"},{"id":"549480","messageId":"a6db74f8767622e8d1327e760b504298c25878f8.1785750108.git.phillip.wood@dunelm.org.uk","threadId":"65812","inReplyTo":"cover.1785750108.git.phillip.wood@dunelm.org.uk","subject":"[PATCH v10 3.2/3.7] fixup! history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-03T09:49:22Z","receivedAt":"2026-08-03T09:50:42Z","isPatch":true,"body":"From: Phillip Wood <phillip.wood@dunelm.org.uk>\n\nLook for BOTTOM commit instead of an UNINTERESTING commit\n\nThere are a number of reasons why a commit may be marked as\nUNINTERESTING, we are specifically interested in finding a BOTTOM commit\nthat marks the bottom of the commit range that is being squashed.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n---\n builtin/history.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 1d6d934bbe..64c9914c2f 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1066,7 +1066,7 @@ static int resolve_squash_range(struct repository *repo,\n \t * bottom commit cannot be squashed.\n \t */\n \tfor (i = 0; i < revs.cmdline.nr; i++)\n-\t\tif (revs.cmdline.rev[i].flags & UNINTERESTING)\n+\t\tif (revs.cmdline.rev[i].flags & BOTTOM)\n \t\t\tbreak;\n \tif (i == revs.cmdline.nr) {\n \t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n-- \n2.54.0.200.gfd8d68259e3\n\n"},{"id":"549481","messageId":"9513165effed465acb633eb0152f98dd870b7bf2.1785750108.git.phillip.wood@dunelm.org.uk","threadId":"65812","inReplyTo":"cover.1785750108.git.phillip.wood@dunelm.org.uk","subject":"[PATCH v10 3.3/3.7] fixup! history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-03T09:49:23Z","receivedAt":"2026-08-03T09:50:44Z","isPatch":true,"body":"From: Phillip Wood <phillip.wood@dunelm.org.uk>\n\nreject root commits\n\nWe do not support squashing down to the root commit yet (we should\nprobably require explicit confirmation if we ever do support it to avoid\nnasty surprises) so error out if we see a commit with no parents. As\nexplained in the test, because we use --ancestry-path it is not\nstraight forward to trick it into walking back to the root commit.\n---\n builtin/history.c         |  4 ++++\n t/t3455-history-squash.sh | 12 ++++++++++++\n 2 files changed, 16 insertions(+)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 64c9914c2f..4dcfdb109d 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1087,6 +1087,10 @@ static int resolve_squash_range(struct repository *repo,\n \t\tif (commit->object.flags & BOUNDARY) {\n \t\t\tcommit_list_insert(commit, &boundaries);\n \t\t\tcontinue;\n+\t\t}\n+\t\tif (!commit->parents) {\n+\t\t\tret = error(_(\"cannot squash down to root commit\"));\n+\t\t\tgoto out;\n \t\t}\n \t\tif (!oldest)\n \t\t\toldest = commit;\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 9c362f3094..b181f93892 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -64,6 +64,18 @@ test_expect_success 'errors on a range holding a single commit' '\n \ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n \ttest_grep \"single commit; nothing to squash\" err &&\n \ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'rejects root commit' '\n+\t# create a disconnected root commit\n+\toid=$(git commit-tree -m root three^{tree}) &&\n+\t# because we pass --ancestry-path when calling setup_revs() it the\n+\t# revision walk will only include commits decended from $oid so\n+\t# we need to give it another --ancestry-path commit to actually walk\n+\t# any commits.\n+\ttest_must_fail git history squash --ancestry-path=start $oid..three 2>err &&\n+\techo \"error: cannot squash down to root commit\" >expect &&\n+\ttest_cmp expect err\n '\n \n test_expect_success 'accepts multiple revision arguments with an exclusion' '\n-- \n2.54.0.200.gfd8d68259e3\n\n"},{"id":"549482","messageId":"9f90fd2cc691176e5dcc78b43e7493c6b11d42bb.1785750108.git.phillip.wood@dunelm.org.uk","threadId":"65812","inReplyTo":"cover.1785750108.git.phillip.wood@dunelm.org.uk","subject":"[PATCH v10 3.4/3.7] fixup! history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-03T09:49:24Z","receivedAt":"2026-08-03T09:50:45Z","isPatch":true,"body":"From: Phillip Wood <phillip.wood@dunelm.org.uk>\n\nReject ranges with more than one tip\n\nGiven\n\n          C\n         /\n    A - B - D\n\nthen\n\n   git history squash ^A C D\n\nshould fail because the way we squash commits assumes the range has\na single tip. While we might want to support multiple tips in the\nfuture lets not complicate things now.\n\nTo check for multiple tips mark each commit we see as a tip and\nincrement a tip counter, then, iterate over the parents of each commit,\nremoving the mark and decrementing the counter if the mark was set. As\nwe have to iterate over the parents anyway, stop using \"--boundary\"\nand check that we've seen each parent before which allows us to\nproduce a better error message.\n\nNote: I'm not sure what our policy is with respect to adding new\nusers of object flags. If that's a problem, we could use a commit\nslab instead.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n---\n builtin/history.c         | 80 ++++++++++++++++++++++++---------------\n object.h                  |  1 +\n t/t3455-history-squash.sh | 19 ++++++++--\n 3 files changed, 65 insertions(+), 35 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 4dcfdb109d..bb4a74ec1a 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1006,6 +1006,10 @@ static int cmd_history_split(int argc,\n \trelease_revisions(&revs);\n \treturn ret;\n }\n+\n+/*Remember to update object flag allocation in object.h */\n+#define SQUASH_SEEN (1u << 11)\n+#define SQUASH_TIP (1u << 12)\n \n /*\n  * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n@@ -1023,16 +1027,14 @@ static int resolve_squash_range(struct repository *repo,\n {\n \tstruct rev_info revs;\n \tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n-\tstruct commit_list *boundaries = NULL, *b;\n \tsize_t i;\n-\tint ret;\n+\tint ret, tip_count = 0;\n \n \trepo_init_revisions(repo, &revs, NULL);\n \trevs.reverse = 1;\n \trevs.topo_order = 1;\n \trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n \trevs.simplify_history = 0;\n-\trevs.boundary = 1;\n \trevs.ancestry_path = 1;\n \trevs.limited = 1;\n \trevs.ancestry_path_implicit_bottoms = 1;\n@@ -1045,7 +1047,7 @@ static int resolve_squash_range(struct repository *repo,\n \n \tif (revs.reverse != 1 || revs.topo_order != 1 ||\n \t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n-\t    revs.simplify_history != 0 || revs.boundary != 1 ||\n+\t    revs.simplify_history != 0 || revs.boundary == 1 ||\n \t    revs.ancestry_path != 1 || revs.limited != 1 ||\n \t    revs.ancestry_path_implicit_bottoms != 1) {\n \t\twarning(_(\"ignoring rev-list options that would change how the \"\n@@ -1054,7 +1056,7 @@ static int resolve_squash_range(struct repository *repo,\n \t\trevs.topo_order = 1;\n \t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n \t\trevs.simplify_history = 0;\n-\t\trevs.boundary = 1;\n+\t\trevs.boundary = 0;\n \t\trevs.ancestry_path = 1;\n \t\trevs.limited = 1;\n \t\trevs.ancestry_path_implicit_bottoms = 1;\n@@ -1077,59 +1079,75 @@ static int resolve_squash_range(struct repository *repo,\n \t\tret = error(_(\"error preparing revisions\"));\n \t\tgoto out;\n \t}\n-\n-\t/*\n-\t * Set boundary commits aside for the base check below, and put every\n-\t * in-range commit but the tip into the interior set. A ref pointing\n-\t * at an interior commit would dangle once the range is folded away.\n-\t */\n \twhile ((commit = get_revision(&revs))) {\n-\t\tif (commit->object.flags & BOUNDARY) {\n-\t\t\tcommit_list_insert(commit, &boundaries);\n-\t\t\tcontinue;\n-\t\t}\n+\t\tstruct commit_list *p;\n+\n \t\tif (!commit->parents) {\n \t\t\tret = error(_(\"cannot squash down to root commit\"));\n \t\t\tgoto out;\n+\t\t}\n+\t\tfor (p = commit->parents; oldest && p; p = p->next) {\n+\t\t\tstruct commit_list *q;\n+\t\t\tstruct object *o;\n+\t\t\tbool seen;\n+\n+\t\t\tif (repo_parse_commit(repo, p->item)) {\n+\t\t\t\tret = error(_(\"cannot parse commit\"));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\to = &p->item->object;\n+\t\t\tseen = o->flags & SQUASH_SEEN;\n+\t\t\t/*\n+\t\t\t * Allow parents that match the parents of the\n+\t\t\t * squashed commit.\n+\t\t\t */\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next)\n+\t\t\t\tseen = p->item == q->item;\n+\t\t\tif (!seen) {\n+\t\t\t\tret = error(_(\"parent %s of commit %s is \"\n+\t\t\t\t\t      \"outside the revision range\"),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo, &o->oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tif (o->flags & SQUASH_TIP) {\n+\t\t\t\ttip_count--;\n+\t\t\t\to->flags &= ~SQUASH_TIP;\n+\t\t\t}\n \t\t}\n \t\tif (!oldest)\n \t\t\toldest = commit;\n \t\tif (tip)\n \t\t\toidset_insert(interior_out, &tip->object.oid);\n \t\ttip = commit;\n+\t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n+\t\ttip_count++;\n \t}\n-\n-\tif (!oldest) {\n+\tif (!tip_count) {\n \t\tret = error(_(\"the revision range is empty\"));\n \t\tgoto out;\n-\t} else if (oldest == tip) {\n+\t} else if (tip_count != 1) {\n+\t\t  ret = error(_(\"the revision range contains more than one tip \"\n+\t\t\t\t\"commit\"));\n+\t\t  goto out;\n+\t  } else if (oldest == tip) {\n \t\tret = error(_(\"the revision range holds a single commit; \"\n \t\t\t      \"nothing to squash\"));\n \t\tgoto out;\n \t} else if (!oldest->parents) {\n \t\tBUG(\"an in-range commit must have a parent\");\n \t}\n \tbase = oldest->parents->item;\n-\n-\t/*\n-\t * A boundary other than the base is an in-range commit reaching a\n-\t * commit outside the range, so the range has more than one base.\n-\t */\n-\tfor (b = boundaries; b; b = b->next) {\n-\t\tif (b->item != base) {\n-\t\t\tret = error(_(\"the revision range has more than one base; \"\n-\t\t\t\t      \"cannot squash\"));\n-\t\t\tgoto out;\n-\t\t}\n-\t}\n \n \t*base_out = base;\n \t*oldest_out = oldest;\n \t*tip_out = tip;\n \tret = 0;\n \n out:\n-\tcommit_list_free(boundaries);\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n \treset_revision_walk();\n \trelease_revisions(&revs);\n \treturn ret;\ndiff --git a/object.h b/object.h\nindex 8fb03ff90a..ad18ffcc55 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,6 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n+ * builtin/history.c:                    11-12\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex b181f93892..ba826df592 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -33,8 +33,7 @@ check_log_messages () {\n }\n \n test_expect_success 'setup linear history touching two files' '\n-\ttest_commit base file a &&\n-\tgit tag start &&\n+\ttest_commit base file a start &&\n \ttest_commit --no-tag one other x &&\n \ttest_commit --no-tag two file c &&\n \ttest_commit three file d\n@@ -76,6 +75,18 @@ test_expect_success 'rejects root commit' '\n \ttest_must_fail git history squash --ancestry-path=start $oid..three 2>err &&\n \techo \"error: cannot squash down to root commit\" >expect &&\n \ttest_cmp expect err\n+'\n+\n+test_expect_success 'rejects multiple tips' '\n+\toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n+\ttest_must_fail git history squash ^start $oid three~1 2>err &&\n+\techo \"error: the revision range contains more than one tip commit\" >expect &&\n+\ttest_cmp expect err &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash ^start three~1 three &&\n+\ttest_cmp_rev HEAD~1 start^0 &&\n+\ttest_cmp_rev HEAD^{tree} three^{tree}\n '\n \n test_expect_success 'accepts multiple revision arguments with an exclusion' '\n@@ -404,7 +415,7 @@ test_expect_success 'refuses a merge whose other parent is outside the range' '\n \tmerged=$(git rev-parse HEAD) &&\n \n \ttest_must_fail git history squash \"$base..\" 2>err &&\n-\ttest_grep \"more than one base\" err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n \ttest_cmp_rev \"$merged\" HEAD\n '\n \n@@ -533,7 +544,7 @@ test_expect_success 'refuses an octopus merge with an arm forked before the base\n \tgit branch -D octo-pre octo-within &&\n \n \ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n-\ttest_grep \"more than one base\" err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n \ttest_cmp_rev \"$merged\" HEAD\n '\n \n-- \n2.54.0.200.gfd8d68259e3\n\n"},{"id":"549483","messageId":"4b994a075b6332b45113bfbcfbeb62168e20c255.1785750108.git.phillip.wood@dunelm.org.uk","threadId":"65812","inReplyTo":"cover.1785750108.git.phillip.wood@dunelm.org.uk","subject":"[PATCH v10 3.5/3.7] fixup! history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-03T09:49:25Z","receivedAt":"2026-08-03T09:50:47Z","isPatch":true,"body":"From: Phillip Wood <phillip.wood@dunelm.org.uk>\n\nRefuse to squash if there are branches descended from squashed commits\n\nRework the detection of branches descended from squashed commits\nto protect branches that are not updated (i.e. any branch that is\ndescended from the squashed range but not the tip of the range). The\ncurrent behavior of protecting only those branches that point directly\nat a squashed commit but not those descended from the same commit\nis inconsistent and confusing. Note that this change means we now\ncomplain when a branch points to the start of the squashed range.\n\nWhen walking the commits we only need to add those with a parent\noutside the squashed range to the ref filter as all the other commits\nare descended from those. This patch needs some polishing to print the\nbranches that are causing us to error out. I've updated the existing\ntests to reflect that, though I'm unclear what extra coverage the last\ntest (squashes a range whose internal merge has a single base) adds.\n\nWe do not protect detached HEADs that point to a commit in the\nsquashed range. I think that's reasonable - if HEAD is detached,\nthe user is likely experimenting so does not necessarily care that\nthe commits are being squashed in a different worktree. Protecting\nthem would require a separate revision walk including the detached\nHEADs from all worktrees and excluding the base of the squashed range.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n---\n builtin/history.c         | 85 +++++++++++++++++----------------------\n t/t3455-history-squash.sh | 67 ++++++++++++++----------------\n 2 files changed, 68 insertions(+), 84 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex bb4a74ec1a..84e13fd75a 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -17,6 +17,7 @@\n #include \"path.h\"\n #include \"read-cache.h\"\n #include \"refs.h\"\n+#include \"ref-filter.h\"\n #include \"replay.h\"\n #include \"reset.h\"\n #include \"revision.h\"\n@@ -1019,16 +1020,18 @@ static int cmd_history_split(int argc,\n  * but the range must have a single base and must not reach a root commit.\n  */\n static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tbool update_branches,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **base_out,\n \t\t\t\tstruct commit **oldest_out,\n-\t\t\t\tstruct commit **tip_out,\n-\t\t\t\tstruct oidset *interior_out)\n+\t\t\t\tstruct commit **tip_out)\n {\n \tstruct rev_info revs;\n \tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n \tsize_t i;\n \tint ret, tip_count = 0;\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array refs = { 0 };\n \n \trepo_init_revisions(repo, &revs, NULL);\n \trevs.reverse = 1;\n@@ -1101,8 +1104,12 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t * Allow parents that match the parents of the\n \t\t\t * squashed commit.\n \t\t\t */\n-\t\t\tfor (q = oldest->parents; !seen && q; q = q->next)\n-\t\t\t\tseen = p->item == q->item;\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next) {\n+\t\t\t\tif (p->item == q->item) {\n+\t\t\t\t\tseen = true;\n+\t\t\t\t\tcommit_list_insert(commit, &filter.with_commit);\n+\t\t\t\t}\n+\t\t\t}\n \t\t\tif (!seen) {\n \t\t\t\tret = error(_(\"parent %s of commit %s is \"\n \t\t\t\t\t      \"outside the revision range\"),\n@@ -1117,14 +1124,16 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\to->flags &= ~SQUASH_TIP;\n \t\t\t}\n \t\t}\n-\t\tif (!oldest)\n+\t\tif (!oldest) {\n+\t\t\tcommit_list_insert(commit, &filter.with_commit);\n \t\t\toldest = commit;\n-\t\tif (tip)\n-\t\t\toidset_insert(interior_out, &tip->object.oid);\n+\t\t}\n \t\ttip = commit;\n \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n \t\ttip_count++;\n \t}\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\treset_revision_walk();\n \tif (!tip_count) {\n \t\tret = error(_(\"the revision range is empty\"));\n \t\tgoto out;\n@@ -1138,6 +1147,24 @@ static int resolve_squash_range(struct repository *repo,\n \t\tgoto out;\n \t} else if (!oldest->parents) {\n \t\tBUG(\"an in-range commit must have a parent\");\n+\t}\n+\tcommit_list_insert(tip, &filter.no_commit);\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tif (update_branches &&\n+\t    filter_refs(&refs, &filter, filter.kind)) {\n+\t\tret = error(_(\"could not filter refs\"));\n+\t\tgoto out;\n+\t}\n+\tif (refs.nr) {\n+\t\t/*\n+\t\t * TODO: list the branches and also check HEADS from other worktrees\n+\t\t */\n+\t\tret = error(_(\"a branch points to a commit that is being squashed\"));\n+\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t    \"untouched.\"));\n+\t\tgoto out;\n \t}\n \tbase = oldest->parents->item;\n \n@@ -1147,9 +1174,9 @@ static int resolve_squash_range(struct repository *repo,\n \tret = 0;\n \n out:\n-\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n-\treset_revision_walk();\n \trelease_revisions(&revs);\n+\tref_filter_clear(&filter);\n+\tref_array_clear(&refs);\n \treturn ret;\n }\n \n@@ -1261,23 +1288,6 @@ static int reject_dangling_fixups(struct repository *repo,\n \trelease_revisions(&revs);\n \tstrvec_clear(&args);\n \treturn ret;\n-}\n-\n-struct interior_ref_cb {\n-\tconst struct oidset *interior;\n-\tconst char *name;\n-};\n-\n-static int find_interior_ref(const struct reference *ref, void *cb_data)\n-{\n-\tstruct interior_ref_cb *data = cb_data;\n-\n-\tif (oidset_contains(data->interior, ref->oid)) {\n-\t\tdata->name = xstrdup(ref->name);\n-\t\treturn 1;\n-\t}\n-\n-\treturn 0;\n }\n \n static int cmd_history_squash(int argc,\n@@ -1305,7 +1315,6 @@ static int cmd_history_squash(int argc,\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n \tstruct strbuf message = STRBUF_INIT;\n-\tstruct oidset interior = OIDSET_INIT;\n \tstruct commit *base, *oldest, *tip, *rewritten, *msg_source,\n \t\t*amend_source;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n@@ -1328,8 +1337,8 @@ static int cmd_history_squash(int argc,\n \tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n \tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n \n-\tret = resolve_squash_range(repo, argc, argv, &base, &oldest, &tip,\n-\t\t\t\t   &interior);\n+\tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n+\t\t\t\t   argc, argv, &base, &oldest, &tip);\n \tif (ret < 0)\n \t\tgoto out;\n \n@@ -1347,23 +1356,6 @@ static int cmd_history_squash(int argc,\n \t\tstrbuf_addstr(&message, body);\n \t\tmessage_template = message.buf;\n \t\trepo_unuse_commit_buffer(repo, amend_source, amend_message);\n-\t}\n-\n-\tif (action == REF_ACTION_BRANCHES) {\n-\t\tstruct interior_ref_cb cb = { .interior = &interior };\n-\n-\t\trefs_for_each_ref(get_main_ref_store(repo),\n-\t\t\t\t  find_interior_ref, &cb);\n-\t\tif (cb.name) {\n-\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n-\t\t\t\t    cb.name);\n-\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n-\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n-\t\t\t\t\t    \"the current branch and leave such refs \"\n-\t\t\t\t\t    \"untouched.\"));\n-\t\t\tfree((char *)cb.name);\n-\t\t\tgoto out;\n-\t\t}\n \t}\n \n \tret = setup_revwalk(repo, action, tip, &revs);\n@@ -1395,7 +1387,6 @@ static int cmd_history_squash(int argc,\n out:\n \tstrbuf_release(&reflog_msg);\n \tstrbuf_release(&message);\n-\toidset_clear(&interior);\n \tcommit_list_free(parents);\n \trelease_revisions(&revs);\n \treturn ret;\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex ba826df592..d7697489a0 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -305,17 +305,36 @@ test_expect_success '--update-refs=head only moves HEAD' '\n \ttest_cmp_rev \"$other_before\" other\n '\n \n-test_expect_success 'refuses to fold a range a ref points into' '\n-\tgit reset --hard three &&\n-\tgit branch -f mid HEAD~1 &&\n-\thead_before=$(git rev-parse HEAD) &&\n+test_expect_success 'refuses to fold a range a branch points into' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f -b feature start &&\n+\ttest_commit C1 &&\n+\ttest_commit C2 &&\n+\tgit checkout -b topic-1 start &&\n+\ttest_commit C3 &&\n+\ttest_commit C4 &&\n+\tgit checkout C3 &&\n+\ttest_commit C5 &&\n+\tgit checkout feature &&\n+\tgit merge C5 &&\n+\ttest_commit C6 &&\n+\tgit checkout -b topic-2 C2 &&\n+\ttest_commit C7 &&\n+\tgit checkout feature &&\n \n \ttest_must_fail git history squash start.. 2>err &&\n-\ttest_grep \"error: .* points into the squashed range\" err &&\n-\ttest_grep \"hint: .*--update-refs=head\" err &&\n-\ttest_cmp_rev \"$head_before\" HEAD &&\n+\t# TODO: check the branch names when we print them (topic-1 & topic-2)\n+\ttest_grep \"^error: a branch points to\" err &&\n+\ttest_grep \"^hint: .* --update-refs=head\" err &&\n+\ttest_cmp_rev C6 HEAD &&\n \n-\tgit branch -D mid\n+\t# squash succeeds with --update-refs=head\n+\tgit history squash --update-refs=head start.. &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest_cmp_rev C6^{tree} HEAD^{tree} &&\n+\ttest_cmp_rev C6 HEAD@{1}\n '\n \n test_expect_success 'advice.historyUpdateRefs silences the hint' '\n@@ -325,37 +344,11 @@ test_expect_success 'advice.historyUpdateRefs silences the hint' '\n \n \ttest_must_fail git -c advice.historyUpdateRefs=false \\\n \t\thistory squash start.. 2>err &&\n-\ttest_grep \"points into the squashed range\" err &&\n+\ttest_grep \"^error: a branch points to\" err &&\n \ttest_grep ! \"hint:\" err &&\n \ttest_cmp_rev \"$head_before\" HEAD &&\n \n \tgit branch -D mid\n-'\n-\n-test_expect_success '--update-refs=head folds past a ref pointing into the range' '\n-\tgit reset --hard three &&\n-\tgit branch -f mid HEAD~1 &&\n-\tmid_before=$(git rev-parse mid) &&\n-\n-\tgit history squash --update-refs=head start.. &&\n-\n-\tcheck_commit_count start..HEAD 1 &&\n-\ttest_cmp_rev \"$mid_before\" mid &&\n-\n-\tgit branch -D mid\n-'\n-\n-test_expect_success 'refuses to fold a range a tag points into' '\n-\tgit reset --hard three &&\n-\tgit tag -f mark HEAD~1 &&\n-\thead_before=$(git rev-parse HEAD) &&\n-\n-\ttest_must_fail git history squash start.. 2>err &&\n-\ttest_grep \"refs/tags/mark\" err &&\n-\ttest_grep \"points into the squashed range\" err &&\n-\ttest_cmp_rev \"$head_before\" HEAD &&\n-\n-\tgit tag -d mark\n '\n \n test_expect_success 'squashes a range whose internal merge has a single base' '\n@@ -582,8 +575,8 @@ test_expect_success 'refuses to fold a range a ref points into at a merge' '\n \thead_before=$(git rev-parse HEAD) &&\n \n \ttest_must_fail git history squash start.. 2>err &&\n-\ttest_grep \"at-merge\" err &&\n-\ttest_grep \"points into the squashed range\" err &&\n+\t# TODO: test for branch nome \"at-merge\"\n+\ttest_grep \"a branch points to a commit\" err &&\n \ttest_cmp_rev \"$head_before\" HEAD &&\n \n \tgit branch -D at-merge\n-- \n2.54.0.200.gfd8d68259e3\n\n"},{"id":"549484","messageId":"84abbf386548600fafd33352480466362e73747a.1785750108.git.phillip.wood@dunelm.org.uk","threadId":"65812","inReplyTo":"cover.1785750108.git.phillip.wood@dunelm.org.uk","subject":"[PATCH v10 3.6/3.7] fixup! history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-03T09:49:26Z","receivedAt":"2026-08-03T09:50:48Z","isPatch":true,"body":"From: Phillip Wood <phillip.wood@dunelm.org.uk>\n\nDo not discard \"squash!\" or \"amend!\" messages\n\n\"squash!\" and \"amend!\" commit messages are a strong indication that\nthe user intended to reword the target commit when squashing. To\nthat end only squash them if the message ends up being used. If an\n\"amend!\" message targets a commit that's being squashed, but is not\nthe first commit that's being squashed, then refuse to squash it\nunless the commit message is being edited. Also refuse to squash a\n\"squash!\" message unless it is being squashed into a \"fixup!\" or\n\"squash!\" message with the same target, or the message is being\nedited. If an \"amend!\" or \"squash!\" message is being squashed into\na \"fixup!\" message, then we retain the original target text. This\nmeans that we can safely squash 'def456 \"amend! abc123\"' into 'abc123\n\"fixup! add new feature\"', and later squash the result into '098765\n\"add new feature\"'.\n\nThe logic is implemented directly, rather than creating a rebase\ntodo list which is more efficient and hopefully clearer (I had a hard\ntime understanding what the existing code does). It also allows us to\ntailor the rules to suit the new command. In particular we are much\nmore careful when handling \"fixup! <object-name>\" subjects, rejecting\nsubjects like \"fixup! HEAD^\" that do not have a fixed target. We also\nsupport squashing \"fixup! abc123\" into \"fixup! <subject of abc123>\"\nto make it easier to squash a group of fixups together.\n\nThe authorship of the squashed commit is always taken from the first\ncommit being squashed which matches what rebase does (if there is\nan \"amend!\" commit the existing code takes the authorship from that\ninstead). While an \"amend!\" commit might contain substantial changes\nthat justify changing the authorship it could equally be correcting\na simple typo. There is no reason to assume that changes in \"amend!\"\ncommits are more significant that those in \"fixup!\" or \"squash!\"\ncommits.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n---\n builtin/history.c         | 409 +++++++++++++++++++++++++-------------\n object.h                  |   2 +-\n t/t3455-history-squash.sh | 261 ++++++++++++++----------\n 3 files changed, 431 insertions(+), 241 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 84e13fd75a..894da69f31 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1011,6 +1011,260 @@ static int cmd_history_split(int argc,\n /*Remember to update object flag allocation in object.h */\n #define SQUASH_SEEN (1u << 11)\n #define SQUASH_TIP (1u << 12)\n+#define SQUASH_AMEND_TARGET (1u << 13)\n+\n+static bool is_autosquash_subject(const char *s)\n+{\n+\treturn starts_with(s, \"amend!\") || starts_with(s, \"fixup!\") ||\n+\t\tstarts_with(s, \"squash!\");\n+}\n+\n+static bool skip_one_autosquash_prefix(const char *s, const char **out)\n+{\n+\tif (skip_prefix(s, \"amend!\", out) || skip_prefix(s, \"fixup!\", out) ||\n+\t    skip_prefix(s, \"squash!\", out)) {\n+\t\twhile (**out == ' ')\n+\t\t\t(*out)++;\n+\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static void truncate_message_to_subject(struct strbuf *msg)\n+{\n+\tconst char *eos = strstr(msg->buf, \"\\n\\n\");\n+\n+\tif (eos)\n+\t\tstrbuf_setlen(msg, eos - msg->buf + 1);\n+}\n+\n+struct subject_data {\n+\tstruct strintmap subjects;\n+\tstruct strbuf subject;\n+\tstruct strbuf squash_message;\n+\tconst char *message;\n+\tbool edit_message;\n+};\n+\n+#define SUBJECT_DATA_INIT {\t\t\\\n+\t.subjects = STRINTMAP_INIT,\t\\\n+\t.subject = STRBUF_INIT,\t\t\\\n+\t.squash_message = STRBUF_INIT,\t\\\n+}\n+\n+static void subject_data_clear(struct subject_data *data)\n+{\n+\tstrintmap_clear(&data->subjects);\n+\tstrbuf_release(&data->subject);\n+\tstrbuf_release(&data->squash_message);\n+}\n+\n+static int squash_amend_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (!*body) {\n+\t\twarning(_(\"ignoring %s (%s): message body is empty\"),\n+\t\t\trepo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\tDEFAULT_ABBREV),\n+\t\t\tdata->subject.buf);\n+\t\treturn 0;\n+\t}\n+\n+\tif (data->edit_message) {\n+\t\tBUG(\"Editing is not implemented yet\");\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\"))\n+\t\t\treturn error(_(\"squashing %s (%s) would overwrite \"\n+\t\t\t\t       \"'squash!' message, please combine them \"\n+\t\t\t\t       \"using '--reedit-message'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\"))\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"amend!\", 5);\n+\t\tif (starts_with(data->squash_message.buf, \"amend!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t} else {\n+\t\t\tstrbuf_reset(&data->squash_message);\n+\t\t}\n+\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--reedit-message'\"),\n+\t\t\t     repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t     data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (data->edit_message) {\n+\t\tBUG(\"Editing is not implemented yet\");\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"squash\", 6);\n+\t\t}\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\")) {\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t} else {\n+\t\t\treturn error(_(\"squashing %s (%s) would discard its \"\n+\t\t\t\t       \"message, please combine them using \"\n+\t\t\t\t       \"'--reedit-message'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\t}\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--reedit-message'\"),\n+\t\t\t      repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t      DEFAULT_ABBREV),\n+\t\t\t      data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_check_can_autosquash(struct repository *repo,\n+\t\t\t\t       struct commit *commit,\n+\t\t\t\t       struct subject_data *data,\n+\t\t\t\t       unsigned flags)\n+{\n+\tcommit->object.flags |= flags & SQUASH_AMEND_TARGET;\n+\tif (starts_with(data->subject.buf, \"amend!\"))\n+\t\treturn squash_amend_message(repo, commit, data, flags);\n+\telse if (starts_with(data->subject.buf, \"squash!\"))\n+\t\treturn squash_squash_message(repo, commit, data, flags);\n+\n+\treturn 0;\n+}\n+\n+static int squash_check_autosquash_subject(struct repository *repo,\n+\t\t\t\t\t   struct commit *commit,\n+\t\t\t\t\t   struct subject_data *data)\n+{\n+\tconst char* s = data->subject.buf;\n+\tstruct commit *target;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\t/* Try skipping autosquash prefixes one at a time to allow\n+\t * squashing\n+\t *     a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * where we may have started with\n+\t *     a commit\n+\t *     fixup! a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * and squashed the first fixup separately from the second\n+\t */\n+\twhile (skip_one_autosquash_prefix(s, &s)) {\n+\t\tunsigned flags = strintmap_get(&data->subjects, s);\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit, data, flags);\n+\t}\n+\t/*\n+\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n+\t * \"fixup! main\". If the target is not being squshed check the subject\n+\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n+\t * squashed together.\n+\t */\n+\ttarget = lookup_commit_reference_by_name(s);\n+\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n+\t\tunsigned flags =\n+\t\t\ttarget->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);\n+\t\tif (!flags) {\n+\t\t\tconst char *subject_start;\n+\t\t\tconst char *buffer = repo_logmsg_reencode(repo, target,\n+\t\t\t\t\t\t\t\t  NULL, NULL);\n+\t\t\tsize_t subject_len = find_commit_subject(buffer,\n+\t\t\t\t\t\t\t\t &subject_start);\n+\t\t\tchar *subject = xmemdupz(subject_start, subject_len);\n+\n+\t\t\tflags = strintmap_get(&data->subjects, subject);\n+\t\t\tfree(subject);\n+\t\t\trepo_unuse_commit_buffer(repo, target, buffer);\n+\t\t}\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t   data, flags);\n+\t}\n+\t/* Try subject prefix matches */\n+\tstrintmap_for_each_entry(&data->subjects, &iter, entry) {\n+\t\ts = data->subject.buf;\n+\t\twhile(skip_one_autosquash_prefix(s, &s)) {\n+\t\t\tif (starts_with(entry->key, s)) {\n+\t\t\t\tunsigned value = (intptr_t)entry->value;\n+\n+\t\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t\t   data, value);\n+\t\t\t}\n+\t\t}\n+\t}\n+\treturn error(_(\"cannot squash %s (%s): its target is not being \"\n+\t\t       \"squashed\"),\n+\t\t       repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t       DEFAULT_ABBREV),\n+\t\t       data->subject.buf);\n+}\n+\n+static int squash_check_subject(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data)\n+{\n+\tint ret = 0;\n+\tconst char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\tsize_t subject_len = find_commit_subject(buf, &data->message);\n+\n+\tstrbuf_reset(&data->subject);\n+\tstrbuf_add(&data->subject, data->message, subject_len);\n+\n+\tif (!strintmap_get_size(&data->subjects)) {\n+\t\tconst char *s;\n+\n+\t\tstrbuf_addstr(&data->squash_message, data->message);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t/*\n+\t\t * Strip a single autosquash prefix to allow squashing\n+\t\t *     fixup! base\n+\t\t *     amend! base\n+\t\t */\n+\t\ts = data->subject.buf;\n+\t\tskip_one_autosquash_prefix(s, &s);\n+\t\tstrintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);\n+\t\tcommit->object.flags |= SQUASH_AMEND_TARGET;\n+\t} else if (is_autosquash_subject(data->subject.buf)) {\n+\t\tret = squash_check_autosquash_subject(repo, commit, data);\n+\t} else {\n+\t\tstrintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);\n+\t}\n+\trepo_unuse_commit_buffer(repo, commit, buf);\n+\treturn ret;\n+}\n \n /*\n  * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n@@ -1024,9 +1278,11 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **base_out,\n \t\t\t\tstruct commit **oldest_out,\n-\t\t\t\tstruct commit **tip_out)\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tchar **message_out)\n {\n \tstruct rev_info revs;\n+\tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n \tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n \tsize_t i;\n \tint ret, tip_count = 0;\n@@ -1127,12 +1383,16 @@ static int resolve_squash_range(struct repository *repo,\n \t\tif (!oldest) {\n \t\t\tcommit_list_insert(commit, &filter.with_commit);\n \t\t\toldest = commit;\n+\t\t}\n+\t\tif (squash_check_subject(repo, commit, &subject_data)) {\n+\t\t\tret = -1;\n+\t\t\tgoto out;\n \t\t}\n \t\ttip = commit;\n \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n \t\ttip_count++;\n \t}\n-\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP | SQUASH_AMEND_TARGET);\n \treset_revision_walk();\n \tif (!tip_count) {\n \t\tret = error(_(\"the revision range is empty\"));\n@@ -1171,122 +1431,14 @@ static int resolve_squash_range(struct repository *repo,\n \t*base_out = base;\n \t*oldest_out = oldest;\n \t*tip_out = tip;\n+\t*message_out = strbuf_detach(&subject_data.squash_message, NULL);\n \tret = 0;\n \n out:\n+\tsubject_data_clear(&subject_data);\n \trelease_revisions(&revs);\n \tref_filter_clear(&filter);\n \tref_array_clear(&refs);\n-\treturn ret;\n-}\n-\n-static const char *autosquash_target(const char *subject)\n-{\n-\tconst char *rest;\n-\n-\twhile (skip_prefix(subject, \"fixup! \", &rest) ||\n-\t       skip_prefix(subject, \"squash! \", &rest) ||\n-\t       skip_prefix(subject, \"amend! \", &rest))\n-\t\tsubject = rest;\n-\treturn subject;\n-}\n-\n-static int reject_dangling_fixups(struct repository *repo,\n-\t\t\t\t  struct commit *base,\n-\t\t\t\t  struct commit *tip,\n-\t\t\t\t  struct commit *oldest,\n-\t\t\t\t  struct commit **msg_source,\n-\t\t\t\t  struct commit **amend_source)\n-{\n-\tstruct todo_list todo = TODO_LIST_INIT;\n-\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n-\tstruct rev_info revs;\n-\tstruct commit *commit, *last_amend = NULL;\n-\tstruct strvec args = STRVEC_INIT;\n-\tchar *dangling_subject = NULL, *dangling_target = NULL;\n-\tbool mixed_target = false, all_fixups_one_target;\n-\tbool past_oldest_group = false;\n-\tint i, ret, nr_dangling = 0;\n-\n-\t*msg_source = oldest;\n-\t*amend_source = NULL;\n-\n-\trepo_init_revisions(repo, &revs, NULL);\n-\tstrvec_push(&args, \"ignored\");\n-\tstrvec_push(&args, \"--reverse\");\n-\tstrvec_push(&args, \"--topo-order\");\n-\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n-\t\t     oid_to_hex(&tip->object.oid));\n-\tsetup_revisions_from_strvec(&args, &revs, NULL);\n-\n-\tif (prepare_revision_walk(&revs) < 0) {\n-\t\tret = error(_(\"error preparing revisions\"));\n-\t\tgoto out;\n-\t}\n-\twhile ((commit = get_revision(&revs)))\n-\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n-\t\t\t    oid_to_hex(&commit->object.oid));\n-\n-\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n-\t    todo_list_rearrange_squash(&todo) < 0) {\n-\t\tret = error(_(\"could not check the range for fixups\"));\n-\t\tgoto out;\n-\t}\n-\n-\tfor (i = 0; i < todo.nr; i++) {\n-\t\tconst char *message, *subject_start, *target;\n-\t\tchar *subject;\n-\t\tsize_t sublen;\n-\n-\t\tmessage = repo_logmsg_reencode(repo, todo.items[i].commit,\n-\t\t\t\t\t       NULL, NULL);\n-\t\tsublen = find_commit_subject(message, &subject_start);\n-\n-\t\tif (todo.items[i].command != TODO_PICK) {\n-\t\t\tif (!past_oldest_group &&\n-\t\t\t    starts_with(subject_start, \"amend! \"))\n-\t\t\t\t*amend_source = todo.items[i].commit;\n-\t\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n-\t\t\tcontinue;\n-\t\t}\n-\t\tif (i)\n-\t\t\tpast_oldest_group = true;\n-\n-\t\tsubject = xmemdupz(subject_start, sublen);\n-\t\ttarget = autosquash_target(subject);\n-\t\tif (target != subject) {\n-\t\t\tnr_dangling++;\n-\t\t\tif (!dangling_target) {\n-\t\t\t\tdangling_target = xstrdup(target);\n-\t\t\t\tdangling_subject = xstrdup(subject);\n-\t\t\t} else if (strcmp(dangling_target, target)) {\n-\t\t\t\tmixed_target = true;\n-\t\t\t}\n-\t\t\tif (starts_with(subject, \"amend! \"))\n-\t\t\t\tlast_amend = todo.items[i].commit;\n-\t\t}\n-\t\tfree(subject);\n-\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n-\t}\n-\n-\tall_fixups_one_target = nr_dangling == todo.nr && !mixed_target;\n-\tif (nr_dangling && !all_fixups_one_target) {\n-\t\tret = error(_(\"cannot squash '%s': its target is not in the \"\n-\t\t\t      \"range\"), dangling_subject);\n-\t} else {\n-\t\tif (last_amend)\n-\t\t\t*msg_source = last_amend;\n-\t\tret = 0;\n-\t}\n-\n-out:\n-\tfree(dangling_subject);\n-\tfree(dangling_target);\n-\ttodo_list_release(&todo);\n-\treplay_opts_release(&opts);\n-\treset_revision_walk();\n-\trelease_revisions(&revs);\n-\tstrvec_clear(&args);\n \treturn ret;\n }\n \n@@ -1314,11 +1466,9 @@ static int cmd_history_squash(int argc,\n \t\tOPT_END(),\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n-\tstruct strbuf message = STRBUF_INIT;\n-\tstruct commit *base, *oldest, *tip, *rewritten, *msg_source,\n-\t\t*amend_source;\n+\tstruct commit *base, *oldest, *tip, *rewritten;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n-\tconst char *message_template = NULL;\n+\tchar *message_template = NULL;\n \tstruct commit_list *parents = NULL;\n \tstruct rev_info revs = { 0 };\n \tint ret;\n@@ -1338,25 +1488,10 @@ static int cmd_history_squash(int argc,\n \tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n \n \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n-\t\t\t\t   argc, argv, &base, &oldest, &tip);\n-\tif (ret < 0)\n-\t\tgoto out;\n-\n-\tret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,\n-\t\t\t\t     &amend_source);\n-\tif (ret < 0)\n-\t\tgoto out;\n-\tif (amend_source) {\n-\t\tconst char *amend_message, *body;\n-\n-\t\tamend_message = repo_logmsg_reencode(repo, amend_source,\n-\t\t\t\t\t\t     NULL, NULL);\n-\t\tfind_commit_subject(amend_message, &body);\n-\t\tbody = skip_blank_lines(body + commit_subject_length(body));\n-\t\tstrbuf_addstr(&message, body);\n-\t\tmessage_template = message.buf;\n-\t\trepo_unuse_commit_buffer(repo, amend_source, amend_message);\n-\t}\n+\t\t\t\t   argc, argv, &base, &oldest, &tip,\n+\t\t\t\t   &message_template);\n+\tif (ret < 0)\n+\t\tgoto out;\n \n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n@@ -1366,7 +1501,7 @@ static int cmd_history_squash(int argc,\n \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n \tcommit_list_append(base, &parents);\n \n-\tret = commit_tree_ext(repo, \"squash\", msg_source, message_template,\n+\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n \t\t\t      parents,\n \t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n \tif (ret < 0) {\n@@ -1386,9 +1521,9 @@ static int cmd_history_squash(int argc,\n \n out:\n \tstrbuf_release(&reflog_msg);\n-\tstrbuf_release(&message);\n \tcommit_list_free(parents);\n \trelease_revisions(&revs);\n+\tfree(message_template);\n \treturn ret;\n }\n \ndiff --git a/object.h b/object.h\nindex ad18ffcc55..46cade33fb 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,7 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n- * builtin/history.c:                    11-12\n+ * builtin/history.c:                    11---13\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex d7697489a0..26c4b4e29e 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -29,14 +29,25 @@ check_log_subjects () {\n check_log_messages () {\n \tgit log --format=\"%B\" \"$1\" >actual &&\n \tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+# Checks that the author data of two commits matches\n+# Usage: check_commit_author <rev1> <rev2>\n+check_commit_author () {\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$1\" >expect &&\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$1\" >actual &&\n \ttest_cmp expect actual\n }\n \n test_expect_success 'setup linear history touching two files' '\n \ttest_commit base file a start &&\n-\ttest_commit --no-tag one other x &&\n-\ttest_commit --no-tag two file c &&\n-\ttest_commit three file d\n+\tGIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \\\n+\t\ttest_commit one other x &&\n+\tGIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \\\n+\t\ttest_commit two file c &&\n+\tGIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \\\n+\t\ttest_commit three file d\n '\n \n test_expect_success 'errors on missing range argument' '\n@@ -192,106 +203,150 @@ test_expect_success 'squashes when the base is the root commit' '\n \ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n '\n \n-\n-test_expect_success 'folds fixups whose target is in the range' '\n-\tgit reset --hard start &&\n-\ttest_commit --no-tag target file b &&\n-\tgit commit --allow-empty -m \"fixup! target\" &&\n-\tgit commit --allow-empty -m \"fixup! target\" &&\n-\ttest_commit --no-tag later file c &&\n-\n-\tgit history squash start.. &&\n-\n-\tcheck_commit_count start..HEAD 1 &&\n-\tcheck_log_subjects -1 <<-\\EOF\n-\ttarget\n-\tEOF\n-'\n-\n-test_expect_success 'refuses a below-range fixup! after an in-range commit' '\n-\tgit reset --hard start &&\n-\ttest_commit --no-tag inside file b &&\n-\ttest_commit --no-tag \"fixup! outside\" file c &&\n-\thead_before=$(git rev-parse HEAD) &&\n-\n-\ttest_must_fail git history squash start.. 2>err &&\n-\ttest_grep \"target is not in the range\" err &&\n-\ttest_cmp_rev \"$head_before\" HEAD\n-'\n-\n-test_expect_success 'combines a run of fixups for one commit below the range' '\n-\tgit reset --hard start &&\n-\tstage_file b && git commit -m \"fixup! base\" &&\n-\tstage_file c && git commit -m \"fixup! base\" &&\n-\n-\tgit history squash start.. &&\n-\n-\tcheck_commit_count start..HEAD 1 &&\n-\tcheck_log_subjects -1 <<-\\EOF\n-\tfixup! base\n-\tEOF\n-'\n-\n-test_expect_success 'combining below-range fixups keeps the last amend! message' '\n-\tgit reset --hard start &&\n-\tstage_file b && git commit -m \"fixup! base\" &&\n-\tstage_file c &&\n-\tcommit_with_message \"amend! base\\n\\namended body\\n\" &&\n-\n-\tgit history squash start.. &&\n-\n-\tcheck_commit_count start..HEAD 1 &&\n-\tcheck_log_messages -1 <<-\\EOF\n-\tamend! base\n-\n-\tamended body\n-\n-\tEOF\n-'\n-\n-test_expect_success 'refuses fixups for two different commits below the range' '\n-\tgit reset --hard start &&\n-\tstage_file b && git commit -m \"fixup! aaa\" &&\n-\tstage_file c && git commit -m \"fixup! bbb\" &&\n-\thead_before=$(git rev-parse HEAD) &&\n-\n-\ttest_must_fail git history squash start.. 2>err &&\n-\ttest_grep \"target is not in the range\" err &&\n-\ttest_cmp_rev \"$head_before\" HEAD\n-'\n-\n-test_expect_success 'the last amend! for the oldest commit replaces its message' '\n-\tgit reset --hard start &&\n-\ttest_commit --no-tag marker-oldest file b &&\n-\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n-\tcommit_with_message \"amend! marker-oldest\\n\\nearlier message\\n\" &&\n-\tcommit_with_message \\\n-\t\t\"amend! marker-oldest\\n\\namended subject\\n\\namended body\\n\" &&\n-\ttest_commit --no-tag marker-later file c &&\n-\tcommit_with_message \"amend! marker-later\\n\\nwrong message\\n\" &&\n-\n-\tgit history squash start.. &&\n-\n-\tcheck_commit_count start..HEAD 1 &&\n-\tcheck_log_messages -1 <<-\\EOF\n-\tamended subject\n-\n-\tamended body\n-\n-\tEOF\n-'\n-\n-test_expect_success 'preserves authorship of the oldest commit' '\n-\tgit reset --hard start &&\n-\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n-\t\ttest_commit --no-tag oldest file b &&\n-\ttest_commit newest file c &&\n-\n-\tgit history squash start.. &&\n-\n-\tgit log -1 --format=\"%an <%ae>\" >actual &&\n-\techo \"Squasher <squash@example.com>\" >expect &&\n-\ttest_cmp expect actual\n+test_expect_success 'squashing a mix of fixups' '\n+\tgit reset --hard three &&\n+\techo fix >file &&\n+\tgit commit --fixup=two -a &&\n+\techo really fix >file &&\n+\tgit commit --fixup=one -a &&\n+\techo really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! two\n+\techo really really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! one\n+\n+\t# squashing fixup! with a target that is not being squashed fails\n+\ttest_must_fail git history squash one.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing fixup! into fixup! with a different target fails\n+\ttest_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing a sequence of fixup! commits into their targets\n+\tgit history squash start..HEAD~1 &&\n+\ttest_cmp_rev start HEAD~2 &&\n+\tcheck_commit_author one HEAD~1 &&\n+\ttest_commit_message HEAD~1 -m one &&\n+\n+\t# squashing \"fixup! fixup! <target>\" into \"<target>\"\n+\tgit history squash start.. &&\n+\ttest_cmp_rev start HEAD~1 &&\n+\tcheck_commit_author one HEAD &&\n+\ttest_commit_message HEAD -m one\n+'\n+\n+test_expect_success 'squashing \"squash!\" messages' '\n+\tgit reset --hard two &&\n+\techo fix >file &&\n+\tgit commit --fixup=HEAD -a &&\n+\techo better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! $(git rev-parse two)\n+\n+\tAppend this\n+\tEOF\n+\n+\techo an even better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! squash! two\n+\n+\tAppend this as well\n+\tEOF\n+\n+\t# must edit when squashing \"squash!\" into its target\n+\ttest_must_fail git history squash two^.. 2>err &&\n+\ttest_grep \"^error: squashing .* (squash! [a-f0-9]*) would discard its message\" err &&\n+\n+\t# squashing \"squash!\" into \"fixup!\" appends messages and changes\n+\t# subject prefix\n+\tgit history squash two.. &&\n+\ttest_cmp_rev HEAD^ two &&\n+\ttest_commit_message HEAD <<-\\EOF &&\n+\tsquash! two\n+\n+\tAppend this\n+\n+\tAppend this as well\n+\tEOF\n+\tcheck_commit_author two HEAD &&\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! two\n+\n+\tA new message\n+\tEOF\n+\n+\t# \"amend!\" does not replace \"squash!\"\n+\ttest_must_fail git history squash HEAD~2.. 2>err &&\n+\ttest_grep \"^error: squashing .* (amend! two) would overwrite .squash!. message\" err\n+'\n+\n+test_expect_success 'squash commit uses last \"amend!\" message' '\n+\tgit reset --hard three &&\n+\techo fix >file &&\n+\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n+\tgit commit --allow-empty -F - <<-EOF &&\n+\tamend! $(git rev-parse --short HEAD)\n+\n+\tThe first reword\n+\n+\tMore detail\n+\tEOF\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! three\n+\n+\tThe second reword\n+\n+\tExtra detail\n+\tEOF\n+\n+\ttest_commit WIP &&\n+\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n+\n+\tThe third reword\n+\n+\tExcruciating detail\n+\tEOF\n+\n+\tgit commit --author=\"Someone Else <s.else@example.com>\" --allow-empty \\\n+\t\t-F msg &&\n+\n+\t# squashing amend! updates the commit message\n+\tgit history squash three^.. &&\n+\tsed -e 1,2d msg | test_commit_message HEAD &&\n+\tcheck_commit_author three HEAD &&\n+\ttest_cmp_rev HEAD^ three^ &&\n+\n+\t# squashing amend! into fixup! updates subject prefix\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash three.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\tcheck_commit_author HEAD@{1}~4 HEAD &&\n+\ttest_cmp_rev HEAD^ three &&\n+\n+\t# squashing amend! into amend! keeps original subject line\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash HEAD~3.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\ttest_cmp_rev HEAD~3 three &&\n+\n+\t# all amend! messages must target the first commit\n+\tgit reset --hard HEAD@{1} &&\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! WIP\n+\n+\tThe real message\n+\tEOF\n+\n+\ttest_must_fail git history squash HEAD~4.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* that does not target\" err &&\n+\n+\t# amend! message that targets commit that is not in range is rejected\n+\ttest_must_fail git history squash HEAD~3.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* target is not being squashed\" err\n '\n \n test_expect_success '--update-refs=head only moves HEAD' '\n-- \n2.54.0.200.gfd8d68259e3\n\n"},{"id":"549485","messageId":"2ccc83c776ed77a80cc5e0584367540d231311de.1785750108.git.phillip.wood@dunelm.org.uk","threadId":"65812","inReplyTo":"cover.1785750108.git.phillip.wood@dunelm.org.uk","subject":"[PATCH v10 3.7/3.7] fixup! history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-03T09:49:27Z","receivedAt":"2026-08-03T09:50:49Z","isPatch":true,"body":"From: Phillip Wood <phillip.wood@dunelm.org.uk>\n\nFix squashing into a merge\n\nIf the first commit of the range is a merge then the squashed commit\nshould also be a merge. Unfortunately, we assume that the first\ncommit in the range only has a single parent, but do not error out\nif that's not the case. This means that fixing up a merge drops the\nsecond and later parents. Correct this by using the list of parents\nfrom the first commit in the range when creating the squashed commit.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n---\n builtin/history.c         | 20 +++++++-------------\n t/t3455-history-squash.sh | 26 ++++++++++++++++++++++++++\n 2 files changed, 33 insertions(+), 13 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 894da69f31..e21ef31e7e 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1276,14 +1276,13 @@ static int squash_check_subject(struct repository *repo,\n static int resolve_squash_range(struct repository *repo,\n \t\t\t\tbool update_branches,\n \t\t\t\tint argc, const char **argv,\n-\t\t\t\tstruct commit **base_out,\n \t\t\t\tstruct commit **oldest_out,\n \t\t\t\tstruct commit **tip_out,\n \t\t\t\tchar **message_out)\n {\n \tstruct rev_info revs;\n \tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n-\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n+\tstruct commit *commit, *oldest = NULL, *tip = NULL;\n \tsize_t i;\n \tint ret, tip_count = 0;\n \tstruct ref_filter filter = REF_FILTER_INIT;\n@@ -1426,9 +1425,6 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\t    \"untouched.\"));\n \t\tgoto out;\n \t}\n-\tbase = oldest->parents->item;\n-\n-\t*base_out = base;\n \t*oldest_out = oldest;\n \t*tip_out = tip;\n \t*message_out = strbuf_detach(&subject_data.squash_message, NULL);\n@@ -1466,10 +1462,9 @@ static int cmd_history_squash(int argc,\n \t\tOPT_END(),\n \t};\n \tstruct strbuf reflog_msg = STRBUF_INIT;\n-\tstruct commit *base, *oldest, *tip, *rewritten;\n+\tstruct commit *oldest, *tip, *rewritten;\n \tconst struct object_id *base_tree_oid, *tip_tree_oid;\n \tchar *message_template = NULL;\n-\tstruct commit_list *parents = NULL;\n \tstruct rev_info revs = { 0 };\n \tint ret;\n \n@@ -1488,7 +1483,7 @@ static int cmd_history_squash(int argc,\n \tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n \n \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n-\t\t\t\t   argc, argv, &base, &oldest, &tip,\n+\t\t\t\t   argc, argv, &oldest, &tip,\n \t\t\t\t   &message_template);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1497,13 +1492,13 @@ static int cmd_history_squash(int argc,\n \tif (ret < 0)\n \t\tgoto out;\n \n-\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n+\tbase_tree_oid = &repo_get_commit_tree(repo,\n+\t\t\t\t\toldest->parents->item)->object.oid;\n \ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n-\tcommit_list_append(base, &parents);\n \n \tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n-\t\t\t      parents,\n-\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n+\t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n+\t\t\t      &rewritten, flags);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n \t\tgoto out;\n@@ -1521,7 +1516,6 @@ static int cmd_history_squash(int argc,\n \n out:\n \tstrbuf_release(&reflog_msg);\n-\tcommit_list_free(parents);\n \trelease_revisions(&revs);\n \tfree(message_template);\n \treturn ret;\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 26c4b4e29e..1713d6dcc3 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -347,6 +347,32 @@ test_expect_success 'squash commit uses last \"amend!\" message' '\n \t# amend! message that targets commit that is not in range is rejected\n \ttest_must_fail git history squash HEAD~3.. 2>err &&\n \ttest_grep \"^error: cannot squash .* target is not being squashed\" err\n+'\n+\n+test_expect_success 'squashing fixups into a merge' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f start &&\n+\ttest_commit F1 &&\n+\tgit checkout -b feature start &&\n+\ttest_commit F2 &&\n+\tgit merge F1 &&\n+\techo fixed >F1.t &&\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse HEAD)\n+\n+\tmerge F1 and F2\n+\n+\treworded\n+\tEOF\n+\n+\tgit commit -a -F msg &&\n+\tgit history squash HEAD^^! HEAD &&\n+\ttest_cmp_rev HEAD^1 F2 &&\n+\ttest_cmp_rev HEAD^2 F1 &&\n+\ttest_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&\n+\tsed 1,2d msg | test_commit_message HEAD\n '\n \n test_expect_success '--update-refs=head only moves HEAD' '\n-- \n2.54.0.200.gfd8d68259e3\n\n"},{"id":"549504","messageId":"CAHwyqnX8Api2VWqaDt4vgnG5P9RHGkK2Bhhi4dVAu7Qrh908rw@mail.gmail.com","threadId":"65812","inReplyTo":"cover.1785750108.git.phillip.wood@dunelm.org.uk","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-08-03T16:35:22Z","receivedAt":"2026-08-03T16:36:02Z","isPatch":true,"body":"> These patches can be fetched with\n>\n>   git fetch https://github.com/phillipwood/git wip/hn/history-squash/v10-early-part\n>\n> note that they do not support editing the commit message of the\n> squashed commit.\n\nThanks, what should I do with it?\n\nIs it a replacement for my branch, or should it apply them as fixups\n(if so how do we handle the lack of re-edit support)?\n\n\nHarald\n"},{"id":"549546","messageId":"pull.2337.v12.git.git.1785832251.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v11.git.git.1785567209.gitgitgadget@gmail.com","subject":"[PATCH v12 0/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-04T08:30:47Z","receivedAt":"2026-08-04T08:30:54Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v12:\n\n * Incorporated fixups from Phillip:\n   * Reworks range validation into a single walk that rejects roots and\n     multiple tips while preserving every parent when squashing into a\n     merge.\n   * Resolves fixup!, squash! and amend! targets directly, preserving\n     message intent and safely consolidating related markers.\n   * Builds the editor template from the exact selected revisions, including\n     exclusions, while retaining --no-edit behavior.\n   * Protects descendant local branches while leaving tags and\n     remote-tracking refs unchanged.\n\nChanges in v11:\n\n * Make message editing the default with the autosquash-style template, add\n   --no-edit instead of squash-specific --reedit-message.\n * Validate one actual boundary and tip, rejecting root-reaching and\n   multi-tip ranges.\n * Protect only interior local branches, leaving tags and remote-tracking\n   refs unchanged.\n * Move sequencer preparation before squash and fold the later\n   message-editing patch into the feature commit.\n\nChanges in v10:\n\n * Record the full revision expression in squash reflog.\n * Preserve the boundary-walk invariant when sanitizing rev-list options.\n * Clarify amend! and --reedit-message documentation.\n\nChanges in v9:\n\n * Use the last amend! targeting the oldest folded commit as the default\n   squashed message. Ignore amend! markers targeting later commits while\n   selecting that replacement message.\n * Improve tests.\n\nChanges in v8:\n\n * --reedit-message now builds the same editor template as git rebase -i\n   --autosquash: fixup!, squash! and amend! commits are grouped under the\n   commit they target instead of shown in commit order, and an amend!\n   replaces its target's message.\n * A fixup!, squash! or amend! is refused only when its target is outside\n   the range, so several fixups for an in-range commit fold together. A\n   range that is entirely markers for one below-range target is combined\n   into a single commit, keeping the last amend! message.\n * Merges inside the range are folded when the range has a single base, with\n   no dedicated opt-in flag, --ancestry-path ensures only commits descended\n   from the base are folded, and a range reaching more than one base is\n   rejected.\n * Rev-list options are accepted and sanitized the way git replay does,\n   forcing the walk order back with a warning, which also fixes git history\n   squash -- --reverse slipping past the previous option check.\n * Kept this as an explicit squash subcommand rather than making\n   --reedit-message the default or renaming the command.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (4):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  sequencer: share the squash message marker helpers and flags\n  history: add squash subcommand to fold a range\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  59 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 685 ++++++++++++++++++++++--\n object.h                         |   1 +\n sequencer.c                      |  70 +--\n sequencer.h                      |  30 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 884 +++++++++++++++++++++++++++++++\n 10 files changed, 1665 insertions(+), 71 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 5b2471720c93ee30e5764a19f3d3b3ae9ec9712a\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v12\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v12\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v11:\n\n 1:  dd1deb5e5b = 1:  20e050a0ad history: extract helper for a commit's parent tree\n 2:  7309f64485 = 2:  b99867a0d4 history: give commit_tree_ext a message template\n 3:  0762589e22 = 3:  352db997e9 sequencer: share the squash message marker helpers and flags\n 4:  7abda3643c ! 4:  33d3eca6db history: add squash subcommand to fold a range\n     @@ Commit message\n          a \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n      \n          Add \"git history squash <revision-range>\" to do this directly. It folds\n     -    every commit in the range into one commit, preserving the authorship of\n     +    every selected commit into one, preserving the authorship and parents of\n          the oldest commit and taking the tree of the tip, then replays commits\n     -    above the range. The editor opens with every folded message in the same\n     -    template used by \"git rebase -i --autosquash\". With --no-edit, the\n     -    oldest message is kept, or the last amend! replacement that targets it.\n     +    above the range. By default an editor opens with every folded message in\n     +    the same template used by \"git rebase -i --autosquash\". With --no-edit,\n     +    the selected message is used without opening an editor.\n      \n     -    A fixup!, squash! or amend! commit is refused unless its target is also\n     -    in the range, so the fold does not silently absorb a marker intended for\n     -    another commit. As an exception, a range made up entirely of markers for\n     -    one target is combined, allowing a batch of related fixups to be\n     -    consolidated.\n     +    Resolve fixup!, squash! and amend! subjects directly while walking the\n     +    selected commits. A marker is rejected unless its fixed target is also\n     +    selected, except that a range made up entirely of related markers can be\n     +    consolidated. Without editing, refuse any squash! or amend! whose message\n     +    would be discarded.\n      \n     -    The range is read like the arguments to \"git rev-list\", so several\n     -    revisions such as \"HEAD~3..HEAD ^topic\" may be given, and rev-list\n     -    options are accepted too. Walk-order options needed by the fold are\n     -    restored after setup_revisions(), with a warning when an option changed\n     -    them. The selected graph must have one actual boundary and one actual\n     -    tip and must not reach a root. A merge inside the range is folded when\n     -    all selected history reaches the same boundary; otherwise the range has\n     -    more than one base and is rejected.\n     +    Read the range like arguments to \"git rev-list\" and accept multiple\n     +    revisions and rev-list options. Restore the walk settings required by the\n     +    fold after setup_revisions(), warning when an option changed them. The\n     +    range must name a bottom commit, must not reach a root, and must have one\n     +    tip. Every parent after the oldest commit must be selected or also be a\n     +    parent of the oldest commit. This permits internal merges and preserves\n     +    all parents when the oldest commit is itself a merge.\n      \n     -    By default the command refuses when a local branch points inside the\n     -    range, because that branch cannot be replayed as a descendant of the\n     -    result. Tags and remote-tracking refs are left unchanged. Use\n     -    --update-refs=head to leave interior branches unchanged as well.\n     +    By default, refuse when a local branch descends from the selected commits\n     +    but cannot remain a descendant of the result. Tags and remote-tracking\n     +    refs are left unchanged. Use --update-refs=head to leave such local\n     +    branches unchanged as well.\n      \n          Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n     +    Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/config/advice.adoc ##\n     @@ Documentation/git-history.adoc: at once.\n      +This command does not (yet) replay merge commits onto the rewritten\n      +history: if a commit that would be replayed is a merge, the operation is\n      +rejected, and you should use linkgit:git-rebase[1] with the\n     -+`--rebase-merges` flag instead. The `squash` subcommand can still fold a\n     -+merge that lies inside the range, as long as the range has a single base.\n     ++`--rebase-merges` flag instead. The `squash` subcommand can still fold merges\n     ++that lie inside the selected range, subject to the restrictions below.\n       \n       Furthermore, the command does not support operations that can result in merge\n       conflicts. This limitation is by design as history rewrites are not intended to\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +already on `topic`. Rev-list options may also be given, but any that would\n      +change how the range is walked are overridden with a warning.\n      ++\n     -+An editor opens pre-filled with the messages of all the folded commits so\n     -+you can combine them. With `--no-edit`, the oldest commit's message is\n     -+preserved instead, except that an `amend!` commit targeting it replaces its\n     -+message. A merge commit inside the range is folded like any other, but the\n     -+range must have a single base and a single tip. A range that reaches more\n     -+than one entry point (for example a side branch that forked before the range\n     -+and was later merged into it), or that selects two unmerged tips, is rejected.\n     ++An editor opens pre-filled with the messages of all the folded commits so you\n     ++can combine them. With `--no-edit`, the oldest commit's message is preserved\n     ++instead, except that an `amend!` commit targeting it replaces its message.\n     +++\n     ++The selected commits must form a connected graph with a single tip and must\n     ++not include a root commit. Every parent of a commit after the oldest one must\n     ++either be selected or also be a parent of the oldest commit. When the oldest\n     ++commit is a merge, all of its parents are preserved in the squashed commit.\n      ++\n      +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n      +targets is also in the range, so the fold does not silently absorb a\n      +marker meant for a commit outside it. As an exception, a range made up entirely\n      +of markers for one target is combined into a single commit. With `--no-edit`,\n     -+the last `amend!` message is used if there is one.\n     ++the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n     ++otherwise refused if folding it would discard its message.\n      ++\n     -+The template mirrors `git rebase -i --autosquash`: each `fixup!`, `squash!`,\n     -+or `amend!` is grouped under the commit it targets rather than shown in\n     -+commit order. A `fixup!` message is dropped (commented out in full), a\n     -+`squash!` keeps its body with only the marker subject commented, and an\n     ++The editor template mirrors `git rebase -i --autosquash`: each `fixup!`,\n     ++`squash!`, or `amend!` is grouped under the commit it targets rather than\n     ++shown in commit order. A `fixup!` message is dropped (commented out in full),\n     ++a `squash!` keeps its body with only the marker subject commented, and an\n      +`amend!` replaces its target's message, unless a `squash!` folded into that\n      +target first, in which case it keeps its body like a `squash!`.\n      ++\n     -+A local branch that points at a commit inside the range cannot be rewritten\n     -+as a descendant of the result, so with the default `--update-refs=branches`\n     -+the command refuses. Rerun with `--update-refs=head` to rewrite only the\n     -+current branch and leave such branches pointing at the old commits. Tags and\n     -+remote-tracking refs are always left unchanged.\n     ++A local branch descended from a selected commit but not from the range tip\n     ++cannot be rewritten as a descendant of the result, so with the default\n     ++`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n     ++to rewrite only the current branch and leave such branches unchanged. Tags\n     ++and remote-tracking refs are always left unchanged.\n      +\n       OPTIONS\n       -------\n     @@ Documentation/git-history.adoc: OPTIONS\n      +`--edit`::\n      +`--no-edit`::\n      +\tFor `squash`, open an editor to combine the messages of the folded commits.\n     -+\tThis is the default; use `--no-edit` to keep the selected message\n     -+\twithout opening an editor.\n     ++\tThis is the default; use `--no-edit` to keep the selected message without\n     ++\topening an editor.\n      +\n       `--reedit-message`::\n       \tOpen an editor to modify the target commit's message.\n     @@ builtin/history.c\n       #include \"cache-tree.h\"\n       #include \"commit.h\"\n       #include \"commit-reach.h\"\n     +@@\n     + #include \"path.h\"\n     + #include \"read-cache.h\"\n     + #include \"refs.h\"\n     ++#include \"ref-filter.h\"\n     + #include \"replay.h\"\n     + #include \"reset.h\"\n     + #include \"revision.h\"\n      @@\n       \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n       #define GIT_HISTORY_SPLIT_USAGE \\\n     @@ builtin/history.c: out:\n       \treturn ret;\n       }\n       \n     ++/*Remember to update object flag allocation in object.h */\n     ++#define SQUASH_SEEN (1u << 11)\n     ++#define SQUASH_TIP (1u << 12)\n     ++#define SQUASH_AMEND_TARGET (1u << 13)\n     ++\n     ++static bool is_autosquash_subject(const char *s)\n     ++{\n     ++\treturn starts_with(s, \"amend!\") || starts_with(s, \"fixup!\") ||\n     ++\t\tstarts_with(s, \"squash!\");\n     ++}\n     ++\n     ++static bool skip_one_autosquash_prefix(const char *s, const char **out)\n     ++{\n     ++\tif (skip_prefix(s, \"amend!\", out) || skip_prefix(s, \"fixup!\", out) ||\n     ++\t    skip_prefix(s, \"squash!\", out)) {\n     ++\t\twhile (**out == ' ')\n     ++\t\t\t(*out)++;\n     ++\t\treturn true;\n     ++\t}\n     ++\treturn false;\n     ++}\n     ++\n     ++static void truncate_message_to_subject(struct strbuf *msg)\n     ++{\n     ++\tconst char *eos = strstr(msg->buf, \"\\n\\n\");\n     ++\n     ++\tif (eos)\n     ++\t\tstrbuf_setlen(msg, eos - msg->buf + 1);\n     ++}\n     ++\n     ++struct subject_data {\n     ++\tstruct strintmap subjects;\n     ++\tstruct strbuf subject;\n     ++\tstruct strbuf squash_message;\n     ++\tconst char *message;\n     ++\tbool edit_message;\n     ++};\n     ++\n     ++#define SUBJECT_DATA_INIT {\t\t\\\n     ++\t.subjects = STRINTMAP_INIT,\t\\\n     ++\t.subject = STRBUF_INIT,\t\t\\\n     ++\t.squash_message = STRBUF_INIT,\t\\\n     ++}\n     ++\n     ++static void subject_data_clear(struct subject_data *data)\n     ++{\n     ++\tstrintmap_clear(&data->subjects);\n     ++\tstrbuf_release(&data->subject);\n     ++\tstrbuf_release(&data->squash_message);\n     ++}\n     ++\n     ++static int squash_amend_message(struct repository *repo,\n     ++\t\t\t\tstruct commit *commit,\n     ++\t\t\t\tstruct subject_data *data,\n     ++\t\t\t\tunsigned flags)\n     ++{\n     ++\tconst char *body = data->message + data->subject.len;\n     ++\n     ++\twhile (isspace(*body))\n     ++\t\tbody++;\n     ++\n     ++\tif (!*body) {\n     ++\t\twarning(_(\"ignoring %s (%s): message body is empty\"),\n     ++\t\t\trepo_find_unique_abbrev(repo, &commit->object.oid,\n     ++\t\t\t\t\t\tDEFAULT_ABBREV),\n     ++\t\t\tdata->subject.buf);\n     ++\t\treturn 0;\n     ++\t}\n     ++\n     ++\tif (data->edit_message) {\n     ++\t\treturn 0;\n     ++\t} else if (flags & SQUASH_AMEND_TARGET) {\n     ++\t\tif (starts_with(data->squash_message.buf, \"squash!\"))\n     ++\t\t\treturn error(_(\"squashing %s (%s) would overwrite \"\n     ++\t\t\t\t       \"'squash!' message, please combine them \"\n     ++\t\t\t\t       \"using '--edit'\"),\n     ++\t\t\t\t     repo_find_unique_abbrev(repo,\n     ++\t\t\t\t\t\t\t     &commit->object.oid,\n     ++\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n     ++\t\t\t\t     data->subject.buf);\n     ++\t\tif (starts_with(data->squash_message.buf, \"fixup!\"))\n     ++\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"amend!\", 5);\n     ++\t\tif (starts_with(data->squash_message.buf, \"amend!\")) {\n     ++\t\t\ttruncate_message_to_subject(&data->squash_message);\n     ++\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n     ++\t\t} else {\n     ++\t\t\tstrbuf_reset(&data->squash_message);\n     ++\t\t}\n     ++\t\tstrbuf_addstr(&data->squash_message, body);\n     ++\t\tstrbuf_complete_line(&data->squash_message);\n     ++\t} else {\n     ++\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n     ++\t\t\t       \"base commit without '--edit'\"),\n     ++\t\t\t     repo_find_unique_abbrev(repo, &commit->object.oid,\n     ++\t\t\t\t\t\t     DEFAULT_ABBREV),\n     ++\t\t\t     data->subject.buf);\n     ++\t}\n     ++\treturn 0;\n     ++}\n     ++\n     ++static int squash_squash_message(struct repository *repo,\n     ++\t\t\t\tstruct commit *commit,\n     ++\t\t\t\tstruct subject_data *data,\n     ++\t\t\t\tunsigned flags)\n     ++{\n     ++\tconst char *body = data->message + data->subject.len;\n     ++\n     ++\twhile (isspace(*body))\n     ++\t\tbody++;\n     ++\n     ++\tif (data->edit_message) {\n     ++\t\treturn 0;\n     ++\t} else if (flags & SQUASH_AMEND_TARGET) {\n     ++\t\tif (starts_with(data->squash_message.buf, \"fixup!\")) {\n     ++\t\t\ttruncate_message_to_subject(&data->squash_message);\n     ++\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"squash\", 6);\n     ++\t\t}\n     ++\t\tif (starts_with(data->squash_message.buf, \"squash!\")) {\n     ++\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n     ++\t\t\tstrbuf_addstr(&data->squash_message, body);\n     ++\t\t\tstrbuf_complete_line(&data->squash_message);\n     ++\t\t} else {\n     ++\t\t\treturn error(_(\"squashing %s (%s) would discard its \"\n     ++\t\t\t\t       \"message, please combine them using \"\n     ++\t\t\t\t       \"'--edit'\"),\n     ++\t\t\t\t     repo_find_unique_abbrev(repo,\n     ++\t\t\t\t\t\t\t     &commit->object.oid,\n     ++\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n     ++\t\t\t\t     data->subject.buf);\n     ++\t\t}\n     ++\t} else {\n     ++\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n     ++\t\t\t       \"base commit without '--edit'\"),\n     ++\t\t\t      repo_find_unique_abbrev(repo, &commit->object.oid,\n     ++\t\t\t\t\t\t      DEFAULT_ABBREV),\n     ++\t\t\t      data->subject.buf);\n     ++\t}\n     ++\treturn 0;\n     ++}\n     ++\n     ++static int squash_check_can_autosquash(struct repository *repo,\n     ++\t\t\t\t       struct commit *commit,\n     ++\t\t\t\t       struct subject_data *data,\n     ++\t\t\t\t       unsigned flags)\n     ++{\n     ++\tcommit->object.flags |= flags & SQUASH_AMEND_TARGET;\n     ++\tif (starts_with(data->subject.buf, \"amend!\"))\n     ++\t\treturn squash_amend_message(repo, commit, data, flags);\n     ++\telse if (starts_with(data->subject.buf, \"squash!\"))\n     ++\t\treturn squash_squash_message(repo, commit, data, flags);\n     ++\n     ++\treturn 0;\n     ++}\n     ++\n     ++static int squash_check_autosquash_subject(struct repository *repo,\n     ++\t\t\t\t\t   struct commit *commit,\n     ++\t\t\t\t\t   struct subject_data *data)\n     ++{\n     ++\tconst char* s = data->subject.buf;\n     ++\tstruct commit *target;\n     ++\tstruct hashmap_iter iter;\n     ++\tstruct strmap_entry *entry;\n     ++\t/* Try skipping autosquash prefixes one at a time to allow\n     ++\t * squashing\n     ++\t *     a commit\n     ++\t *     fixup! fixup! a commit\n     ++\t *\n     ++\t * where we may have started with\n     ++\t *     a commit\n     ++\t *     fixup! a commit\n     ++\t *     fixup! fixup! a commit\n     ++\t *\n     ++\t * and squashed the first fixup separately from the second\n     ++\t */\n     ++\twhile (skip_one_autosquash_prefix(s, &s)) {\n     ++\t\tunsigned flags = strintmap_get(&data->subjects, s);\n     ++\t\tif (flags)\n     ++\t\t\treturn squash_check_can_autosquash(repo, commit, data, flags);\n     ++\t}\n     ++\t/*\n     ++\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n     ++\t * \"fixup! main\". If the target is not being squshed check the subject\n     ++\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n     ++\t * squashed together.\n     ++\t */\n     ++\ttarget = lookup_commit_reference_by_name(s);\n     ++\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n     ++\t\tunsigned flags =\n     ++\t\t\ttarget->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);\n     ++\t\tif (!flags) {\n     ++\t\t\tconst char *subject_start;\n     ++\t\t\tconst char *buffer = repo_logmsg_reencode(repo, target,\n     ++\t\t\t\t\t\t\t\t  NULL, NULL);\n     ++\t\t\tsize_t subject_len = find_commit_subject(buffer,\n     ++\t\t\t\t\t\t\t\t &subject_start);\n     ++\t\t\tchar *subject = xmemdupz(subject_start, subject_len);\n     ++\n     ++\t\t\tflags = strintmap_get(&data->subjects, subject);\n     ++\t\t\tfree(subject);\n     ++\t\t\trepo_unuse_commit_buffer(repo, target, buffer);\n     ++\t\t}\n     ++\t\tif (flags)\n     ++\t\t\treturn squash_check_can_autosquash(repo, commit,\n     ++\t\t\t\t\t\t\t   data, flags);\n     ++\t}\n     ++\t/* Try subject prefix matches */\n     ++\tstrintmap_for_each_entry(&data->subjects, &iter, entry) {\n     ++\t\ts = data->subject.buf;\n     ++\t\twhile(skip_one_autosquash_prefix(s, &s)) {\n     ++\t\t\tif (starts_with(entry->key, s)) {\n     ++\t\t\t\tunsigned value = (intptr_t)entry->value;\n     ++\n     ++\t\t\t\treturn squash_check_can_autosquash(repo, commit,\n     ++\t\t\t\t\t\t\t\t   data, value);\n     ++\t\t\t}\n     ++\t\t}\n     ++\t}\n     ++\treturn error(_(\"cannot squash %s (%s): its target is not being \"\n     ++\t\t       \"squashed\"),\n     ++\t\t       repo_find_unique_abbrev(repo, &commit->object.oid,\n     ++\t\t\t\t\t       DEFAULT_ABBREV),\n     ++\t\t       data->subject.buf);\n     ++}\n     ++\n     ++static int squash_check_subject(struct repository *repo,\n     ++\t\t\t\tstruct commit *commit,\n     ++\t\t\t\tstruct subject_data *data)\n     ++{\n     ++\tint ret = 0;\n     ++\tconst char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);\n     ++\tsize_t subject_len = find_commit_subject(buf, &data->message);\n     ++\n     ++\tstrbuf_reset(&data->subject);\n     ++\tstrbuf_add(&data->subject, data->message, subject_len);\n     ++\n     ++\tif (!strintmap_get_size(&data->subjects)) {\n     ++\t\tconst char *s;\n     ++\n     ++\t\tstrbuf_addstr(&data->squash_message, data->message);\n     ++\t\tstrbuf_complete_line(&data->squash_message);\n     ++\t\t/*\n     ++\t\t * Strip a single autosquash prefix to allow squashing\n     ++\t\t *     fixup! base\n     ++\t\t *     amend! base\n     ++\t\t */\n     ++\t\ts = data->subject.buf;\n     ++\t\tskip_one_autosquash_prefix(s, &s);\n     ++\t\tstrintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);\n     ++\t\tcommit->object.flags |= SQUASH_AMEND_TARGET;\n     ++\t} else if (is_autosquash_subject(data->subject.buf)) {\n     ++\t\tret = squash_check_autosquash_subject(repo, commit, data);\n     ++\t} else {\n     ++\t\tstrintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);\n     ++\t}\n     ++\trepo_unuse_commit_buffer(repo, commit, buf);\n     ++\treturn ret;\n     ++}\n     ++\n     ++static int build_squash_message(struct repository *repo,\n     ++\t\t\t\tconst struct strbuf *todo_buf,\n     ++\t\t\t\tstruct strbuf *out);\n     ++\n      +/*\n      + * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n      + * the range (which becomes the parent of the squashed commit), the oldest\n     @@ builtin/history.c: out:\n      + * but the range must have a single base and must not reach a root commit.\n      + */\n      +static int resolve_squash_range(struct repository *repo,\n     -+\t\t\t\tconst char **argv,\n     -+\t\t\t\tstruct commit **base_out,\n     ++\t\t\t\tbool update_branches,\n     ++\t\t\t\tbool edit_message,\n     ++\t\t\t\tint argc, const char **argv,\n      +\t\t\t\tstruct commit **oldest_out,\n      +\t\t\t\tstruct commit **tip_out,\n     -+\t\t\t\tstruct oidset *interior_out)\n     ++\t\t\t\tchar **message_out)\n      +{\n      +\tstruct rev_info revs;\n     -+\tstruct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;\n     -+\tstruct commit_list *boundaries = NULL, *commits = NULL, *iter,\n     -+\t\t**commits_tail = &commits;\n     -+\tstruct oidset selected = OIDSET_INIT, has_children = OIDSET_INIT;\n     -+\tstruct strvec args = STRVEC_INIT;\n     -+\tbool reaches_root = false;\n     ++\tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n     ++\tstruct commit *commit, *oldest = NULL, *tip = NULL;\n     ++\tstruct strbuf todo_buf = STRBUF_INIT;\n      +\tsize_t i;\n     -+\tint ret;\n     ++\tint ret, tip_count = 0;\n     ++\tstruct ref_filter filter = REF_FILTER_INIT;\n     ++\tstruct ref_array refs = { 0 };\n      +\n      +\trepo_init_revisions(repo, &revs, NULL);\n     ++\tsubject_data.edit_message = edit_message;\n      +\trevs.reverse = 1;\n      +\trevs.topo_order = 1;\n      +\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n      +\trevs.simplify_history = 0;\n     -+\trevs.boundary = 1;\n     -+\n     -+\tstrvec_push(&args, \"ignored\");\n     -+\tstrvec_push(&args, \"--ancestry-path\");\n     -+\tstrvec_pushv(&args, argv);\n     -+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n     -+\tif (args.nr != 1) {\n     -+\t\tret = error(_(\"unrecognized argument: %s\"), args.v[1]);\n     ++\trevs.ancestry_path = 1;\n     ++\trevs.limited = 1;\n     ++\trevs.ancestry_path_implicit_bottoms = 1;\n     ++\n     ++\targc = setup_revisions(argc, argv, &revs, NULL);\n     ++\tif (argc > 1) {\n     ++\t\tret = error(_(\"unrecognized argument: %s\"), argv[1]);\n      +\t\tgoto out;\n      +\t}\n      +\n      +\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n      +\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n     -+\t    revs.simplify_history != 0 || revs.boundary != 1) {\n     ++\t    revs.simplify_history != 0 || revs.boundary == 1 ||\n     ++\t    revs.ancestry_path != 1 || revs.limited != 1 ||\n     ++\t    revs.ancestry_path_implicit_bottoms != 1) {\n      +\t\twarning(_(\"ignoring rev-list options that would change how the \"\n      +\t\t\t  \"range is walked\"));\n      +\t\trevs.reverse = 1;\n      +\t\trevs.topo_order = 1;\n      +\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n      +\t\trevs.simplify_history = 0;\n     -+\t\trevs.boundary = 1;\n     ++\t\trevs.boundary = 0;\n     ++\t\trevs.ancestry_path = 1;\n     ++\t\trevs.limited = 1;\n     ++\t\trevs.ancestry_path_implicit_bottoms = 1;\n      +\t}\n      +\n     -+\t/* A squash range must name a bottom revision to reparent onto. */\n     ++\t/*\n     ++\t * A squash needs a base to reparent onto, so the range has to exclude\n     ++\t * something, as in \"<base>..<tip>\". A revision range with no such\n     ++\t * bottom commit cannot be squashed.\n     ++\t */\n      +\tfor (i = 0; i < revs.cmdline.nr; i++)\n      +\t\tif (revs.cmdline.rev[i].flags & BOTTOM)\n      +\t\t\tbreak;\n     @@ builtin/history.c: out:\n      +\t\tret = error(_(\"error preparing revisions\"));\n      +\t\tgoto out;\n      +\t}\n     -+\n     -+\t/* Set boundary commits aside for the base check below. */\n      +\twhile ((commit = get_revision(&revs))) {\n     -+\t\tif (commit->object.flags & BOUNDARY) {\n     -+\t\t\tcommit_list_insert(commit, &boundaries);\n     -+\t\t\tcontinue;\n     ++\t\tstruct commit_list *p;\n     ++\n     ++\t\tif (edit_message)\n     ++\t\t\tstrbuf_addf(&todo_buf, \"pick %s\\n\",\n     ++\t\t\t\t    oid_to_hex(&commit->object.oid));\n     ++\n     ++\t\tif (!commit->parents) {\n     ++\t\t\tret = error(_(\"cannot squash down to root commit\"));\n     ++\t\t\tgoto out;\n     ++\t\t}\n     ++\t\tfor (p = commit->parents; oldest && p; p = p->next) {\n     ++\t\t\tstruct commit_list *q;\n     ++\t\t\tstruct object *o;\n     ++\t\t\tbool seen;\n     ++\n     ++\t\t\tif (repo_parse_commit(repo, p->item)) {\n     ++\t\t\t\tret = error(_(\"cannot parse commit\"));\n     ++\t\t\t\tgoto out;\n     ++\t\t\t}\n     ++\t\t\to = &p->item->object;\n     ++\t\t\tseen = o->flags & SQUASH_SEEN;\n     ++\t\t\t/*\n     ++\t\t\t * Allow parents that match the parents of the\n     ++\t\t\t * squashed commit.\n     ++\t\t\t */\n     ++\t\t\tfor (q = oldest->parents; !seen && q; q = q->next) {\n     ++\t\t\t\tif (p->item == q->item) {\n     ++\t\t\t\t\tseen = true;\n     ++\t\t\t\t\tcommit_list_insert(commit, &filter.with_commit);\n     ++\t\t\t\t}\n     ++\t\t\t}\n     ++\t\t\tif (!seen) {\n     ++\t\t\t\tret = error(_(\"parent %s of commit %s is \"\n     ++\t\t\t\t\t      \"outside the revision range\"),\n     ++\t\t\t\t\t    repo_find_unique_abbrev(repo, &o->oid,\n     ++\t\t\t\t\t\t\t\t    DEFAULT_ABBREV),\n     ++\t\t\t\t\t    repo_find_unique_abbrev(repo, &commit->object.oid,\n     ++\t\t\t\t\t\t\t\t    DEFAULT_ABBREV));\n     ++\t\t\t\tgoto out;\n     ++\t\t\t}\n     ++\t\t\tif (o->flags & SQUASH_TIP) {\n     ++\t\t\t\ttip_count--;\n     ++\t\t\t\to->flags &= ~SQUASH_TIP;\n     ++\t\t\t}\n      +\t\t}\n     -+\t\tif (!oldest)\n     ++\t\tif (!oldest) {\n     ++\t\t\tcommit_list_insert(commit, &filter.with_commit);\n      +\t\t\toldest = commit;\n     -+\t\toidset_insert(&selected, &commit->object.oid);\n     -+\t\tcommits_tail = commit_list_append(commit, commits_tail);\n     ++\t\t}\n     ++\t\tif (squash_check_subject(repo, commit, &subject_data)) {\n     ++\t\t\tret = -1;\n     ++\t\t\tgoto out;\n     ++\t\t}\n     ++\t\ttip = commit;\n     ++\t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n     ++\t\ttip_count++;\n      +\t}\n     -+\n     -+\tif (!oldest) {\n     ++\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP | SQUASH_AMEND_TARGET);\n     ++\treset_revision_walk();\n     ++\tif (!tip_count) {\n      +\t\tret = error(_(\"the revision range is empty\"));\n      +\t\tgoto out;\n     -+\t} else if (!commits->next) {\n     ++\t} else if (tip_count != 1) {\n     ++\t\t  ret = error(_(\"the revision range contains more than one tip \"\n     ++\t\t\t\t\"commit\"));\n     ++\t\t  goto out;\n     ++\t  } else if (oldest == tip) {\n      +\t\tret = error(_(\"the revision range holds a single commit; \"\n      +\t\t\t      \"nothing to squash\"));\n      +\t\tgoto out;\n     ++\t} else if (!oldest->parents) {\n     ++\t\tBUG(\"an in-range commit must have a parent\");\n      +\t}\n     -+\n     -+\t/*\n     -+\t * Find the selected commits that have selected children. The only\n     -+\t * remaining commit must be the tip whose tree becomes the result.\n     -+\t */\n     -+\tfor (iter = commits; iter; iter = iter->next) {\n     -+\t\tstruct commit_list *p;\n     -+\n     -+\t\tif (!iter->item->parents)\n     -+\t\t\treaches_root = true;\n     -+\t\tfor (p = iter->item->parents; p; p = p->next)\n     -+\t\t\tif (oidset_contains(&selected, &p->item->object.oid))\n     -+\t\t\t\toidset_insert(&has_children,\n     -+\t\t\t\t\t      &p->item->object.oid);\n     -+\t}\n     -+\tif (reaches_root) {\n     -+\t\tret = error(_(\"the revision range reaches a root commit; \"\n     -+\t\t\t      \"cannot squash\"));\n     ++\tcommit_list_insert(tip, &filter.no_commit);\n     ++\tfilter.kind = FILTER_REFS_BRANCHES;\n     ++\tif (update_branches &&\n     ++\t    filter_refs(&refs, &filter, filter.kind)) {\n     ++\t\tret = error(_(\"could not filter refs\"));\n      +\t\tgoto out;\n      +\t}\n     -+\tfor (iter = commits; iter; iter = iter->next) {\n     -+\t\tif (oidset_contains(&has_children, &iter->item->object.oid))\n     -+\t\t\tcontinue;\n     -+\t\tif (tip) {\n     -+\t\t\tret = error(_(\"the revision range has more than one tip; \"\n     -+\t\t\t\t      \"cannot squash\"));\n     -+\t\t\tgoto out;\n     -+\t\t}\n     -+\t\ttip = iter->item;\n     -+\t}\n     -+\n     -+\t/* The range must reach exactly one commit outside it. */\n     -+\tif (!boundaries) {\n     -+\t\tret = error(_(\"the revision range has no base; cannot squash\"));\n     ++\tif (refs.nr) {\n     ++\t\t/*\n     ++\t\t * TODO: list the branches and also check HEADS from other worktrees\n     ++\t\t */\n     ++\t\tret = error(_(\"a branch points to a commit that is being squashed\"));\n     ++\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n     ++\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n     ++\t\t\t\t    \"the current branch and leave such refs \"\n     ++\t\t\t\t    \"untouched.\"));\n      +\t\tgoto out;\n      +\t}\n     -+\tbase = boundaries->item;\n     -+\tfor (iter = boundaries; iter; iter = iter->next) {\n     -+\t\tif (iter->item != base) {\n     -+\t\t\tret = error(_(\"the revision range has more than one base; \"\n     -+\t\t\t\t      \"cannot squash\"));\n     ++\tif (edit_message) {\n     ++\t\tstrbuf_reset(&subject_data.squash_message);\n     ++\t\tret = build_squash_message(repo, &todo_buf,\n     ++\t\t\t\t\t   &subject_data.squash_message);\n     ++\t\tif (ret < 0)\n      +\t\t\tgoto out;\n     -+\t\t}\n      +\t}\n     -+\n     -+\tfor (iter = commits; iter; iter = iter->next)\n     -+\t\tif (iter->item != tip)\n     -+\t\t\toidset_insert(interior_out, &iter->item->object.oid);\n     -+\n     -+\t*base_out = base;\n      +\t*oldest_out = oldest;\n      +\t*tip_out = tip;\n     ++\t*message_out = strbuf_detach(&subject_data.squash_message, NULL);\n      +\tret = 0;\n      +\n      +out:\n     -+\tcommit_list_free(boundaries);\n     -+\tcommit_list_free(commits);\n     -+\toidset_clear(&selected);\n     -+\toidset_clear(&has_children);\n     -+\treset_revision_walk();\n     ++\tstrbuf_release(&todo_buf);\n     ++\tsubject_data_clear(&subject_data);\n      +\trelease_revisions(&revs);\n     -+\tstrvec_clear(&args);\n     ++\tref_filter_clear(&filter);\n     ++\tref_array_clear(&refs);\n      +\treturn ret;\n      +}\n      +\n     -+static const char *autosquash_target(const char *subject)\n     -+{\n     -+\tconst char *rest;\n     -+\n     -+\twhile (skip_prefix(subject, \"fixup! \", &rest) ||\n     -+\t       skip_prefix(subject, \"squash! \", &rest) ||\n     -+\t       skip_prefix(subject, \"amend! \", &rest))\n     -+\t\tsubject = rest;\n     -+\treturn subject;\n     -+}\n     -+\n     -+static int reject_dangling_fixups(struct repository *repo,\n     -+\t\t\t\t  struct commit *base,\n     -+\t\t\t\t  struct commit *tip,\n     -+\t\t\t\t  struct commit **amend_source)\n     -+{\n     -+\tstruct todo_list todo = TODO_LIST_INIT;\n     -+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n     -+\tstruct rev_info revs;\n     -+\tstruct commit *commit, *last_amend = NULL;\n     -+\tstruct strvec args = STRVEC_INIT;\n     -+\tchar *dangling_subject = NULL, *dangling_target = NULL;\n     -+\tbool mixed_target = false, all_fixups_one_target;\n     -+\tbool past_oldest_group = false;\n     -+\tint i, ret, nr_dangling = 0;\n     -+\n     -+\t*amend_source = NULL;\n     -+\n     -+\trepo_init_revisions(repo, &revs, NULL);\n     -+\tstrvec_push(&args, \"ignored\");\n     -+\tstrvec_push(&args, \"--reverse\");\n     -+\tstrvec_push(&args, \"--topo-order\");\n     -+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n     -+\t\t     oid_to_hex(&tip->object.oid));\n     -+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n     -+\n     -+\tif (prepare_revision_walk(&revs) < 0) {\n     -+\t\tret = error(_(\"error preparing revisions\"));\n     -+\t\tgoto out;\n     -+\t}\n     -+\twhile ((commit = get_revision(&revs)))\n     -+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n     -+\t\t\t    oid_to_hex(&commit->object.oid));\n     -+\n     -+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n     -+\t    todo_list_rearrange_squash(&todo) < 0) {\n     -+\t\tret = error(_(\"could not check the range for fixups\"));\n     -+\t\tgoto out;\n     -+\t}\n     -+\n     -+\tfor (i = 0; i < todo.nr; i++) {\n     -+\t\tconst char *message, *subject_start, *target;\n     -+\t\tchar *subject;\n     -+\t\tsize_t sublen;\n     -+\n     -+\t\tmessage = repo_logmsg_reencode(repo, todo.items[i].commit,\n     -+\t\t\t\t\t       NULL, NULL);\n     -+\t\tsublen = find_commit_subject(message, &subject_start);\n     -+\n     -+\t\tif (todo.items[i].command != TODO_PICK) {\n     -+\t\t\tif (!past_oldest_group &&\n     -+\t\t\t    starts_with(subject_start, \"amend! \"))\n     -+\t\t\t\t*amend_source = todo.items[i].commit;\n     -+\t\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n     -+\t\t\tcontinue;\n     -+\t\t}\n     -+\t\tif (i)\n     -+\t\t\tpast_oldest_group = true;\n     -+\n     -+\t\tsubject = xmemdupz(subject_start, sublen);\n     -+\t\ttarget = autosquash_target(subject);\n     -+\t\tif (target != subject) {\n     -+\t\t\tnr_dangling++;\n     -+\t\t\tif (!dangling_target) {\n     -+\t\t\t\tdangling_target = xstrdup(target);\n     -+\t\t\t\tdangling_subject = xstrdup(subject);\n     -+\t\t\t} else if (strcmp(dangling_target, target)) {\n     -+\t\t\t\tmixed_target = true;\n     -+\t\t\t}\n     -+\t\t\tif (starts_with(subject, \"amend! \"))\n     -+\t\t\t\tlast_amend = todo.items[i].commit;\n     -+\t\t}\n     -+\t\tfree(subject);\n     -+\t\trepo_unuse_commit_buffer(repo, todo.items[i].commit, message);\n     -+\t}\n     -+\n     -+\tall_fixups_one_target = nr_dangling == todo.nr && !mixed_target;\n     -+\tif (nr_dangling && !all_fixups_one_target) {\n     -+\t\tret = error(_(\"cannot squash '%s': its target is not in the \"\n     -+\t\t\t      \"range\"), dangling_subject);\n     -+\t} else {\n     -+\t\tif (last_amend)\n     -+\t\t\t*amend_source = last_amend;\n     -+\t\tret = 0;\n     -+\t}\n     -+\n     -+out:\n     -+\tfree(dangling_subject);\n     -+\tfree(dangling_target);\n     -+\ttodo_list_release(&todo);\n     -+\treplay_opts_release(&opts);\n     -+\treset_revision_walk();\n     -+\trelease_revisions(&revs);\n     -+\tstrvec_clear(&args);\n     -+\treturn ret;\n     -+}\n     -+\n     -+struct interior_ref_cb {\n     -+\tconst struct oidset *interior;\n     -+\tconst char *name;\n     -+};\n     -+\n     -+static int find_interior_ref(const struct reference *ref, void *cb_data)\n     -+{\n     -+\tstruct interior_ref_cb *data = cb_data;\n     -+\n     -+\tif (oidset_contains(data->interior, ref->oid)) {\n     -+\t\tdata->name = xstrdup(ref->name);\n     -+\t\treturn 1;\n     -+\t}\n     -+\n     -+\treturn 0;\n     -+}\n     -+\n      +static bool amend_replaces_target(struct todo_list *todo, int target)\n      +{\n      +\tint i;\n     @@ builtin/history.c: out:\n      +}\n      +\n      +static int build_squash_message(struct repository *repo,\n     -+\t\t\t\tstruct commit *base,\n     -+\t\t\t\tstruct commit *tip,\n     ++\t\t\t\tconst struct strbuf *todo_buf,\n      +\t\t\t\tstruct strbuf *out)\n      +{\n     -+\tstruct rev_info revs;\n     -+\tstruct commit *commit;\n     -+\tstruct strvec args = STRVEC_INIT;\n      +\tstruct todo_list todo = TODO_LIST_INIT;\n      +\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n      +\tint i, nr_commits, ret;\n      +\n     -+\trepo_init_revisions(repo, &revs, NULL);\n     -+\tstrvec_push(&args, \"ignored\");\n     -+\tstrvec_push(&args, \"--reverse\");\n     -+\tstrvec_push(&args, \"--topo-order\");\n     -+\tstrvec_pushf(&args, \"%s..%s\", oid_to_hex(&base->object.oid),\n     -+\t\t     oid_to_hex(&tip->object.oid));\n     -+\tsetup_revisions_from_strvec(&args, &revs, NULL);\n     -+\n     -+\tif (prepare_revision_walk(&revs) < 0) {\n     -+\t\tret = error(_(\"error preparing revisions\"));\n     -+\t\tgoto out;\n     -+\t}\n     -+\n     -+\twhile ((commit = get_revision(&revs)))\n     -+\t\tstrbuf_addf(&todo.buf, \"pick %s\\n\",\n     -+\t\t\t    oid_to_hex(&commit->object.oid));\n     -+\n     -+\tif (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||\n     ++\tif (todo_list_parse_insn_buffer(repo, &opts, todo_buf->buf, &todo) < 0 ||\n      +\t    todo_list_rearrange_squash(&todo) < 0) {\n      +\t\tret = error(_(\"could not prepare the squash message\"));\n      +\t\tgoto out;\n     @@ builtin/history.c: out:\n      +out:\n      +\ttodo_list_release(&todo);\n      +\treplay_opts_release(&opts);\n     -+\treset_revision_walk();\n     -+\trelease_revisions(&revs);\n     -+\tstrvec_clear(&args);\n      +\treturn ret;\n      +}\n      +\n     @@ builtin/history.c: out:\n      +\t\tNULL,\n      +\t};\n      +\tenum ref_action action = REF_ACTION_DEFAULT;\n     -+\tenum commit_tree_flags flags = 0;\n      +\tint dry_run = 0;\n      +\tint edit = 1;\n      +\tstruct option options[] = {\n     @@ builtin/history.c: out:\n      +\t\tOPT_END(),\n      +\t};\n      +\tstruct strbuf reflog_msg = STRBUF_INIT;\n     -+\tstruct strbuf message = STRBUF_INIT;\n     -+\tstruct oidset interior = OIDSET_INIT;\n     -+\tstruct commit *base = NULL, *oldest = NULL, *tip = NULL, *rewritten,\n     -+\t\t*amend_source = NULL;\n     ++\tstruct commit *oldest, *tip, *rewritten;\n      +\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n     -+\tconst char *message_template = NULL;\n     -+\tstruct commit_list *parents = NULL;\n     ++\tchar *message_template = NULL;\n      +\tstruct rev_info revs = { 0 };\n      +\tint ret;\n      +\n      +\targc = parse_options(argc, argv, prefix, options, usage,\n     -+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT);\n     -+\tif (!argc) {\n     ++\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n     ++\tif (argc < 2) {\n      +\t\tret = error(_(\"command expects a revision range\"));\n      +\t\tgoto out;\n      +\t}\n     @@ builtin/history.c: out:\n      +\tif (action == REF_ACTION_DEFAULT)\n      +\t\taction = REF_ACTION_BRANCHES;\n      +\n     -+\tret = resolve_squash_range(repo, argv, &base, &oldest, &tip,\n     -+\t\t\t\t   &interior);\n     -+\tif (ret < 0)\n     -+\t\tgoto out;\n     ++\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n     ++\tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n      +\n     -+\tret = reject_dangling_fixups(repo, base, tip, &amend_source);\n     ++\tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n     ++\t\t\t\t   edit,\n     ++\t\t\t\t   argc, argv, &oldest, &tip,\n     ++\t\t\t\t   &message_template);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n     -+\tif (!edit && amend_source) {\n     -+\t\tconst char *amend_message, *body;\n     -+\n     -+\t\tamend_message = repo_logmsg_reencode(repo, amend_source,\n     -+\t\t\t\t\t\t     NULL, NULL);\n     -+\t\tfind_commit_subject(amend_message, &body);\n     -+\t\tbody = skip_blank_lines(body + commit_subject_length(body));\n     -+\t\tstrbuf_addstr(&message, body);\n     -+\t\tmessage_template = message.buf;\n     -+\t\trepo_unuse_commit_buffer(repo, amend_source, amend_message);\n     -+\t}\n     -+\n     -+\tif (action == REF_ACTION_BRANCHES) {\n     -+\t\tstruct interior_ref_cb cb = { .interior = &interior };\n     -+\n     -+\t\trefs_for_each_branch_ref(get_main_ref_store(repo),\n     -+\t\t\t\t\t find_interior_ref, &cb);\n     -+\t\tif (cb.name) {\n     -+\t\t\tret = error(_(\"'%s' points into the squashed range\"),\n     -+\t\t\t\t    cb.name);\n     -+\t\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n     -+\t\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n     -+\t\t\t\t\t    \"the current branch and leave such refs \"\n     -+\t\t\t\t\t    \"untouched.\"));\n     -+\t\t\tfree((char *)cb.name);\n     -+\t\t\tgoto out;\n     -+\t\t}\n     -+\t}\n     -+\n     -+\tif (edit) {\n     -+\t\tret = build_squash_message(repo, base, tip, &message);\n     -+\t\tif (ret < 0)\n     -+\t\t\tgoto out;\n     -+\t\tmessage_template = message.buf;\n     -+\t\tflags |= COMMIT_TREE_EDIT_MESSAGE;\n     -+\t}\n      +\n      +\tret = setup_revwalk(repo, action, tip, &revs);\n      +\tif (ret < 0)\n      +\t\tgoto out;\n      +\n     -+\tbase_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;\n     ++\tbase_tree_oid = &repo_get_commit_tree(repo,\n     ++\t\t\t\t\toldest->parents->item)->object.oid;\n      +\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n     -+\tcommit_list_append(base, &parents);\n      +\n      +\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n     -+\t\t\t      parents,\n     -+\t\t\t      base_tree_oid, tip_tree_oid, &rewritten, flags);\n     ++\t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n     ++\t\t\t      &rewritten,\n     ++\t\t\t      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);\n      +\tif (ret < 0) {\n      +\t\tret = error(_(\"failed writing squashed commit\"));\n      +\t\tgoto out;\n      +\t}\n      +\n     -+\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n     -+\tstrbuf_join_argv(&reflog_msg, argc, argv, ' ');\n     -+\n      +\tret = handle_reference_updates(&revs, action, tip, rewritten,\n      +\t\t\t\t       reflog_msg.buf, dry_run,\n      +\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n     @@ builtin/history.c: out:\n      +\n      +out:\n      +\tstrbuf_release(&reflog_msg);\n     -+\tstrbuf_release(&message);\n     -+\toidset_clear(&interior);\n     -+\tcommit_list_free(parents);\n      +\trelease_revisions(&revs);\n     ++\tfree(message_template);\n      +\treturn ret;\n      +}\n      +\n     @@ builtin/history.c: int cmd_history(int argc,\n       \t};\n       \n      \n     + ## object.h ##\n     +@@ object.h: void object_array_init(struct object_array *array);\n     +  * bisect.c:                                        16\n     +  * bundle.c:                                        16\n     +  * http-push.c:                          11-----14\n     ++ * builtin/history.c:                    11---13\n     +  * commit-graph.c:                                15\n     +  * commit-reach.c:                                  16-------20\n     +  * builtin/last-modified.c:                         1617\n     +\n       ## t/meson.build ##\n      @@ t/meson.build: integration_tests = [\n         't3452-history-split.sh',\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp expect actual\n      +}\n      +\n     ++# Checks that the author data of two commits matches\n     ++# Usage: check_commit_author <rev1> <rev2>\n     ++check_commit_author () {\n     ++\tgit show -s --format=\"%an <%ae> %ad\" \"$1\" >expect &&\n     ++\tgit show -s --format=\"%an <%ae> %ad\" \"$2\" >actual &&\n     ++\ttest_cmp expect actual\n     ++}\n     ++\n      +test_expect_success 'setup linear history touching two files' '\n     -+\ttest_commit base file a &&\n     -+\tgit tag start &&\n     -+\tGIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \\\n     -+\t\ttest_commit --no-tag one other x &&\n     -+\ttest_commit --no-tag two file c &&\n     -+\ttest_commit three file d\n     ++\ttest_commit base file a start &&\n     ++\tGIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \\\n     ++\t\ttest_commit one other x &&\n     ++\tGIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \\\n     ++\t\ttest_commit two file c &&\n     ++\tGIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \\\n     ++\t\ttest_commit three file d\n      +'\n      +\n      +test_expect_success 'errors on missing range argument' '\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp_rev \"$head_before\" HEAD\n      +'\n      +\n     ++test_expect_success 'rejects root commit' '\n     ++\t# create a disconnected root commit\n     ++\toid=$(git commit-tree -m root three^{tree}) &&\n     ++\t# because we pass --ancestry-path when calling setup_revs() it the\n     ++\t# revision walk will only include commits decended from $oid so\n     ++\t# we need to give it another --ancestry-path commit to actually walk\n     ++\t# any commits.\n     ++\ttest_must_fail git history squash --ancestry-path=start $oid..three 2>err &&\n     ++\techo \"error: cannot squash down to root commit\" >expect &&\n     ++\ttest_cmp expect err\n     ++'\n     ++\n     ++test_expect_success 'rejects multiple tips' '\n     ++\toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n     ++\ttest_must_fail git history squash ^start $oid three~1 2>err &&\n     ++\techo \"error: the revision range contains more than one tip commit\" >expect &&\n     ++\ttest_cmp expect err &&\n     ++\n     ++\tgit reset --hard three &&\n     ++\tgit history squash --no-edit ^start three~1 three &&\n     ++\ttest_cmp_rev HEAD~1 start^0 &&\n     ++\ttest_cmp_rev HEAD^{tree} three^{tree}\n     ++'\n     ++\n      +test_expect_success 'accepts multiple revision arguments with an exclusion' '\n      +\tgit reset --hard three &&\n      +\tgit branch -f keep HEAD~2 &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D keep\n      +'\n      +\n     -+test_expect_success 'refuses a range with more than one tip' '\n     -+\tgit reset --hard start &&\n     -+\tmain=$(git symbolic-ref --short HEAD) &&\n     -+\ttest_commit --no-tag main-one file b &&\n     -+\ttest_commit --no-tag main-two file c &&\n     -+\tgit checkout -b other-tip start &&\n     -+\ttest_commit --no-tag other-tip other d &&\n     -+\tgit checkout \"$main\" &&\n     -+\tmain_before=$(git rev-parse HEAD) &&\n     -+\tother_before=$(git rev-parse other-tip) &&\n     -+\n     -+\ttest_must_fail git history squash ^start HEAD other-tip 2>err &&\n     -+\ttest_grep \"more than one tip\" err &&\n     -+\ttest_cmp_rev \"$main_before\" HEAD &&\n     -+\ttest_cmp_rev \"$other_before\" other-tip &&\n     -+\n     -+\tgit branch -D other-tip\n     -+'\n     -+\n     -+test_expect_success 'refuses a range that reaches a root commit' '\n     -+\tgit reset --hard three &&\n     -+\troot=$(printf \"unrelated root\\n\" |\n     -+\t\tgit commit-tree \"$(git rev-parse HEAD^{tree})\") &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     -+\n     -+\ttest_must_fail git history squash --ancestry-path=\"$root\" \\\n     -+\t\t^start HEAD \"$root\" 2>err &&\n     -+\ttest_grep \"reaches a root commit\" err &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD\n     -+'\n     -+\n      +test_expect_success 'squashes a branch the current branch is not on' '\n      +\tgit reset --hard three &&\n      +\tmain=$(git symbolic-ref --short HEAD) &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D off-history\n      +'\n      +\n     -+test_expect_success 'squashes a range preserving its tree and oldest authorship' '\n     ++test_expect_success 'squashes a range into a single commit without changing the tree' '\n      +\tgit reset --hard three &&\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\tgit history squash --dry-run start.. >out &&\n     ++\tgit history squash --no-edit --dry-run start.. >out &&\n      +\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n     -+\tgit history squash start.. &&\n     ++\tgit history squash --no-edit start.. &&\n      +\n      +\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n      +\tcheck_commit_count start..HEAD 1 &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tcheck_log_subjects -1 <<-\\EOF &&\n      +\tone\n      +\tEOF\n     -+\tgit log -1 --format=\"%an <%ae>\" >actual &&\n     -+\techo \"Squasher <squash@example.com>\" >expect &&\n     -+\ttest_cmp expect actual &&\n      +\tgit reflog >reflog &&\n      +\ttest_grep \"squash: updating\" reflog\n      +'\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n      +'\n      +\n     ++test_expect_success 'squashing a mix of fixups' '\n     ++\tgit reset --hard three &&\n     ++\techo fix >file &&\n     ++\tgit commit --fixup=two -a &&\n     ++\techo really fix >file &&\n     ++\tgit commit --fixup=one -a &&\n     ++\techo really really fix >file &&\n     ++\tgit commit --fixup=HEAD~1 -a && # fixup! two\n     ++\techo really really really fix >file &&\n     ++\tgit commit --fixup=HEAD~1 -a && # fixup! one\n     ++\n     ++\t# squashing fixup! with a target that is not being squashed fails\n     ++\ttest_must_fail git history squash one.. 2>err &&\n     ++\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n     ++\n     ++\t# squashing fixup! into fixup! with a different target fails\n     ++\ttest_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two\n     ++\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n     ++\n     ++\t# squashing a sequence of fixup! commits into their targets\n     ++\tgit history squash --no-edit start..HEAD~1 &&\n     ++\ttest_cmp_rev start HEAD~2 &&\n     ++\tcheck_commit_author one HEAD~1 &&\n     ++\ttest_commit_message HEAD~1 -m one &&\n      +\n     -+test_expect_success 'folds fixups whose target is in the range' '\n     -+\tgit reset --hard start &&\n     -+\ttest_commit --no-tag target file b &&\n     -+\tgit commit --allow-empty -m \"fixup! target\" &&\n     -+\tgit commit --allow-empty -m \"fixup! target\" &&\n     -+\ttest_commit --no-tag later file c &&\n     -+\n     ++\t# squashing \"fixup! fixup! <target>\" into \"<target>\"\n      +\tgit history squash --no-edit start.. &&\n     -+\n     -+\tcheck_commit_count start..HEAD 1 &&\n     -+\tcheck_log_subjects -1 <<-\\EOF\n     -+\ttarget\n     -+\tEOF\n     ++\ttest_cmp_rev start HEAD~1 &&\n     ++\tcheck_commit_author one HEAD &&\n     ++\ttest_commit_message HEAD -m one\n      +'\n      +\n     -+test_expect_success 'refuses a below-range fixup! after an in-range commit' '\n     -+\tgit reset --hard start &&\n     -+\ttest_commit --no-tag inside file b &&\n     -+\ttest_commit --no-tag \"fixup! outside\" file c &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     -+\n     -+\ttest_must_fail git history squash start.. 2>err &&\n     -+\ttest_grep \"target is not in the range\" err &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD\n     -+'\n     ++test_expect_success 'squashing \"squash!\" messages' '\n     ++\tgit reset --hard two &&\n     ++\techo fix >file &&\n     ++\tgit commit --fixup=HEAD -a &&\n     ++\toldest=$(git rev-parse HEAD) &&\n     ++\techo better fix >file &&\n     ++\tgit commit -a -F - <<-EOF &&\n     ++\tsquash! $(git rev-parse two)\n      +\n     -+test_expect_success 'combines a run of fixups for one commit below the range' '\n     -+\tgit reset --hard start &&\n     -+\tstage_file b && git commit -m \"fixup! base\" &&\n     -+\tstage_file c && git commit -m \"fixup! base\" &&\n     ++\tAppend this\n     ++\tEOF\n      +\n     -+\tgit history squash --no-edit start.. &&\n     ++\techo an even better fix >file &&\n     ++\tgit commit -a -F - <<-EOF &&\n     ++\tsquash! squash! two\n      +\n     -+\tcheck_commit_count start..HEAD 1 &&\n     -+\tcheck_log_subjects -1 <<-\\EOF\n     -+\tfixup! base\n     ++\tAppend this as well\n      +\tEOF\n     -+'\n      +\n     -+test_expect_success 'combining below-range markers offers every message' '\n     -+\tgit reset --hard start &&\n     -+\tstage_file b && git commit -m \"fixup! base\" &&\n     -+\tstage_file c &&\n     -+\tcommit_with_message \"amend! base\\n\\namended body\\n\" &&\n     ++\t# must edit when squashing \"squash!\" into its target\n     ++\ttest_must_fail git history squash --no-edit two^.. 2>err &&\n     ++\ttest_grep \"^error: squashing .* (squash! [a-f0-9]*) would discard its message\" err &&\n      +\n     -+\tgit history squash start.. &&\n     ++\t# squashing \"squash!\" into \"fixup!\" appends messages and changes\n     ++\t# subject prefix\n     ++\tgit history squash --no-edit two.. &&\n     ++\ttest_cmp_rev HEAD^ two &&\n     ++\ttest_commit_message HEAD <<-\\EOF &&\n     ++\tsquash! two\n      +\n     -+\tcheck_commit_count start..HEAD 1 &&\n     -+\tcheck_log_messages -1 <<-\\EOF\n     -+\tfixup! base\n     ++\tAppend this\n      +\n     -+\tamend! base\n     ++\tAppend this as well\n     ++\tEOF\n     ++\tcheck_commit_author \"$oldest\" HEAD &&\n      +\n     -+\tamended body\n     ++\tgit commit --allow-empty -F - <<-\\EOF &&\n     ++\tamend! two\n      +\n     ++\tA new message\n      +\tEOF\n     ++\n     ++\t# \"amend!\" does not replace \"squash!\"\n     ++\ttest_must_fail git history squash --no-edit HEAD~2.. 2>err &&\n     ++\ttest_grep \"^error: squashing .* (amend! two) would overwrite .squash!. message\" err\n      +'\n      +\n     -+test_expect_success 'refuses fixups for two different commits below the range' '\n     -+\tgit reset --hard start &&\n     -+\tstage_file b && git commit -m \"fixup! aaa\" &&\n     -+\tstage_file c && git commit -m \"fixup! bbb\" &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     ++test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n     ++\tgit reset --hard three &&\n     ++\twrite_script editor <<-\\EOF &&\n     ++\texit 1\n     ++\tEOF\n     ++\ttest_set_editor \"$(pwd)/editor\" &&\n     ++\techo fix >file &&\n     ++\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n     ++\tgit commit --allow-empty -F - <<-EOF &&\n     ++\tamend! $(git rev-parse --short HEAD)\n      +\n     -+\ttest_must_fail git history squash start.. 2>err &&\n     -+\ttest_grep \"target is not in the range\" err &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD\n     -+'\n     ++\tThe first reword\n      +\n     -+test_expect_success 'does not discard squash! or amend! message bodies' '\n     -+\tgit reset --hard start &&\n     -+\ttest_commit --no-tag marker-oldest file b &&\n     -+\tgit commit --allow-empty -m \"squash! marker-oldest\" &&\n     -+\tcommit_with_message \"amend! marker-oldest\\n\\nearlier message\\n\" &&\n     -+\tcommit_with_message \\\n     -+\t\t\"amend! marker-oldest\\n\\namended subject\\n\\namended body\\n\" &&\n     -+\ttest_commit --no-tag marker-later file c &&\n     -+\tcommit_with_message \"amend! marker-later\\n\\nwrong message\\n\" &&\n     ++\tMore detail\n     ++\tEOF\n      +\n     -+\tgit history squash start.. &&\n     ++\tgit commit --allow-empty -F - <<-\\EOF &&\n     ++\tamend! three\n      +\n     -+\tcheck_commit_count start..HEAD 1 &&\n     -+\tcheck_log_messages -1 <<-\\EOF\n     -+\tmarker-oldest\n     ++\tThe second reword\n      +\n     -+\tearlier message\n     ++\tExtra detail\n     ++\tEOF\n      +\n     -+\tamended subject\n     ++\ttest_commit WIP &&\n      +\n     -+\tamended body\n     ++\tcat >msg <<-EOF &&\n     ++\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n      +\n     -+\twrong message\n     ++\tThe third reword\n      +\n     ++\tExcruciating detail\n      +\tEOF\n     -+'\n     -+\n     -+test_expect_success '--no-edit keeps the selected message without an editor' '\n     -+\tgit reset --hard start &&\n     -+\ttest_commit --no-tag no-edit-target file b &&\n     -+\tgit commit --allow-empty -m \"squash! no-edit-target\" &&\n     -+\tcommit_with_message \"amend! no-edit-target\\n\\namended subject\\n\\namended body\\n\" &&\n      +\n     -+\twrite_script editor <<-\\EOF &&\n     -+\texit 1\n     ++\tgit commit --author=\"Someone Else <s.else@example.com>\" --allow-empty \\\n     ++\t\t-F msg &&\n     ++\n     ++\t# squashing amend! updates the commit message\n     ++\tgit history squash --no-edit three^.. &&\n     ++\tsed -e 1,2d msg | test_commit_message HEAD &&\n     ++\tcheck_commit_author three HEAD &&\n     ++\ttest_cmp_rev HEAD^ three^ &&\n     ++\n     ++\t# squashing amend! into fixup! updates subject prefix\n     ++\tgit reset --hard HEAD@{1} &&\n     ++\tgit history squash --no-edit three.. &&\n     ++\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n     ++\tcheck_commit_author HEAD@{1}~4 HEAD &&\n     ++\ttest_cmp_rev HEAD^ three &&\n     ++\n     ++\t# squashing amend! into amend! keeps original subject line\n     ++\tgit reset --hard HEAD@{1} &&\n     ++\tgit history squash --no-edit HEAD~3.. &&\n     ++\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n     ++\ttest_cmp_rev HEAD~3 three &&\n     ++\n     ++\t# all amend! messages must target the first commit\n     ++\tgit reset --hard HEAD@{1} &&\n     ++\tgit commit --allow-empty -F - <<-\\EOF &&\n     ++\tamend! WIP\n     ++\n     ++\tThe real message\n      +\tEOF\n     -+\ttest_set_editor \"$(pwd)/editor\" &&\n     -+\tgit history squash --no-edit start.. &&\n      +\n     -+\tcheck_log_messages -1 <<-\\EOF &&\n     -+\tamended subject\n     ++\ttest_must_fail git history squash --no-edit HEAD~4.. 2>err &&\n     ++\ttest_grep \"^error: cannot squash .* that does not target\" err &&\n      +\n     -+\tamended body\n     ++\t# amend! message that targets commit that is not in range is rejected\n     ++\ttest_must_fail git history squash --no-edit HEAD~3.. 2>err &&\n     ++\ttest_grep \"^error: cannot squash .* target is not being squashed\" err &&\n     ++\ttest_set_editor :\n     ++'\n      +\n     ++test_expect_success 'squashing fixups into a merge' '\n     ++\ttest_when_finished \\\n     ++\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n     ++\t\t git branch -D feature\" &&\n     ++\tgit checkout -f start &&\n     ++\ttest_commit F1 &&\n     ++\tgit checkout -b feature start &&\n     ++\ttest_commit F2 &&\n     ++\tgit merge F1 &&\n     ++\techo fixed >F1.t &&\n     ++\tcat >msg <<-EOF &&\n     ++\tamend! $(git rev-parse HEAD)\n     ++\n     ++\tmerge F1 and F2\n     ++\n     ++\treworded\n      +\tEOF\n     -+\ttest_set_editor :\n     ++\n     ++\tgit commit -a -F msg &&\n     ++\tgit history squash --no-edit HEAD^^! HEAD &&\n     ++\ttest_cmp_rev HEAD^1 F2 &&\n     ++\ttest_cmp_rev HEAD^2 F1 &&\n     ++\ttest_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&\n     ++\tsed 1,2d msg | test_commit_message HEAD\n      +'\n      +\n      +test_expect_success 'edits every message and aborts on an empty result' '\n     @@ t/t3455-history-squash.sh (new)\n      +\tEOF\n      +'\n      +\n     -+test_expect_success 'handles fixup!, squash! and amend! messages like rebase' '\n     ++test_expect_success 'handles fixup!, squash! and amend! like rebase' '\n      +\tgit reset --hard start &&\n      +\ttest_commit --no-tag mark-base file b &&\n      +\tstage_file c &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tEOF\n      +'\n      +\n     -+test_expect_success 'handles branches pointing at the squashed range' '\n     ++test_expect_success '--update-refs=head only moves HEAD' '\n      +\tgit reset --hard three &&\n      +\tgit branch -f other HEAD &&\n     -+\tgit branch -f mid HEAD~1 &&\n      +\tother_before=$(git rev-parse other) &&\n     -+\tmid_before=$(git rev-parse mid) &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\tgit history squash --no-edit --update-refs=head start.. &&\n     ++\n     ++\tcheck_commit_count start..HEAD 1 &&\n     ++\ttest_cmp_rev \"$other_before\" other\n     ++'\n     ++\n     ++test_expect_success 'refuses to fold a range a branch points into' '\n     ++\ttest_when_finished \\\n     ++\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n     ++\t\t git branch -D feature\" &&\n     ++\tgit checkout -f -b feature start &&\n     ++\ttest_commit C1 &&\n     ++\ttest_commit C2 &&\n     ++\tgit checkout -b topic-1 start &&\n     ++\ttest_commit C3 &&\n     ++\ttest_commit C4 &&\n     ++\tgit checkout C3 &&\n     ++\ttest_commit C5 &&\n     ++\tgit checkout feature &&\n     ++\tgit merge C5 &&\n     ++\ttest_commit C6 &&\n     ++\tgit checkout -b topic-2 C2 &&\n     ++\ttest_commit C7 &&\n     ++\tgit checkout feature &&\n      +\n      +\ttest_must_fail git history squash start.. 2>err &&\n     -+\ttest_grep \"error: .* points into the squashed range\" err &&\n     -+\ttest_grep \"hint: .*--update-refs=head\" err &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\t# TODO: check the branch names when we print them (topic-1 & topic-2)\n     ++\ttest_grep \"^error: a branch points to\" err &&\n     ++\ttest_grep \"^hint: .* --update-refs=head\" err &&\n     ++\ttest_cmp_rev C6 HEAD &&\n     ++\n     ++\t# squash succeeds with --update-refs=head\n     ++\tgit history squash --no-edit --update-refs=head start.. &&\n     ++\ttest_cmp_rev start HEAD^ &&\n     ++\ttest_cmp_rev C6^{tree} HEAD^{tree} &&\n     ++\ttest_cmp_rev C6 HEAD@{1}\n     ++'\n     ++\n     ++test_expect_success 'advice.historyUpdateRefs silences the hint' '\n     ++\tgit reset --hard three &&\n     ++\tgit branch -f mid HEAD~1 &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n      +\n      +\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n      +\t\thistory squash start.. 2>err &&\n     -+\ttest_grep \"points into the squashed range\" err &&\n     ++\ttest_grep \"^error: a branch points to\" err &&\n      +\ttest_grep ! \"hint:\" err &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n     -+\tgit history squash --no-edit --update-refs=head start.. &&\n     -+\n     -+\tcheck_commit_count start..HEAD 1 &&\n     -+\ttest_cmp_rev \"$other_before\" other &&\n     -+\ttest_cmp_rev \"$mid_before\" mid &&\n     -+\n     -+\tgit branch -D other mid\n     ++\tgit branch -D mid\n      +'\n      +\n      +test_expect_success 'leaves tags and remote-tracking refs unchanged' '\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit update-ref -d refs/remotes/origin/mark\n      +'\n      +\n     -+test_expect_success 'handles a branch pointing at an internal merge' '\n     ++test_expect_success 'squashes a range whose internal merge has a single base' '\n      +\tgit reset --hard start &&\n      +\tmain=$(git symbolic-ref --short HEAD) &&\n      +\ttest_commit --no-tag before-side file b &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_commit --no-tag after-side file c &&\n      +\tgit merge --no-ff -m merge inner-side &&\n      +\tgit branch -D inner-side &&\n     -+\tgit branch at-merge HEAD &&\n      +\ttest_commit --no-tag after-merge file d &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n      +\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n      +\n     -+\ttest_must_fail git history squash start.. 2>err &&\n     -+\ttest_grep \"at-merge\" err &&\n     -+\ttest_grep \"points into the squashed range\" err &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD &&\n     -+\tgit branch -D at-merge &&\n     -+\n      +\tgit history squash --no-edit start.. &&\n      +\n      +\tcheck_commit_count start..HEAD 1 &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tmerged=$(git rev-parse HEAD) &&\n      +\n      +\ttest_must_fail git history squash \"$base..\" 2>err &&\n     -+\ttest_grep \"more than one base\" err &&\n     ++\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n      +\ttest_cmp_rev \"$merged\" HEAD\n      +'\n      +\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n      +'\n      +\n     ++test_expect_success 'folds a range with two interior merges' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\ttest_commit --no-tag two-merge-a file a1 &&\n     ++\tgit checkout -b two-merge-s1 &&\n     ++\ttest_commit --no-tag two-merge-s1 s1 x &&\n     ++\tgit checkout \"$main\" &&\n     ++\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n     ++\ttest_commit --no-tag two-merge-b file b1 &&\n     ++\tgit checkout -b two-merge-s2 &&\n     ++\ttest_commit --no-tag two-merge-s2 s2 y &&\n     ++\tgit checkout \"$main\" &&\n     ++\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n     ++\tgit branch -D two-merge-s1 two-merge-s2 &&\n     ++\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n     ++\n     ++\tgit history squash --no-edit start.. &&\n     ++\n     ++\tcheck_commit_count start..HEAD 1 &&\n     ++\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n     ++\ttest_path_is_file s1 &&\n     ++\ttest_path_is_file s2\n     ++'\n     ++\n      +test_expect_success 'folds a range with a nested merge' '\n      +\tgit reset --hard start &&\n      +\tmain=$(git symbolic-ref --short HEAD) &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit branch -D octo-pre octo-within &&\n      +\n      +\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n     -+\ttest_grep \"more than one base\" err &&\n     ++\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n      +\ttest_cmp_rev \"$merged\" HEAD\n      +'\n      +\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp_rev \"$head_before\" HEAD\n      +'\n      +\n     ++test_expect_success 'refuses to fold a range a ref points into at a merge' '\n     ++\tgit reset --hard start &&\n     ++\tmain=$(git symbolic-ref --short HEAD) &&\n     ++\ttest_commit --no-tag refmerge-base file b &&\n     ++\tgit checkout -b refmerge-side &&\n     ++\ttest_commit --no-tag refmerge-side side x &&\n     ++\tgit checkout \"$main\" &&\n     ++\ttest_commit --no-tag refmerge-main file c &&\n     ++\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n     ++\tgit branch -D refmerge-side &&\n     ++\tgit branch at-merge HEAD &&\n     ++\ttest_commit --no-tag refmerge-tail file d &&\n     ++\thead_before=$(git rev-parse HEAD) &&\n     ++\n     ++\ttest_must_fail git history squash start.. 2>err &&\n     ++\t# TODO: test for branch nome \"at-merge\"\n     ++\ttest_grep \"a branch points to a commit\" err &&\n     ++\ttest_cmp_rev \"$head_before\" HEAD &&\n     ++\n     ++\tgit branch -D at-merge\n     ++'\n     ++\n      +test_done\n\n-- \ngitgitgadget\n"},{"id":"549545","messageId":"20e050a0ad9a3e9d665921561749cfed4ca089fd.1785832251.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v12.git.git.1785832251.gitgitgadget@gmail.com","subject":"[PATCH v12 1/4] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-04T08:30:48Z","receivedAt":"2026-08-04T08:30:55Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 000155ad9c..c22715d848 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -164,6 +164,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -171,21 +190,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -475,18 +484,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -830,16 +831,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"549547","messageId":"b99867a0d463e08de45258e6bc585052661fae89.1785832251.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v12.git.git.1785832251.gitgitgadget@gmail.com","subject":"[PATCH v12 2/4] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-04T08:30:49Z","receivedAt":"2026-08-04T08:30:57Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex c22715d848..a60e8dbcd0 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -108,6 +108,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -675,7 +679,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -886,7 +890,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -903,7 +907,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"549548","messageId":"352db997e9da56b2d94eb98d8a90b9f90c0577e8.1785832251.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v12.git.git.1785832251.gitgitgadget@gmail.com","subject":"[PATCH v12 3/4] sequencer: share the squash message marker helpers and flags","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-04T08:30:50Z","receivedAt":"2026-08-04T08:30:59Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). Also move the\ntodo_item_flags enum to the header, so a caller reading the output of\ntodo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\nfrom a plain fixup!. A later change reuses all of this to give \"git\nhistory squash\" the same template.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 70 +++++++++++++++++++++++++++++------------------------\n sequencer.h | 30 +++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 31 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 82ab3c536f..642dfa9d91 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)\n \t}\n }\n \n-enum todo_item_flags {\n-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n-};\n-\n static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 64a9c7fb1b..b01f897020 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -119,6 +119,13 @@ enum todo_command {\n \tTODO_COMMENT\n };\n \n+/* Bits for the \"flags\" member of struct todo_item */\n+enum todo_item_flags {\n+\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n+\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n+\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n+};\n+\n struct todo_item {\n \tenum todo_command command;\n \tstruct commit *commit;\n@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"549549","messageId":"33d3eca6dbbc556b096ca6ea0ff6c1720aa08916.1785832251.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v12.git.git.1785832251.gitgitgadget@gmail.com","subject":"[PATCH v12 4/4] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-04T08:30:51Z","receivedAt":"2026-08-04T08:31:01Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nFolding a series of commits into one required either an interactive\nrebase where each commit after the first was hand-edited to \"fixup\", or\na \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n\nAdd \"git history squash <revision-range>\" to do this directly. It folds\nevery selected commit into one, preserving the authorship and parents of\nthe oldest commit and taking the tree of the tip, then replays commits\nabove the range. By default an editor opens with every folded message in\nthe same template used by \"git rebase -i --autosquash\". With --no-edit,\nthe selected message is used without opening an editor.\n\nResolve fixup!, squash! and amend! subjects directly while walking the\nselected commits. A marker is rejected unless its fixed target is also\nselected, except that a range made up entirely of related markers can be\nconsolidated. Without editing, refuse any squash! or amend! whose message\nwould be discarded.\n\nRead the range like arguments to \"git rev-list\" and accept multiple\nrevisions and rev-list options. Restore the walk settings required by the\nfold after setup_revisions(), warning when an option changed them. The\nrange must name a bottom commit, must not reach a root, and must have one\ntip. Every parent after the oldest commit must be selected or also be a\nparent of the oldest commit. This permits internal merges and preserves\nall parents when the oldest commit is itself a merge.\n\nBy default, refuse when a local branch descends from the selected commits\nbut cannot remain a descendant of the result. Tags and remote-tracking\nrefs are left unchanged. Use --update-refs=head to leave such local\nbranches unchanged as well.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  59 ++-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 611 +++++++++++++++++++++\n object.h                         |   1 +\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 884 +++++++++++++++++++++++++++++++\n 8 files changed, 1560 insertions(+), 2 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 81f80a9274..e2a3487778 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -59,6 +59,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a ref points\n+\t\tinto the range being folded, to tell the user about\n+\t\t`--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 28b477cd37..2e2e31f521 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\n \n DESCRIPTION\n -----------\n@@ -43,8 +44,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold merges\n+that lie inside the selected range, subject to the restrictions below.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -113,6 +117,51 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's authorship and\n+\ttakes the tree of the range's newest commit, so the whole range\n+\tcollapses into a single commit. Commits above the range are replayed\n+\ton top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. Several revisions may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`. Rev-list options may also be given, but any that would\n+change how the range is walked are overridden with a warning.\n++\n+An editor opens pre-filled with the messages of all the folded commits so you\n+can combine them. With `--no-edit`, the oldest commit's message is preserved\n+instead, except that an `amend!` commit targeting it replaces its message.\n++\n+The selected commits must form a connected graph with a single tip and must\n+not include a root commit. Every parent of a commit after the oldest one must\n+either be selected or also be a parent of the oldest commit. When the oldest\n+commit is a merge, all of its parents are preserved in the squashed commit.\n++\n+A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n+targets is also in the range, so the fold does not silently absorb a\n+marker meant for a commit outside it. As an exception, a range made up entirely\n+of markers for one target is combined into a single commit. With `--no-edit`,\n+the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n+otherwise refused if folding it would discard its message.\n++\n+The editor template mirrors `git rebase -i --autosquash`: each `fixup!`,\n+`squash!`, or `amend!` is grouped under the commit it targets rather than\n+shown in commit order. A `fixup!` message is dropped (commented out in full),\n+a `squash!` keeps its body with only the marker subject commented, and an\n+`amend!` replaces its target's message, unless a `squash!` folded into that\n+target first, in which case it keeps its body like a `squash!`.\n++\n+A local branch descended from a selected commit but not from the range tip\n+cannot be rewritten as a descendant of the result, so with the default\n+`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n+to rewrite only the current branch and leave such branches unchanged. Tags\n+and remote-tracking refs are always left unchanged.\n+\n OPTIONS\n -------\n \n@@ -122,6 +171,12 @@ OPTIONS\n \tobjects will be written into the repository, so applying these printed\n \tref updates is generally safe.\n \n+`--edit`::\n+`--no-edit`::\n+\tFor `squash`, open an editor to combine the messages of the folded commits.\n+\tThis is the default; use `--no-edit` to keep the selected message without\n+\topening an editor.\n+\n `--reedit-message`::\n \tOpen an editor to modify the target commit's message.\n \ndiff --git a/advice.c b/advice.c\nindex 63bf8b0c5f..401d047391 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 66f6cd6a77..3f0b4f0485 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex a60e8dbcd0..4c1f089d9f 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -16,6 +17,7 @@\n #include \"path.h\"\n #include \"read-cache.h\"\n #include \"refs.h\"\n+#include \"ref-filter.h\"\n #include \"replay.h\"\n #include \"reset.h\"\n #include \"revision.h\"\n@@ -34,6 +36,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -1004,6 +1008,611 @@ out:\n \treturn ret;\n }\n \n+/*Remember to update object flag allocation in object.h */\n+#define SQUASH_SEEN (1u << 11)\n+#define SQUASH_TIP (1u << 12)\n+#define SQUASH_AMEND_TARGET (1u << 13)\n+\n+static bool is_autosquash_subject(const char *s)\n+{\n+\treturn starts_with(s, \"amend!\") || starts_with(s, \"fixup!\") ||\n+\t\tstarts_with(s, \"squash!\");\n+}\n+\n+static bool skip_one_autosquash_prefix(const char *s, const char **out)\n+{\n+\tif (skip_prefix(s, \"amend!\", out) || skip_prefix(s, \"fixup!\", out) ||\n+\t    skip_prefix(s, \"squash!\", out)) {\n+\t\twhile (**out == ' ')\n+\t\t\t(*out)++;\n+\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static void truncate_message_to_subject(struct strbuf *msg)\n+{\n+\tconst char *eos = strstr(msg->buf, \"\\n\\n\");\n+\n+\tif (eos)\n+\t\tstrbuf_setlen(msg, eos - msg->buf + 1);\n+}\n+\n+struct subject_data {\n+\tstruct strintmap subjects;\n+\tstruct strbuf subject;\n+\tstruct strbuf squash_message;\n+\tconst char *message;\n+\tbool edit_message;\n+};\n+\n+#define SUBJECT_DATA_INIT {\t\t\\\n+\t.subjects = STRINTMAP_INIT,\t\\\n+\t.subject = STRBUF_INIT,\t\t\\\n+\t.squash_message = STRBUF_INIT,\t\\\n+}\n+\n+static void subject_data_clear(struct subject_data *data)\n+{\n+\tstrintmap_clear(&data->subjects);\n+\tstrbuf_release(&data->subject);\n+\tstrbuf_release(&data->squash_message);\n+}\n+\n+static int squash_amend_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (!*body) {\n+\t\twarning(_(\"ignoring %s (%s): message body is empty\"),\n+\t\t\trepo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\tDEFAULT_ABBREV),\n+\t\t\tdata->subject.buf);\n+\t\treturn 0;\n+\t}\n+\n+\tif (data->edit_message) {\n+\t\treturn 0;\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\"))\n+\t\t\treturn error(_(\"squashing %s (%s) would overwrite \"\n+\t\t\t\t       \"'squash!' message, please combine them \"\n+\t\t\t\t       \"using '--edit'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\"))\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"amend!\", 5);\n+\t\tif (starts_with(data->squash_message.buf, \"amend!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t} else {\n+\t\t\tstrbuf_reset(&data->squash_message);\n+\t\t}\n+\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--edit'\"),\n+\t\t\t     repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t     data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (data->edit_message) {\n+\t\treturn 0;\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"squash\", 6);\n+\t\t}\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\")) {\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t} else {\n+\t\t\treturn error(_(\"squashing %s (%s) would discard its \"\n+\t\t\t\t       \"message, please combine them using \"\n+\t\t\t\t       \"'--edit'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\t}\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--edit'\"),\n+\t\t\t      repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t      DEFAULT_ABBREV),\n+\t\t\t      data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_check_can_autosquash(struct repository *repo,\n+\t\t\t\t       struct commit *commit,\n+\t\t\t\t       struct subject_data *data,\n+\t\t\t\t       unsigned flags)\n+{\n+\tcommit->object.flags |= flags & SQUASH_AMEND_TARGET;\n+\tif (starts_with(data->subject.buf, \"amend!\"))\n+\t\treturn squash_amend_message(repo, commit, data, flags);\n+\telse if (starts_with(data->subject.buf, \"squash!\"))\n+\t\treturn squash_squash_message(repo, commit, data, flags);\n+\n+\treturn 0;\n+}\n+\n+static int squash_check_autosquash_subject(struct repository *repo,\n+\t\t\t\t\t   struct commit *commit,\n+\t\t\t\t\t   struct subject_data *data)\n+{\n+\tconst char* s = data->subject.buf;\n+\tstruct commit *target;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\t/* Try skipping autosquash prefixes one at a time to allow\n+\t * squashing\n+\t *     a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * where we may have started with\n+\t *     a commit\n+\t *     fixup! a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * and squashed the first fixup separately from the second\n+\t */\n+\twhile (skip_one_autosquash_prefix(s, &s)) {\n+\t\tunsigned flags = strintmap_get(&data->subjects, s);\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit, data, flags);\n+\t}\n+\t/*\n+\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n+\t * \"fixup! main\". If the target is not being squshed check the subject\n+\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n+\t * squashed together.\n+\t */\n+\ttarget = lookup_commit_reference_by_name(s);\n+\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n+\t\tunsigned flags =\n+\t\t\ttarget->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);\n+\t\tif (!flags) {\n+\t\t\tconst char *subject_start;\n+\t\t\tconst char *buffer = repo_logmsg_reencode(repo, target,\n+\t\t\t\t\t\t\t\t  NULL, NULL);\n+\t\t\tsize_t subject_len = find_commit_subject(buffer,\n+\t\t\t\t\t\t\t\t &subject_start);\n+\t\t\tchar *subject = xmemdupz(subject_start, subject_len);\n+\n+\t\t\tflags = strintmap_get(&data->subjects, subject);\n+\t\t\tfree(subject);\n+\t\t\trepo_unuse_commit_buffer(repo, target, buffer);\n+\t\t}\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t   data, flags);\n+\t}\n+\t/* Try subject prefix matches */\n+\tstrintmap_for_each_entry(&data->subjects, &iter, entry) {\n+\t\ts = data->subject.buf;\n+\t\twhile(skip_one_autosquash_prefix(s, &s)) {\n+\t\t\tif (starts_with(entry->key, s)) {\n+\t\t\t\tunsigned value = (intptr_t)entry->value;\n+\n+\t\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t\t   data, value);\n+\t\t\t}\n+\t\t}\n+\t}\n+\treturn error(_(\"cannot squash %s (%s): its target is not being \"\n+\t\t       \"squashed\"),\n+\t\t       repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t       DEFAULT_ABBREV),\n+\t\t       data->subject.buf);\n+}\n+\n+static int squash_check_subject(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data)\n+{\n+\tint ret = 0;\n+\tconst char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\tsize_t subject_len = find_commit_subject(buf, &data->message);\n+\n+\tstrbuf_reset(&data->subject);\n+\tstrbuf_add(&data->subject, data->message, subject_len);\n+\n+\tif (!strintmap_get_size(&data->subjects)) {\n+\t\tconst char *s;\n+\n+\t\tstrbuf_addstr(&data->squash_message, data->message);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t/*\n+\t\t * Strip a single autosquash prefix to allow squashing\n+\t\t *     fixup! base\n+\t\t *     amend! base\n+\t\t */\n+\t\ts = data->subject.buf;\n+\t\tskip_one_autosquash_prefix(s, &s);\n+\t\tstrintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);\n+\t\tcommit->object.flags |= SQUASH_AMEND_TARGET;\n+\t} else if (is_autosquash_subject(data->subject.buf)) {\n+\t\tret = squash_check_autosquash_subject(repo, commit, data);\n+\t} else {\n+\t\tstrintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);\n+\t}\n+\trepo_unuse_commit_buffer(repo, commit, buf);\n+\treturn ret;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tconst struct strbuf *todo_buf,\n+\t\t\t\tstruct strbuf *out);\n+\n+/*\n+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n+ * the range (which becomes the parent of the squashed commit), the oldest\n+ * commit contained in the range (whose message the squash reuses), and the\n+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n+ * but the range must have a single base and must not reach a root commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tbool update_branches,\n+\t\t\t\tbool edit_message,\n+\t\t\t\tint argc, const char **argv,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tchar **message_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n+\tstruct commit *commit, *oldest = NULL, *tip = NULL;\n+\tstruct strbuf todo_buf = STRBUF_INIT;\n+\tsize_t i;\n+\tint ret, tip_count = 0;\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array refs = { 0 };\n+\n+\trepo_init_revisions(repo, &revs, NULL);\n+\tsubject_data.edit_message = edit_message;\n+\trevs.reverse = 1;\n+\trevs.topo_order = 1;\n+\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\trevs.simplify_history = 0;\n+\trevs.ancestry_path = 1;\n+\trevs.limited = 1;\n+\trevs.ancestry_path_implicit_bottoms = 1;\n+\n+\targc = setup_revisions(argc, argv, &revs, NULL);\n+\tif (argc > 1) {\n+\t\tret = error(_(\"unrecognized argument: %s\"), argv[1]);\n+\t\tgoto out;\n+\t}\n+\n+\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n+\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n+\t    revs.simplify_history != 0 || revs.boundary == 1 ||\n+\t    revs.ancestry_path != 1 || revs.limited != 1 ||\n+\t    revs.ancestry_path_implicit_bottoms != 1) {\n+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n+\t\t\t  \"range is walked\"));\n+\t\trevs.reverse = 1;\n+\t\trevs.topo_order = 1;\n+\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\t\trevs.simplify_history = 0;\n+\t\trevs.boundary = 0;\n+\t\trevs.ancestry_path = 1;\n+\t\trevs.limited = 1;\n+\t\trevs.ancestry_path_implicit_bottoms = 1;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (i = 0; i < revs.cmdline.nr; i++)\n+\t\tif (revs.cmdline.rev[i].flags & BOTTOM)\n+\t\t\tbreak;\n+\tif (i == revs.cmdline.nr) {\n+\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tstruct commit_list *p;\n+\n+\t\tif (edit_message)\n+\t\t\tstrbuf_addf(&todo_buf, \"pick %s\\n\",\n+\t\t\t\t    oid_to_hex(&commit->object.oid));\n+\n+\t\tif (!commit->parents) {\n+\t\t\tret = error(_(\"cannot squash down to root commit\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t\tfor (p = commit->parents; oldest && p; p = p->next) {\n+\t\t\tstruct commit_list *q;\n+\t\t\tstruct object *o;\n+\t\t\tbool seen;\n+\n+\t\t\tif (repo_parse_commit(repo, p->item)) {\n+\t\t\t\tret = error(_(\"cannot parse commit\"));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\to = &p->item->object;\n+\t\t\tseen = o->flags & SQUASH_SEEN;\n+\t\t\t/*\n+\t\t\t * Allow parents that match the parents of the\n+\t\t\t * squashed commit.\n+\t\t\t */\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next) {\n+\t\t\t\tif (p->item == q->item) {\n+\t\t\t\t\tseen = true;\n+\t\t\t\t\tcommit_list_insert(commit, &filter.with_commit);\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tif (!seen) {\n+\t\t\t\tret = error(_(\"parent %s of commit %s is \"\n+\t\t\t\t\t      \"outside the revision range\"),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo, &o->oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tif (o->flags & SQUASH_TIP) {\n+\t\t\t\ttip_count--;\n+\t\t\t\to->flags &= ~SQUASH_TIP;\n+\t\t\t}\n+\t\t}\n+\t\tif (!oldest) {\n+\t\t\tcommit_list_insert(commit, &filter.with_commit);\n+\t\t\toldest = commit;\n+\t\t}\n+\t\tif (squash_check_subject(repo, commit, &subject_data)) {\n+\t\t\tret = -1;\n+\t\t\tgoto out;\n+\t\t}\n+\t\ttip = commit;\n+\t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n+\t\ttip_count++;\n+\t}\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP | SQUASH_AMEND_TARGET);\n+\treset_revision_walk();\n+\tif (!tip_count) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t} else if (tip_count != 1) {\n+\t\t  ret = error(_(\"the revision range contains more than one tip \"\n+\t\t\t\t\"commit\"));\n+\t\t  goto out;\n+\t  } else if (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t} else if (!oldest->parents) {\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\t}\n+\tcommit_list_insert(tip, &filter.no_commit);\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tif (update_branches &&\n+\t    filter_refs(&refs, &filter, filter.kind)) {\n+\t\tret = error(_(\"could not filter refs\"));\n+\t\tgoto out;\n+\t}\n+\tif (refs.nr) {\n+\t\t/*\n+\t\t * TODO: list the branches and also check HEADS from other worktrees\n+\t\t */\n+\t\tret = error(_(\"a branch points to a commit that is being squashed\"));\n+\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t    \"the current branch and leave such refs \"\n+\t\t\t\t    \"untouched.\"));\n+\t\tgoto out;\n+\t}\n+\tif (edit_message) {\n+\t\tstrbuf_reset(&subject_data.squash_message);\n+\t\tret = build_squash_message(repo, &todo_buf,\n+\t\t\t\t\t   &subject_data.squash_message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\t*message_out = strbuf_detach(&subject_data.squash_message, NULL);\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&todo_buf);\n+\tsubject_data_clear(&subject_data);\n+\trelease_revisions(&revs);\n+\tref_filter_clear(&filter);\n+\tref_array_clear(&refs);\n+\treturn ret;\n+}\n+\n+static bool amend_replaces_target(struct todo_list *todo, int target)\n+{\n+\tint i;\n+\n+\tfor (i = target + 1; i < todo->nr &&\n+\t\t\t     todo->items[i].command != TODO_PICK; i++) {\n+\t\tif (todo->items[i].command == TODO_SQUASH)\n+\t\t\treturn false;\n+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tconst struct strbuf *todo_buf,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tint i, nr_commits, ret;\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo_buf->buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not prepare the squash message\"));\n+\t\tgoto out;\n+\t}\n+\n+\tnr_commits = todo.nr;\n+\tfor (i = 0; i < nr_commits; i++) {\n+\t\tstruct todo_item *item = &todo.items[i];\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tbool skip, squashing;\n+\n+\t\tsquashing = item->command == TODO_SQUASH ||\n+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n+\t\tif (item->command == TODO_PICK)\n+\t\t\tskip = amend_replaces_target(&todo, i);\n+\t\telse\n+\t\t\tskip = !squashing;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tif (skip)\n+\t\t\tcommented_len = strlen(body);\n+\t\telse if (squashing)\n+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n+\t\telse\n+\t\t\tcommented_len = 0;\n+\n+\t\tif (!i)\n+\t\t\tadd_squash_combination_header(out, nr_commits);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, i + 1, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treturn ret;\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tint dry_run = 0;\n+\tint edit = 1;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BOOL('e', \"edit\", &edit,\n+\t\t\t N_(\"edit the commit message\")),\n+\t\tOPT_END(),\n+\t};\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct commit *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tchar *message_template = NULL;\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage,\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n+\tif (argc < 2) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n+\tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n+\n+\tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n+\t\t\t\t   edit,\n+\t\t\t\t   argc, argv, &oldest, &tip,\n+\t\t\t\t   &message_template);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo,\n+\t\t\t\t\toldest->parents->item)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n+\t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n+\t\t\t      &rewritten,\n+\t\t\t      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\trelease_revisions(&revs);\n+\tfree(message_template);\n+\treturn ret;\n+}\n+\n static int update_worktree(struct repository *repo,\n \t\t\t   const struct commit *old_head,\n \t\t\t   const struct commit *new_head,\n@@ -1192,6 +1801,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -1200,6 +1810,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/object.h b/object.h\nindex 8fb03ff90a..46cade33fb 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,6 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n+ * builtin/history.c:                    11---13\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/meson.build b/t/meson.build\nindex a25f37d2f5..78b8ea54ad 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -406,6 +406,7 @@ integration_tests = [\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n   't3454-history-drop.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..8638d56dd6\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,884 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+stage_file () {\n+\tprintf \"%s\\n\" \"$1\" >file &&\n+\tgit add file\n+}\n+\n+commit_with_message () {\n+\tprintf \"%b\" \"$1\" >msg &&\n+\tgit commit --allow-empty -qF msg\n+}\n+\n+check_commit_count () {\n+\tgit rev-list --count \"$1\" >actual &&\n+\techo \"$2\" >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_subjects () {\n+\tgit log --format=\"%s\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_messages () {\n+\tgit log --format=\"%B\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+# Checks that the author data of two commits matches\n+# Usage: check_commit_author <rev1> <rev2>\n+check_commit_author () {\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$1\" >expect &&\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$2\" >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'setup linear history touching two files' '\n+\ttest_commit base file a start &&\n+\tGIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \\\n+\t\ttest_commit one other x &&\n+\tGIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \\\n+\t\ttest_commit two file c &&\n+\tGIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \\\n+\t\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'rejects root commit' '\n+\t# create a disconnected root commit\n+\toid=$(git commit-tree -m root three^{tree}) &&\n+\t# because we pass --ancestry-path when calling setup_revs() it the\n+\t# revision walk will only include commits decended from $oid so\n+\t# we need to give it another --ancestry-path commit to actually walk\n+\t# any commits.\n+\ttest_must_fail git history squash --ancestry-path=start $oid..three 2>err &&\n+\techo \"error: cannot squash down to root commit\" >expect &&\n+\ttest_cmp expect err\n+'\n+\n+test_expect_success 'rejects multiple tips' '\n+\toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n+\ttest_must_fail git history squash ^start $oid three~1 2>err &&\n+\techo \"error: the revision range contains more than one tip commit\" >expect &&\n+\ttest_cmp expect err &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit ^start three~1 three &&\n+\ttest_cmp_rev HEAD~1 start^0 &&\n+\ttest_cmp_rev HEAD^{tree} three^{tree}\n+'\n+\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..HEAD ^keep &&\n+\n+\tgit reflog -1 --format=%gs >actual &&\n+\techo \"squash: updating start..HEAD ^keep\" >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n+\tgit checkout \"$main\" &&\n+\n+\tgit history squash --no-edit start..off-history &&\n+\n+\tcheck_commit_count start..off-history 1 &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tone\n+\tEOF\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'sanitizes rev-list walk options, before and after --' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --date-order start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit -- --reverse start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..@~1 &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$root..\" &&\n+\n+\tcheck_commit_count \"$root..HEAD\" 1 &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashing a mix of fixups' '\n+\tgit reset --hard three &&\n+\techo fix >file &&\n+\tgit commit --fixup=two -a &&\n+\techo really fix >file &&\n+\tgit commit --fixup=one -a &&\n+\techo really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! two\n+\techo really really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! one\n+\n+\t# squashing fixup! with a target that is not being squashed fails\n+\ttest_must_fail git history squash one.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing fixup! into fixup! with a different target fails\n+\ttest_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing a sequence of fixup! commits into their targets\n+\tgit history squash --no-edit start..HEAD~1 &&\n+\ttest_cmp_rev start HEAD~2 &&\n+\tcheck_commit_author one HEAD~1 &&\n+\ttest_commit_message HEAD~1 -m one &&\n+\n+\t# squashing \"fixup! fixup! <target>\" into \"<target>\"\n+\tgit history squash --no-edit start.. &&\n+\ttest_cmp_rev start HEAD~1 &&\n+\tcheck_commit_author one HEAD &&\n+\ttest_commit_message HEAD -m one\n+'\n+\n+test_expect_success 'squashing \"squash!\" messages' '\n+\tgit reset --hard two &&\n+\techo fix >file &&\n+\tgit commit --fixup=HEAD -a &&\n+\toldest=$(git rev-parse HEAD) &&\n+\techo better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! $(git rev-parse two)\n+\n+\tAppend this\n+\tEOF\n+\n+\techo an even better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! squash! two\n+\n+\tAppend this as well\n+\tEOF\n+\n+\t# must edit when squashing \"squash!\" into its target\n+\ttest_must_fail git history squash --no-edit two^.. 2>err &&\n+\ttest_grep \"^error: squashing .* (squash! [a-f0-9]*) would discard its message\" err &&\n+\n+\t# squashing \"squash!\" into \"fixup!\" appends messages and changes\n+\t# subject prefix\n+\tgit history squash --no-edit two.. &&\n+\ttest_cmp_rev HEAD^ two &&\n+\ttest_commit_message HEAD <<-\\EOF &&\n+\tsquash! two\n+\n+\tAppend this\n+\n+\tAppend this as well\n+\tEOF\n+\tcheck_commit_author \"$oldest\" HEAD &&\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! two\n+\n+\tA new message\n+\tEOF\n+\n+\t# \"amend!\" does not replace \"squash!\"\n+\ttest_must_fail git history squash --no-edit HEAD~2.. 2>err &&\n+\ttest_grep \"^error: squashing .* (amend! two) would overwrite .squash!. message\" err\n+'\n+\n+test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n+\tgit reset --hard three &&\n+\twrite_script editor <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\techo fix >file &&\n+\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n+\tgit commit --allow-empty -F - <<-EOF &&\n+\tamend! $(git rev-parse --short HEAD)\n+\n+\tThe first reword\n+\n+\tMore detail\n+\tEOF\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! three\n+\n+\tThe second reword\n+\n+\tExtra detail\n+\tEOF\n+\n+\ttest_commit WIP &&\n+\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n+\n+\tThe third reword\n+\n+\tExcruciating detail\n+\tEOF\n+\n+\tgit commit --author=\"Someone Else <s.else@example.com>\" --allow-empty \\\n+\t\t-F msg &&\n+\n+\t# squashing amend! updates the commit message\n+\tgit history squash --no-edit three^.. &&\n+\tsed -e 1,2d msg | test_commit_message HEAD &&\n+\tcheck_commit_author three HEAD &&\n+\ttest_cmp_rev HEAD^ three^ &&\n+\n+\t# squashing amend! into fixup! updates subject prefix\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash --no-edit three.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\tcheck_commit_author HEAD@{1}~4 HEAD &&\n+\ttest_cmp_rev HEAD^ three &&\n+\n+\t# squashing amend! into amend! keeps original subject line\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash --no-edit HEAD~3.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\ttest_cmp_rev HEAD~3 three &&\n+\n+\t# all amend! messages must target the first commit\n+\tgit reset --hard HEAD@{1} &&\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! WIP\n+\n+\tThe real message\n+\tEOF\n+\n+\ttest_must_fail git history squash --no-edit HEAD~4.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* that does not target\" err &&\n+\n+\t# amend! message that targets commit that is not in range is rejected\n+\ttest_must_fail git history squash --no-edit HEAD~3.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* target is not being squashed\" err &&\n+\ttest_set_editor :\n+'\n+\n+test_expect_success 'squashing fixups into a merge' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f start &&\n+\ttest_commit F1 &&\n+\tgit checkout -b feature start &&\n+\ttest_commit F2 &&\n+\tgit merge F1 &&\n+\techo fixed >F1.t &&\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse HEAD)\n+\n+\tmerge F1 and F2\n+\n+\treworded\n+\tEOF\n+\n+\tgit commit -a -F msg &&\n+\tgit history squash --no-edit HEAD^^! HEAD &&\n+\ttest_cmp_rev HEAD^1 F2 &&\n+\ttest_cmp_rev HEAD^2 F1 &&\n+\ttest_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&\n+\tsed 1,2d msg | test_commit_message HEAD\n+'\n+\n+test_expect_success 'edits every message and aborts on an empty result' '\n+\tgit reset --hard start &&\n+\tstage_file b &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script empty-editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/empty-editor\" &&\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"Aborting commit due to empty commit message\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tcombined\n+\tEOF\n+'\n+\n+test_expect_success 'handles fixup!, squash! and amend! like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\tstage_file e &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tmark-base\n+\n+\tsquash remark\n+\n+\tamended message\n+\n+\tEOF\n+'\n+\n+test_expect_success 'groups fixups under their targets in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag alpha file a1 &&\n+\ttest_commit --no-tag beta file b1 &&\n+\tstage_file a2 &&\n+\tcommit_with_message \"fixup! alpha\\n\" &&\n+\tstage_file b2 &&\n+\tcommit_with_message \"fixup! beta\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\talpha\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! alpha\n+\n+\t# This is the commit message #3:\n+\n+\tbeta\n+\n+\t# The commit message #4 will be skipped:\n+\n+\t# fixup! beta\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited\n+'\n+\n+test_expect_success 'lets amend! replace its target message in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# The 1st commit message will be skipped:\n+\n+\t# mark-base\n+\n+\t# This is the commit message #2:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended message\n+\n+\tsquash remark\n+\n+\tEOF\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a branch points into' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f -b feature start &&\n+\ttest_commit C1 &&\n+\ttest_commit C2 &&\n+\tgit checkout -b topic-1 start &&\n+\ttest_commit C3 &&\n+\ttest_commit C4 &&\n+\tgit checkout C3 &&\n+\ttest_commit C5 &&\n+\tgit checkout feature &&\n+\tgit merge C5 &&\n+\ttest_commit C6 &&\n+\tgit checkout -b topic-2 C2 &&\n+\ttest_commit C7 &&\n+\tgit checkout feature &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\t# TODO: check the branch names when we print them (topic-1 & topic-2)\n+\ttest_grep \"^error: a branch points to\" err &&\n+\ttest_grep \"^hint: .* --update-refs=head\" err &&\n+\ttest_cmp_rev C6 HEAD &&\n+\n+\t# squash succeeds with --update-refs=head\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest_cmp_rev C6^{tree} HEAD^{tree} &&\n+\ttest_cmp_rev C6 HEAD@{1}\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"^error: a branch points to\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D mid\n+'\n+\n+test_expect_success 'leaves tags and remote-tracking refs unchanged' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\tgit update-ref refs/remotes/origin/mark HEAD~1 &&\n+\tmark_before=$(git rev-parse mark) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest_cmp_rev \"$mark_before\" mark &&\n+\ttest_cmp_rev \"$mark_before\" refs/remotes/origin/mark &&\n+\n+\tgit tag -d mark &&\n+\tgit update-ref -d refs/remotes/origin/mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tbefore-side\n+\tEOF\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$base..\" &&\n+\n+\tcheck_commit_count \"$base..HEAD\" 1 &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash --no-edit start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\t# TODO: test for branch nome \"at-merge\"\n+\ttest_grep \"a branch points to a commit\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n+test_done\n-- \ngitgitgadget\n"},{"id":"549551","messageId":"49186d54-f7a4-40ff-a88f-62d9f44fdbcd@gmail.com","threadId":"65812","inReplyTo":"CAHwyqnX8Api2VWqaDt4vgnG5P9RHGkK2Bhhi4dVAu7Qrh908rw@mail.gmail.com","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-04T09:36:31Z","receivedAt":"2026-08-04T09:36:39Z","isPatch":true,"body":"Hi Harald\n\nOn 03/08/2026 17:35, Harald Nordgren wrote:\n>> These patches can be fetched with\n>>\n>>    git fetch https://github.com/phillipwood/git wip/hn/history-squash/v10-early-part\n>>\n>> note that they do not support editing the commit message of the\n>> squashed commit.\n> \n> Thanks, what should I do with it?\n> \n> Is it a replacement for my branch, or should it apply them as fixups\n> (if so how do we handle the lack of re-edit support)?\n\nI'd start by reading through them and asking any questions you have \nabout the implementation or the reasoning behind it. Before you send a \nnew version patch 3.5 needs some work to print the branch names (that \nshouldn't be difficult the ref-filter has functions to handle sorting \nand printing the contents of the ref_array) and patch 3.6 needs tweaking \nto work with editing the commit message (I'm afraid I haven't given that \nany thought yet). We should also think about how to split the series up \nto make it easier to review: the last patch in v12 is over 1500 lines of \nnew code and tests. It would be better if we could split it into a few steps\n\n1 - add the skeleton command with option parsing and the checks for\n     rev-list arguments and BOTTOM commits.\n2 - walk the revs and add all the error checks for multiple tips, merges\n     outside the range, root commits etc.\n3 - add the check for branches that point into the range.\n4 - add the code that creates the new commit and its message when it is\n     not being edited.\n5 - add support for editing the commit message.\n\nThat makes each logical step easier to review and gives you the \nopportunity to explain the rationale for the design and implementation \nof each step in the commit messages.\n\nI see you've sent another version already - it would have been helpful \nto wait for a response to this message first. The mailing list is \nasynchronous so you wont always receive an immediate response to your \nmessages. Each time you send a new version you should think what it is \nyou want to achieve from that iteration. Simply sending a series of what \nare in effect works-in-progress without any indication of what feedback \nyou are looking for just clutters the list and makes it hard for \nreviewers to keep up.\n\nThanks\n\nPhillip\n\n\n"},{"id":"549567","messageId":"xmqqqzkevx62.fsf@gitster.g","threadId":"65812","inReplyTo":"CAHwyqnX8Api2VWqaDt4vgnG5P9RHGkK2Bhhi4dVAu7Qrh908rw@mail.gmail.com","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-04T13:21:25Z","receivedAt":"2026-08-04T13:21:29Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> writes:\n\n>> These patches can be fetched with\n>>\n>>   git fetch https://github.com/phillipwood/git wip/hn/history-squash/v10-early-part\n>>\n>> note that they do not support editing the commit message of the\n>> squashed commit.\n>\n> Thanks, what should I do with it?\n>\n> Is it a replacement for my branch, or should it apply them as fixups\n> (if so how do we handle the lack of re-edit support)?\n\nI am not Phillip, but in light of what I said elsewhere, I would\nexpect you to review them as if they were patch submissions to your\ntree.  You may agree with some parts while disagreeing with others.\n\nRespond to them.\n\nRespond even to the ones you agree with, just as you would when\ngiving a positive review.  Demonstrate that you read them,\nunderstood how they work, and then state that you agree.  If you\ndisagree, explain why you think it is better without such\nchanges.  \n\nJust do not send a new iteration without a thorough explanation of\nhow you addressed each of the review comments, including those\nreview messages disguised as patches.  Be inviting to even those who\nhave not been involved in this topic so far to join the discussion,\nwhich requires you to be transparent.  Engage in dialogue.  Explain\nwhat you are doing.  Do not let patches alone explain themselves.\n\nFirst and foremost, this development community is built on humans\ncollaborating with other humans.  An author posts a patch, a\nreviewer responds with suggestions or critiques, and the author\nreplies to that e-mail.  In their own words, the author might:\n\n - build on the suggestion, rephrasing it and proposing further\n   improvements;\n\n - disagree and offer a counter-proposal;\n\n - concede the patch's shortcomings and outline how they plan to fix\n   them; or\n\n - defend their original design to give the reviewer a chance to\n   reconsider.\n\nDoing this in your own words helps reviewers see how close we are to\nan agreement.  This kind of discussion often needs a few rounds of\nback-and-forth.  It should also welcome folks watching from the\nsidelines, which means letting the globe spin at least once so\ndevelopers in other timezones can chime in before we declare a rough\nconsensus.\n\nFiring off a new iteration before there is a rough consensus on what\nthe final shape of the patches should look like makes it appear as\nthough the only thing you care about is producing the next\niteration, and that you care much less about collaborating with\nothers.\n\nThanks, Phillip, for these patches-as-reviews.\n"},{"id":"549614","messageId":"CAHwyqnXJLQ_naFb1RRQWS3eft0FXL7ripviSA15Zy5D6nvHGAQ@mail.gmail.com","threadId":"65812","inReplyTo":"xmqqqzkevx62.fsf@gitster.g","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-08-04T20:41:42Z","receivedAt":"2026-08-04T20:42:21Z","isPatch":true,"body":"Hi Phillip and Junio!\n\nI admit I’m trying to expedite the process. Especially given what\nfeels like endless review cycles, and some frustration that this topic\nwas queued for ’master’ and now it feels like we’re miles away again.\nI recognize that this mindset is not a good collaborative attitude, so\nI’ll try to step back and re-adjust my mindset.\n\nI am used to a work process where moving fast is paramount, and good\nenough is good enough in terms of code quality. I’m not seeing a\nhealthy code review process here — any feedback halts the process\ncompletely. Should it really take months to merge a topic?\n\nI realize I'm too impatient for asynchronous communication, I get very\nstressed about the idea of getting out-of-sync. When Phillip\ngraciously wrote a lot of code for me, I wanted to bring us up to\nspeed as soon as possible so his changes were not wasted if someone\nelse also started reviewing v11 in parallel. (In this particular case\nI went back to v10, applied the fixups and then fit my v11 work on top\nof that.)\n\nI have v13 already in the pipeline, but I won’t send it now.\n\n\nHarald\n"},{"id":"549616","messageId":"CAHwyqnW-py0QZNWUikzYO3yyqrYWdZcHQ+axW41GMv94hHh0nw@mail.gmail.com","threadId":"65812","inReplyTo":"CAHwyqnXJLQ_naFb1RRQWS3eft0FXL7ripviSA15Zy5D6nvHGAQ@mail.gmail.com","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-08-04T20:50:27Z","receivedAt":"2026-08-04T20:51:08Z","isPatch":true,"body":"Btw, I have now twice seen this message from Google when using Reply\nAll, which sends to Phillip's reply-to email, whereas mails are\nactually sent from phillip.wood123@gmail.com:\n\n\"Your message wasn't delivered to phillip.wood@dunlem.org.uk because\nthe domain dunlem.org.uk couldn't be found\n\nDNS Error: DNS type 'mx' lookup of dunlem.org.uk responded with code\nNXDOMAIN Domain name not found: dunlem.org.uk For more information, go\nto https://support.google.com/mail/?p=BadRcptDomain\"\n\n\nHarald\n"},{"id":"549621","messageId":"xmqq8q6ltwsb.fsf@gitster.g","threadId":"65812","inReplyTo":"CAHwyqnXJLQ_naFb1RRQWS3eft0FXL7ripviSA15Zy5D6nvHGAQ@mail.gmail.com","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-04T21:12:36Z","receivedAt":"2026-08-04T21:12:39Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> writes:\n\n> Hi Phillip and Junio!\n>\n> I admit I’m trying to expedite the process. Especially given what\n> feels like endless review cycles, and some frustration that this topic\n> was queued for ’master’ and now it feels like we’re miles away again.\n> I recognize that this mindset is not a good collaborative attitude, so\n> I’ll try to step back and re-adjust my mindset.\n>\n> I am used to a work process where moving fast is paramount, and good\n> enough is good enough in terms of code quality. I’m not seeing a\n> healthy code review process here — any feedback halts the process\n> completely. Should it really take months to merge a topic?\n\nWhen you reroll too fast without waiting for reviewers, it\ninvalidates almost-done-but-yet-unpublished reviews.  If you\nrepeatedly do so, it exacerbates the problem by discouraging\nreviewers from even looking at your topic, as they wonder if a\nrapid reroll will invalidate their reviews yet again.\n\nIf it is taking months, I suspect that half of that is due to your\nrapid rerolls, which have discouraged people from engaging, as they\ncannot make a time commitment to a bottomless time sink.  The summer\nbeing a relatively slow season certainly has played a role, too.\n\nThanks.\n"},{"id":"549811","messageId":"665321c0-b73a-479f-8c03-73ddc9e66e98@app.fastmail.com","threadId":"65812","inReplyTo":"CAHwyqnXJLQ_naFb1RRQWS3eft0FXL7ripviSA15Zy5D6nvHGAQ@mail.gmail.com","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-08-06T09:19:27Z","receivedAt":"2026-08-06T09:19:55Z","isPatch":true,"body":"On Tue, Aug 4, 2026, at 22:41, Harald Nordgren wrote:\n> I admit I’m trying to expedite the process. Especially given what\n> feels like endless review cycles, and some frustration that this topic\n> was queued for ’master’ and now it feels like we’re miles away again.\n> I recognize that this mindset is not a good collaborative attitude, so\n> I’ll try to step back and re-adjust my mindset.\n\nCollaboration is at the heart of wanting to integrate your changes with\nthe main Git tree.\n\nYou already know that you can have this extension for yourself right\nnow, in fact immediately after you were happy with it. But you want to\nget it into the main Git tree as well. Maybe because you don’t want to\ndeal with updates to the surrounding context as you have to update your\ntree according to the main Git tree. Maybe because you, for example,\nknow that someone else is working on another subcommand. And all of that\nis about collaboration, just one level higher (or one step further?).\n\n>[snip]\n"},{"id":"549950","messageId":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.git.git.1781465141.gitgitgadget@gmail.com","subject":"[PATCH v13 0/8] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:23Z","receivedAt":"2026-08-07T07:39:34Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v13:\n\n * Split the squash implementation into five patches covering parsing, range\n   validation, branch protection, commit creation, and message editing.\n * Print the sorted names of local branches that prevent a squash, with\n   clearer advice for --update-refs=head.\n * Isolate --no-edit behavior from the final patch that enables default\n   editor-based message combining.\n\nChanges in v12:\n\n * Incorporated fixups from Phillip:\n   * Reworks range validation into a single walk that rejects roots and\n     multiple tips while preserving every parent when squashing into a\n     merge.\n   * Resolves fixup!, squash! and amend! targets directly, preserving\n     message intent and safely consolidating related markers.\n   * Builds the editor template from the exact selected revisions, including\n     exclusions, while retaining --no-edit behavior.\n   * Protects descendant local branches while leaving tags and\n     remote-tracking refs unchanged.\n\nChanges in v11:\n\n * Make message editing the default with the autosquash-style template, add\n   --no-edit instead of squash-specific --reedit-message.\n * Validate one actual boundary and tip, rejecting root-reaching and\n   multi-tip ranges.\n * Protect only interior local branches, leaving tags and remote-tracking\n   refs unchanged.\n * Move sequencer preparation before squash and fold the later\n   message-editing patch into the feature commit.\n\nChanges in v10:\n\n * Record the full revision expression in squash reflog.\n * Preserve the boundary-walk invariant when sanitizing rev-list options.\n * Clarify amend! and --reedit-message documentation.\n\nChanges in v9:\n\n * Use the last amend! targeting the oldest folded commit as the default\n   squashed message. Ignore amend! markers targeting later commits while\n   selecting that replacement message.\n * Improve tests.\n\nChanges in v8:\n\n * --reedit-message now builds the same editor template as git rebase -i\n   --autosquash: fixup!, squash! and amend! commits are grouped under the\n   commit they target instead of shown in commit order, and an amend!\n   replaces its target's message.\n * A fixup!, squash! or amend! is refused only when its target is outside\n   the range, so several fixups for an in-range commit fold together. A\n   range that is entirely markers for one below-range target is combined\n   into a single commit, keeping the last amend! message.\n * Merges inside the range are folded when the range has a single base, with\n   no dedicated opt-in flag, --ancestry-path ensures only commits descended\n   from the base are folded, and a range reaching more than one base is\n   rejected.\n * Rev-list options are accepted and sanitized the way git replay does,\n   forcing the walk order back with a warning, which also fixes git history\n   squash -- --reverse slipping past the previous option check.\n * Kept this as an explicit squash subcommand rather than making\n   --reedit-message the default or renaming the command.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (8):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  sequencer: share the squash message marker helpers and flags\n  history: add skeleton for squash subcommand\n  history: validate squash revision ranges\n  history: protect branches when squashing a range\n  history: create squashed commits without editing\n  history: support editing squashed commit messages\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  59 +-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 720 +++++++++++++++++++++++--\n object.h                         |   1 +\n sequencer.c                      |  70 +--\n sequencer.h                      |  30 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 886 +++++++++++++++++++++++++++++++\n 10 files changed, 1702 insertions(+), 71 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 2c78326f810173a4f3aefd8021f1e07575412481\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v13\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v13\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v12:\n\n 1:  20e050a0ad = 1:  b175bdca54 history: extract helper for a commit's parent tree\n 2:  b99867a0d4 = 2:  965074f9e8 history: give commit_tree_ext a message template\n 3:  352db997e9 = 3:  cdbdd766b2 sequencer: share the squash message marker helpers and flags\n -:  ---------- > 4:  f7d953b081 history: add skeleton for squash subcommand\n -:  ---------- > 5:  dbbf66ba02 history: validate squash revision ranges\n -:  ---------- > 6:  e71a8adfbe history: protect branches when squashing a range\n 4:  33d3eca6db ! 7:  8b3551d0d4 history: add squash subcommand to fold a range\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    history: add squash subcommand to fold a range\n     +    history: create squashed commits without editing\n      \n     -    Folding a series of commits into one required either an interactive\n     -    rebase where each commit after the first was hand-edited to \"fixup\", or\n     -    a \"git reset --soft\" to the merge base followed by \"git commit --amend\".\n     +    Create one replacement commit from the resolved range when --no-edit is\n     +    selected. Preserve the authorship and all parents of the oldest commit,\n     +    use the tip tree, and replay descendants through the existing history\n     +    rewrite machinery. Record the complete revision expression in the\n     +    reflog and retain dry-run and update-refs behavior.\n      \n     -    Add \"git history squash <revision-range>\" to do this directly. It folds\n     -    every selected commit into one, preserving the authorship and parents of\n     -    the oldest commit and taking the tree of the tip, then replays commits\n     -    above the range. By default an editor opens with every folded message in\n     -    the same template used by \"git rebase -i --autosquash\". With --no-edit,\n     -    the selected message is used without opening an editor.\n     -\n     -    Resolve fixup!, squash! and amend! subjects directly while walking the\n     -    selected commits. A marker is rejected unless its fixed target is also\n     -    selected, except that a range made up entirely of related markers can be\n     -    consolidated. Without editing, refuse any squash! or amend! whose message\n     -    would be discarded.\n     -\n     -    Read the range like arguments to \"git rev-list\" and accept multiple\n     -    revisions and rev-list options. Restore the walk settings required by the\n     -    fold after setup_revisions(), warning when an option changed them. The\n     -    range must name a bottom commit, must not reach a root, and must have one\n     -    tip. Every parent after the oldest commit must be selected or also be a\n     -    parent of the oldest commit. This permits internal merges and preserves\n     -    all parents when the oldest commit is itself a merge.\n     -\n     -    By default, refuse when a local branch descends from the selected commits\n     -    but cannot remain a descendant of the result. Tags and remote-tracking\n     -    refs are left unchanged. Use --update-refs=head to leave such local\n     -    branches unchanged as well.\n     +    Resolve fixup!, squash! and amend! subjects while walking the range.\n     +    Reject markers whose targets are not selected and refuse any no-edit\n     +    fold that would discard a squash! or amend! message. A range made\n     +    entirely from related markers can still be consolidated, with the last\n     +    applicable amend! body supplying the message.\n      \n          Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n          Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     - ## Documentation/config/advice.adoc ##\n     -@@ Documentation/config/advice.adoc: all advice messages.\n     - \tforceDeleteBranch::\n     - \t\tShown when the user tries to delete a not fully merged\n     - \t\tbranch without the force option set.\n     -+\thistoryUpdateRefs::\n     -+\t\tShown when `git history squash` refuses because a ref points\n     -+\t\tinto the range being folded, to tell the user about\n     -+\t\t`--update-refs=head`.\n     - \tignoredHook::\n     - \t\tShown when a hook is ignored because the hook is not\n     - \t\tset as executable.\n     -\n       ## Documentation/git-history.adoc ##\n     -@@ Documentation/git-history.adoc: git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(\n     - git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n     - git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n     - git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n     -+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\n     - \n     - DESCRIPTION\n     - -----------\n      @@ Documentation/git-history.adoc: at once.\n       LIMITATIONS\n       -----------\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +already on `topic`. Rev-list options may also be given, but any that would\n      +change how the range is walked are overridden with a warning.\n      ++\n     -+An editor opens pre-filled with the messages of all the folded commits so you\n     -+can combine them. With `--no-edit`, the oldest commit's message is preserved\n     -+instead, except that an `amend!` commit targeting it replaces its message.\n     ++With `--no-edit`, the oldest commit's message is preserved, except that an\n     ++`amend!` commit targeting it replaces its message.\n      ++\n      +The selected commits must form a connected graph with a single tip and must\n      +not include a root commit. Every parent of a commit after the oldest one must\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n      +the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n      +otherwise refused if folding it would discard its message.\n      ++\n     -+The editor template mirrors `git rebase -i --autosquash`: each `fixup!`,\n     -+`squash!`, or `amend!` is grouped under the commit it targets rather than\n     -+shown in commit order. A `fixup!` message is dropped (commented out in full),\n     -+a `squash!` keeps its body with only the marker subject commented, and an\n     -+`amend!` replaces its target's message, unless a `squash!` folded into that\n     -+target first, in which case it keeps its body like a `squash!`.\n     -++\n      +A local branch descended from a selected commit but not from the range tip\n      +cannot be rewritten as a descendant of the result, so with the default\n      +`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].\n       OPTIONS\n       -------\n       \n     -@@ Documentation/git-history.adoc: OPTIONS\n     - \tobjects will be written into the repository, so applying these printed\n     - \tref updates is generally safe.\n     - \n     -+`--edit`::\n     -+`--no-edit`::\n     -+\tFor `squash`, open an editor to combine the messages of the folded commits.\n     -+\tThis is the default; use `--no-edit` to keep the selected message without\n     -+\topening an editor.\n     -+\n     - `--reedit-message`::\n     - \tOpen an editor to modify the target commit's message.\n     - \n     -\n     - ## advice.c ##\n     -@@ advice.c: static struct {\n     - \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n     - \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n     - \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n     -+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n     - \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n     - \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n     - \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\n     -\n     - ## advice.h ##\n     -@@ advice.h: enum advice_type {\n     - \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n     - \tADVICE_FORCE_DELETE_BRANCH,\n     - \tADVICE_GRAFT_FILE_DEPRECATED,\n     -+\tADVICE_HISTORY_UPDATE_REFS,\n     - \tADVICE_IGNORED_HOOK,\n     - \tADVICE_IMPLICIT_IDENTITY,\n     - \tADVICE_MERGE_CONFLICT,\n      \n       ## builtin/history.c ##\n     -@@\n     - #define USE_THE_REPOSITORY_VARIABLE\n     - \n     - #include \"builtin.h\"\n     -+#include \"advice.h\"\n     - #include \"cache-tree.h\"\n     - #include \"commit.h\"\n     - #include \"commit-reach.h\"\n     -@@\n     - #include \"path.h\"\n     - #include \"read-cache.h\"\n     - #include \"refs.h\"\n     -+#include \"ref-filter.h\"\n     - #include \"replay.h\"\n     - #include \"reset.h\"\n     - #include \"revision.h\"\n     -@@\n     - \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n     - #define GIT_HISTORY_SPLIT_USAGE \\\n     - \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n     -+#define GIT_HISTORY_SQUASH_USAGE \\\n     -+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\")\n     - \n     - static void change_data_free(void *util, const char *str UNUSED)\n     - {\n      @@ builtin/history.c: out:\n     - \treturn ret;\n     - }\n       \n     -+/*Remember to update object flag allocation in object.h */\n     -+#define SQUASH_SEEN (1u << 11)\n     -+#define SQUASH_TIP (1u << 12)\n     + #define SQUASH_SEEN (1u << 11)\n     + #define SQUASH_TIP (1u << 12)\n      +#define SQUASH_AMEND_TARGET (1u << 13)\n      +\n      +static bool is_autosquash_subject(const char *s)\n     @@ builtin/history.c: out:\n      +\trepo_unuse_commit_buffer(repo, commit, buf);\n      +\treturn ret;\n      +}\n     -+\n     -+static int build_squash_message(struct repository *repo,\n     -+\t\t\t\tconst struct strbuf *todo_buf,\n     -+\t\t\t\tstruct strbuf *out);\n     -+\n     -+/*\n     -+ * Resolve a \"<base>..<tip>\" revision range into the base commit just outside\n     -+ * the range (which becomes the parent of the squashed commit), the oldest\n     -+ * commit contained in the range (whose message the squash reuses), and the\n     -+ * range tip (whose tree becomes the result). A merge inside the range is fine,\n     -+ * but the range must have a single base and must not reach a root commit.\n     -+ */\n     -+static int resolve_squash_range(struct repository *repo,\n     -+\t\t\t\tbool update_branches,\n     -+\t\t\t\tbool edit_message,\n     -+\t\t\t\tint argc, const char **argv,\n     -+\t\t\t\tstruct commit **oldest_out,\n     + \n     + static int setup_squash_revisions(struct repository *repo,\n     + \t\t\t\t  int argc, const char **argv,\n     +@@ builtin/history.c: static int resolve_squash_range(struct repository *repo,\n     + \t\t\t\tbool update_branches,\n     + \t\t\t\tint argc, const char **argv,\n     + \t\t\t\tstruct commit **oldest_out,\n     +-\t\t\t\tstruct commit **tip_out)\n      +\t\t\t\tstruct commit **tip_out,\n      +\t\t\t\tchar **message_out)\n     -+{\n     -+\tstruct rev_info revs;\n     + {\n     + \tstruct rev_info revs;\n      +\tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n     -+\tstruct commit *commit, *oldest = NULL, *tip = NULL;\n     -+\tstruct strbuf todo_buf = STRBUF_INIT;\n     -+\tsize_t i;\n     -+\tint ret, tip_count = 0;\n     -+\tstruct ref_filter filter = REF_FILTER_INIT;\n     -+\tstruct ref_array refs = { 0 };\n     -+\n     -+\trepo_init_revisions(repo, &revs, NULL);\n     -+\tsubject_data.edit_message = edit_message;\n     -+\trevs.reverse = 1;\n     -+\trevs.topo_order = 1;\n     -+\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n     -+\trevs.simplify_history = 0;\n     -+\trevs.ancestry_path = 1;\n     -+\trevs.limited = 1;\n     -+\trevs.ancestry_path_implicit_bottoms = 1;\n     -+\n     -+\targc = setup_revisions(argc, argv, &revs, NULL);\n     -+\tif (argc > 1) {\n     -+\t\tret = error(_(\"unrecognized argument: %s\"), argv[1]);\n     -+\t\tgoto out;\n     -+\t}\n     -+\n     -+\tif (revs.reverse != 1 || revs.topo_order != 1 ||\n     -+\t    revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||\n     -+\t    revs.simplify_history != 0 || revs.boundary == 1 ||\n     -+\t    revs.ancestry_path != 1 || revs.limited != 1 ||\n     -+\t    revs.ancestry_path_implicit_bottoms != 1) {\n     -+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n     -+\t\t\t  \"range is walked\"));\n     -+\t\trevs.reverse = 1;\n     -+\t\trevs.topo_order = 1;\n     -+\t\trevs.sort_order = REV_SORT_IN_GRAPH_ORDER;\n     -+\t\trevs.simplify_history = 0;\n     -+\t\trevs.boundary = 0;\n     -+\t\trevs.ancestry_path = 1;\n     -+\t\trevs.limited = 1;\n     -+\t\trevs.ancestry_path_implicit_bottoms = 1;\n     -+\t}\n     -+\n     -+\t/*\n     -+\t * A squash needs a base to reparent onto, so the range has to exclude\n     -+\t * something, as in \"<base>..<tip>\". A revision range with no such\n     -+\t * bottom commit cannot be squashed.\n     -+\t */\n     -+\tfor (i = 0; i < revs.cmdline.nr; i++)\n     -+\t\tif (revs.cmdline.rev[i].flags & BOTTOM)\n     -+\t\t\tbreak;\n     -+\tif (i == revs.cmdline.nr) {\n     -+\t\tret = error(_(\"not a '<base>..<tip>' revision range\"));\n     -+\t\tgoto out;\n     -+\t}\n     -+\n     -+\tif (prepare_revision_walk(&revs) < 0) {\n     -+\t\tret = error(_(\"error preparing revisions\"));\n     -+\t\tgoto out;\n     -+\t}\n     -+\twhile ((commit = get_revision(&revs))) {\n     -+\t\tstruct commit_list *p;\n     -+\n     -+\t\tif (edit_message)\n     -+\t\t\tstrbuf_addf(&todo_buf, \"pick %s\\n\",\n     -+\t\t\t\t    oid_to_hex(&commit->object.oid));\n     -+\n     -+\t\tif (!commit->parents) {\n     -+\t\t\tret = error(_(\"cannot squash down to root commit\"));\n     -+\t\t\tgoto out;\n     -+\t\t}\n     -+\t\tfor (p = commit->parents; oldest && p; p = p->next) {\n     -+\t\t\tstruct commit_list *q;\n     -+\t\t\tstruct object *o;\n     -+\t\t\tbool seen;\n     -+\n     -+\t\t\tif (repo_parse_commit(repo, p->item)) {\n     -+\t\t\t\tret = error(_(\"cannot parse commit\"));\n     -+\t\t\t\tgoto out;\n     -+\t\t\t}\n     -+\t\t\to = &p->item->object;\n     -+\t\t\tseen = o->flags & SQUASH_SEEN;\n     -+\t\t\t/*\n     -+\t\t\t * Allow parents that match the parents of the\n     -+\t\t\t * squashed commit.\n     -+\t\t\t */\n     -+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next) {\n     -+\t\t\t\tif (p->item == q->item) {\n     -+\t\t\t\t\tseen = true;\n     -+\t\t\t\t\tcommit_list_insert(commit, &filter.with_commit);\n     -+\t\t\t\t}\n     -+\t\t\t}\n     -+\t\t\tif (!seen) {\n     -+\t\t\t\tret = error(_(\"parent %s of commit %s is \"\n     -+\t\t\t\t\t      \"outside the revision range\"),\n     -+\t\t\t\t\t    repo_find_unique_abbrev(repo, &o->oid,\n     -+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV),\n     -+\t\t\t\t\t    repo_find_unique_abbrev(repo, &commit->object.oid,\n     -+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV));\n     -+\t\t\t\tgoto out;\n     -+\t\t\t}\n     -+\t\t\tif (o->flags & SQUASH_TIP) {\n     -+\t\t\t\ttip_count--;\n     -+\t\t\t\to->flags &= ~SQUASH_TIP;\n     -+\t\t\t}\n     -+\t\t}\n     -+\t\tif (!oldest) {\n     -+\t\t\tcommit_list_insert(commit, &filter.with_commit);\n     -+\t\t\toldest = commit;\n     -+\t\t}\n     + \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n     + \tint ret, tip_count = 0;\n     + \tbool walk_started = false;\n     +@@ builtin/history.c: static int resolve_squash_range(struct repository *repo,\n     + \t\t\tcommit_list_insert(commit, &filter.with_commit);\n     + \t\t\toldest = commit;\n     + \t\t}\n      +\t\tif (squash_check_subject(repo, commit, &subject_data)) {\n      +\t\t\tret = -1;\n      +\t\t\tgoto out;\n      +\t\t}\n     -+\t\ttip = commit;\n     -+\t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n     -+\t\ttip_count++;\n     -+\t}\n     -+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP | SQUASH_AMEND_TARGET);\n     -+\treset_revision_walk();\n     -+\tif (!tip_count) {\n     -+\t\tret = error(_(\"the revision range is empty\"));\n     -+\t\tgoto out;\n     -+\t} else if (tip_count != 1) {\n     -+\t\t  ret = error(_(\"the revision range contains more than one tip \"\n     -+\t\t\t\t\"commit\"));\n     -+\t\t  goto out;\n     -+\t  } else if (oldest == tip) {\n     -+\t\tret = error(_(\"the revision range holds a single commit; \"\n     -+\t\t\t      \"nothing to squash\"));\n     -+\t\tgoto out;\n     -+\t} else if (!oldest->parents) {\n     -+\t\tBUG(\"an in-range commit must have a parent\");\n     -+\t}\n     -+\tcommit_list_insert(tip, &filter.no_commit);\n     -+\tfilter.kind = FILTER_REFS_BRANCHES;\n     -+\tif (update_branches &&\n     -+\t    filter_refs(&refs, &filter, filter.kind)) {\n     -+\t\tret = error(_(\"could not filter refs\"));\n     -+\t\tgoto out;\n     -+\t}\n     -+\tif (refs.nr) {\n     -+\t\t/*\n     -+\t\t * TODO: list the branches and also check HEADS from other worktrees\n     -+\t\t */\n     -+\t\tret = error(_(\"a branch points to a commit that is being squashed\"));\n     -+\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n     -+\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n     -+\t\t\t\t    \"the current branch and leave such refs \"\n     -+\t\t\t\t    \"untouched.\"));\n     -+\t\tgoto out;\n     -+\t}\n     -+\tif (edit_message) {\n     -+\t\tstrbuf_reset(&subject_data.squash_message);\n     -+\t\tret = build_squash_message(repo, &todo_buf,\n     -+\t\t\t\t\t   &subject_data.squash_message);\n     -+\t\tif (ret < 0)\n     -+\t\t\tgoto out;\n     -+\t}\n     -+\t*oldest_out = oldest;\n     -+\t*tip_out = tip;\n     + \t\ttip = commit;\n     + \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n     + \t\ttip_count++;\n     +@@ builtin/history.c: static int resolve_squash_range(struct repository *repo,\n     + \n     + \t*oldest_out = oldest;\n     + \t*tip_out = tip;\n      +\t*message_out = strbuf_detach(&subject_data.squash_message, NULL);\n     -+\tret = 0;\n     -+\n     -+out:\n     -+\tstrbuf_release(&todo_buf);\n     + \tret = 0;\n     + \n     + out:\n     +-\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n     ++\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP |\n     ++\t\t\t   SQUASH_AMEND_TARGET);\n     + \tif (walk_started)\n     + \t\treset_revision_walk();\n      +\tsubject_data_clear(&subject_data);\n     -+\trelease_revisions(&revs);\n     -+\tref_filter_clear(&filter);\n     -+\tref_array_clear(&refs);\n     -+\treturn ret;\n     -+}\n     -+\n     -+static bool amend_replaces_target(struct todo_list *todo, int target)\n     -+{\n     -+\tint i;\n     -+\n     -+\tfor (i = target + 1; i < todo->nr &&\n     -+\t\t\t     todo->items[i].command != TODO_PICK; i++) {\n     -+\t\tif (todo->items[i].command == TODO_SQUASH)\n     -+\t\t\treturn false;\n     -+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n     -+\t\t\treturn true;\n     -+\t}\n     -+\treturn false;\n     -+}\n     -+\n     -+static int build_squash_message(struct repository *repo,\n     -+\t\t\t\tconst struct strbuf *todo_buf,\n     -+\t\t\t\tstruct strbuf *out)\n     -+{\n     -+\tstruct todo_list todo = TODO_LIST_INIT;\n     -+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n     -+\tint i, nr_commits, ret;\n     -+\n     -+\tif (todo_list_parse_insn_buffer(repo, &opts, todo_buf->buf, &todo) < 0 ||\n     -+\t    todo_list_rearrange_squash(&todo) < 0) {\n     -+\t\tret = error(_(\"could not prepare the squash message\"));\n     -+\t\tgoto out;\n     -+\t}\n     -+\n     -+\tnr_commits = todo.nr;\n     -+\tfor (i = 0; i < nr_commits; i++) {\n     -+\t\tstruct todo_item *item = &todo.items[i];\n     -+\t\tconst char *message, *body;\n     -+\t\tsize_t commented_len;\n     -+\t\tbool skip, squashing;\n     -+\n     -+\t\tsquashing = item->command == TODO_SQUASH ||\n     -+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n     -+\t\tif (item->command == TODO_PICK)\n     -+\t\t\tskip = amend_replaces_target(&todo, i);\n     -+\t\telse\n     -+\t\t\tskip = !squashing;\n     -+\n     -+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n     -+\t\tfind_commit_subject(message, &body);\n     -+\n     -+\t\tif (skip)\n     -+\t\t\tcommented_len = strlen(body);\n     -+\t\telse if (squashing)\n     -+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n     -+\t\telse\n     -+\t\t\tcommented_len = 0;\n     -+\n     -+\t\tif (!i)\n     -+\t\t\tadd_squash_combination_header(out, nr_commits);\n     -+\t\tstrbuf_addch(out, '\\n');\n     -+\t\tadd_squash_message_header(out, i + 1, skip);\n     -+\t\tstrbuf_addstr(out, \"\\n\\n\");\n     -+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n     -+\t\tstrbuf_addstr(out, body + commented_len);\n     -+\t\tstrbuf_complete_line(out);\n     -+\n     -+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n     -+\t}\n     -+\n     -+\tret = 0;\n     -+\n     -+out:\n     -+\ttodo_list_release(&todo);\n     -+\treplay_opts_release(&opts);\n     -+\treturn ret;\n     -+}\n     -+\n     -+static int cmd_history_squash(int argc,\n     -+\t\t\t      const char **argv,\n     -+\t\t\t      const char *prefix,\n     -+\t\t\t      struct repository *repo)\n     -+{\n     -+\tconst char * const usage[] = {\n     -+\t\tGIT_HISTORY_SQUASH_USAGE,\n     -+\t\tNULL,\n     -+\t};\n     -+\tenum ref_action action = REF_ACTION_DEFAULT;\n     -+\tint dry_run = 0;\n     -+\tint edit = 1;\n     -+\tstruct option options[] = {\n     -+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n     -+\t\t\t       N_(\"control which refs should be updated\"),\n     -+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n     -+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n     -+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n     -+\t\tOPT_BOOL('e', \"edit\", &edit,\n     -+\t\t\t N_(\"edit the commit message\")),\n     -+\t\tOPT_END(),\n     -+\t};\n     + \trelease_revisions(&revs);\n     + \tref_filter_clear(&filter);\n     + \tref_array_clear(&refs);\n     +@@ builtin/history.c: static int cmd_history_squash(int argc,\n     + \t\t\t N_(\"edit the commit message\")),\n     + \t\tOPT_END(),\n     + \t};\n     +-\tstruct commit *oldest, *tip;\n      +\tstruct strbuf reflog_msg = STRBUF_INIT;\n      +\tstruct commit *oldest, *tip, *rewritten;\n      +\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n      +\tchar *message_template = NULL;\n      +\tstruct rev_info revs = { 0 };\n     -+\tint ret;\n     -+\n     -+\targc = parse_options(argc, argv, prefix, options, usage,\n     -+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n     + \tint ret;\n     + \n     + \targc = parse_options(argc, argv, prefix, options, usage,\n     + \t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n     +-\tif (argc < 2)\n     +-\t\treturn error(_(\"command expects a revision range\"));\n      +\tif (argc < 2) {\n      +\t\tret = error(_(\"command expects a revision range\"));\n      +\t\tgoto out;\n      +\t}\n     -+\trepo_config(repo, git_default_config, NULL);\n     -+\n     -+\tif (action == REF_ACTION_DEFAULT)\n     -+\t\taction = REF_ACTION_BRANCHES;\n     + \trepo_config(repo, git_default_config, NULL);\n      +\n     + \tif (action == REF_ACTION_DEFAULT)\n     + \t\taction = REF_ACTION_BRANCHES;\n     + \n      +\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n      +\tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n      +\n     -+\tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n     -+\t\t\t\t   edit,\n     + \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n     +-\t\t\t\t   argc, argv, &oldest, &tip);\n      +\t\t\t\t   argc, argv, &oldest, &tip,\n      +\t\t\t\t   &message_template);\n     -+\tif (ret < 0)\n     + \tif (ret < 0)\n     +-\t\treturn ret;\n     ++\t\tgoto out;\n     ++\tif (edit) {\n     ++\t\tret = error(_(\"message editing is not supported yet; use '--no-edit'\"));\n      +\t\tgoto out;\n     ++\t}\n      +\n      +\tret = setup_revwalk(repo, action, tip, &revs);\n      +\tif (ret < 0)\n     @@ builtin/history.c: out:\n      +\n      +\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n      +\t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n     -+\t\t\t      &rewritten,\n     -+\t\t\t      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);\n     ++\t\t\t      &rewritten, 0);\n      +\tif (ret < 0) {\n      +\t\tret = error(_(\"failed writing squashed commit\"));\n      +\t\tgoto out;\n     @@ builtin/history.c: out:\n      +\t\tret = error(_(\"failed replaying descendants\"));\n      +\t\tgoto out;\n      +\t}\n     -+\n     + \n     +-\treturn error(_(\"squashing commits is not implemented yet\"));\n      +\tret = 0;\n      +\n      +out:\n     @@ builtin/history.c: out:\n      +\trelease_revisions(&revs);\n      +\tfree(message_template);\n      +\treturn ret;\n     -+}\n     -+\n     - static int update_worktree(struct repository *repo,\n     - \t\t\t   const struct commit *old_head,\n     - \t\t\t   const struct commit *new_head,\n     -@@ builtin/history.c: int cmd_history(int argc,\n     - \t\tGIT_HISTORY_FIXUP_USAGE,\n     - \t\tGIT_HISTORY_REWORD_USAGE,\n     - \t\tGIT_HISTORY_SPLIT_USAGE,\n     -+\t\tGIT_HISTORY_SQUASH_USAGE,\n     - \t\tNULL,\n     - \t};\n     - \tparse_opt_subcommand_fn *fn = NULL;\n     -@@ builtin/history.c: int cmd_history(int argc,\n     - \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n     - \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n     - \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n     -+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n     - \t\tOPT_END(),\n     - \t};\n     + }\n       \n     + static int update_worktree(struct repository *repo,\n      \n       ## object.h ##\n      @@ object.h: void object_array_init(struct object_array *array);\n        * bisect.c:                                        16\n        * bundle.c:                                        16\n        * http-push.c:                          11-----14\n     +- * builtin/history.c:                    1112\n      + * builtin/history.c:                    11---13\n        * commit-graph.c:                                15\n        * commit-reach.c:                                  16-------20\n        * builtin/last-modified.c:                         1617\n      \n     - ## t/meson.build ##\n     -@@ t/meson.build: integration_tests = [\n     -   't3452-history-split.sh',\n     -   't3453-history-fixup.sh',\n     -   't3454-history-drop.sh',\n     -+  't3455-history-squash.sh',\n     -   't3500-cherry.sh',\n     -   't3501-revert-cherry-pick.sh',\n     -   't3502-cherry-pick-merge.sh',\n     -\n     - ## t/t3455-history-squash.sh (new) ##\n     -@@\n     -+#!/bin/sh\n     -+\n     -+test_description='tests for git-history squash subcommand'\n     -+\n     -+. ./test-lib.sh\n     -+\n     + ## t/t3455-history-squash.sh ##\n     +@@ t/t3455-history-squash.sh: test_description='tests for git-history squash subcommand'\n     + \n     + . ./test-lib.sh\n     + \n     +-test_expect_success 'setup linear history' '\n      +stage_file () {\n      +\tprintf \"%s\\n\" \"$1\" >file &&\n      +\tgit add file\n     @@ t/t3455-history-squash.sh (new)\n      +}\n      +\n      +test_expect_success 'setup linear history touching two files' '\n     -+\ttest_commit base file a start &&\n     + \ttest_commit base file a start &&\n     +-\ttest_commit one file b &&\n     +-\ttest_commit two file c &&\n     +-\ttest_commit three file d\n      +\tGIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \\\n      +\t\ttest_commit one other x &&\n      +\tGIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \\\n      +\t\ttest_commit two file c &&\n      +\tGIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \\\n      +\t\ttest_commit three file d\n     -+'\n     -+\n     -+test_expect_success 'errors on missing range argument' '\n     -+\ttest_must_fail git history squash 2>err &&\n     -+\ttest_grep \"expects a revision range\" err\n     -+'\n     -+\n     -+test_expect_success 'errors on an empty range' '\n     -+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n     -+\ttest_grep \"the revision range is empty\" err\n     -+'\n     -+\n     -+test_expect_success 'errors on a single revision that is not a range' '\n     -+\ttest_must_fail git history squash HEAD 2>err &&\n     -+\ttest_grep \"not a .*range\" err &&\n     -+\ttest_must_fail git history squash HEAD~1 2>err &&\n     -+\ttest_grep \"not a .*range\" err\n     -+'\n     -+\n     -+test_expect_success 'errors on a range holding a single commit' '\n     + '\n     + \n     + test_expect_success 'errors on missing range argument' '\n     +@@ t/t3455-history-squash.sh: test_expect_success 'errors on a single revision that is not a range' '\n     + '\n     + \n     + test_expect_success 'errors on a range holding a single commit' '\n      +\tgit reset --hard three &&\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\n     -+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n     + \ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n     +-\ttest_grep \"single commit; nothing to squash\" err\n      +\ttest_grep \"single commit; nothing to squash\" err &&\n      +\ttest_cmp_rev \"$head_before\" HEAD\n     -+'\n     -+\n     + '\n     + \n     +-test_expect_success 'rejects a root commit' '\n      +test_expect_success 'rejects root commit' '\n      +\t# create a disconnected root commit\n     -+\toid=$(git commit-tree -m root three^{tree}) &&\n     + \toid=$(git commit-tree -m root three^{tree}) &&\n     +-\ttest_must_fail git history squash \\\n     +-\t\t--ancestry-path=start \"$oid..three\" 2>err &&\n     +-\ttest_grep \"cannot squash down to root commit\" err\n      +\t# because we pass --ancestry-path when calling setup_revs() it the\n      +\t# revision walk will only include commits decended from $oid so\n      +\t# we need to give it another --ancestry-path commit to actually walk\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_must_fail git history squash --ancestry-path=start $oid..three 2>err &&\n      +\techo \"error: cannot squash down to root commit\" >expect &&\n      +\ttest_cmp expect err\n     -+'\n     -+\n     -+test_expect_success 'rejects multiple tips' '\n     -+\toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n     + '\n     + \n     + test_expect_success 'rejects multiple tips' '\n     + \toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n     +-\ttest_must_fail git history squash ^start \"$oid\" three~1 2>err &&\n     +-\ttest_grep \"revision range contains more than one tip\" err\n      +\ttest_must_fail git history squash ^start $oid three~1 2>err &&\n      +\techo \"error: the revision range contains more than one tip commit\" >expect &&\n      +\ttest_cmp expect err &&\n     @@ t/t3455-history-squash.sh (new)\n      +\tgit history squash --no-edit ^start three~1 three &&\n      +\ttest_cmp_rev HEAD~1 start^0 &&\n      +\ttest_cmp_rev HEAD^{tree} three^{tree}\n     -+'\n     -+\n     + '\n     + \n     +-test_expect_success 'rejects a merge parent outside the range' '\n     +-\tgit reset --hard start &&\n      +test_expect_success 'accepts multiple revision arguments with an exclusion' '\n      +\tgit reset --hard three &&\n      +\tgit branch -f keep HEAD~2 &&\n     @@ t/t3455-history-squash.sh (new)\n      +\n      +test_expect_success 'squashes a branch the current branch is not on' '\n      +\tgit reset --hard three &&\n     -+\tmain=$(git symbolic-ref --short HEAD) &&\n     + \tmain=$(git symbolic-ref --short HEAD) &&\n     +-\tgit checkout -b outside-parent &&\n     +-\ttest_commit --no-tag outside-parent outside x &&\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\tgit checkout -b off-history start &&\n      +\ttest_commit --no-tag off-one off a &&\n      +\ttest_commit --no-tag off-two off b &&\n     -+\tgit checkout \"$main\" &&\n     -+\n     + \tgit checkout \"$main\" &&\n     +-\ttest_commit --no-tag outside-main file b &&\n     +-\tbase=$(git rev-parse HEAD) &&\n     +-\ttest_commit --no-tag outside-mid file c &&\n     +-\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n     +-\tgit branch -D outside-parent &&\n     + \n     +-\ttest_must_fail git history squash \"$base..\" 2>err &&\n     +-\ttest_grep \"parent .* of commit .* is outside the revision range\" err\n      +\tgit history squash --no-edit start..off-history &&\n      +\n      +\tcheck_commit_count start..off-history 1 &&\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_must_fail git history squash --no-edit HEAD~3.. 2>err &&\n      +\ttest_grep \"^error: cannot squash .* target is not being squashed\" err &&\n      +\ttest_set_editor :\n     -+'\n     -+\n     + '\n     + \n     +-test_expect_success 'prints branches that cannot follow the squash' '\n      +test_expect_success 'squashing fixups into a merge' '\n      +\ttest_when_finished \\\n      +\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n     @@ t/t3455-history-squash.sh (new)\n      +\tsed 1,2d msg | test_commit_message HEAD\n      +'\n      +\n     -+test_expect_success 'edits every message and aborts on an empty result' '\n     -+\tgit reset --hard start &&\n     -+\tstage_file b &&\n     -+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n     -+\ttest_commit --no-tag re-two file c &&\n     -+\ttest_commit re-three file d &&\n     -+\thead_before=$(git rev-parse HEAD) &&\n     -+\n     -+\twrite_script empty-editor <<-\\EOF &&\n     -+\t>\"$1\"\n     -+\tEOF\n     -+\ttest_set_editor \"$(pwd)/empty-editor\" &&\n     -+\ttest_must_fail git history squash start.. 2>err &&\n     -+\ttest_grep \"Aborting commit due to empty commit message\" err &&\n     -+\ttest_cmp_rev \"$head_before\" HEAD &&\n     -+\n     -+\twrite_script editor <<-\\EOF &&\n     -+\tcat \"$1\" >edited &&\n     -+\techo combined >\"$1\"\n     -+\tEOF\n     -+\ttest_set_editor \"$(pwd)/editor\" &&\n     -+\tgit history squash start.. &&\n     -+\n     -+\tcat >expect <<-EOF &&\n     -+\t# This is a combination of 3 commits.\n     -+\t# This is the 1st commit message:\n     -+\n     -+\tre-one subject\n     -+\n     -+\tre-one body line\n     -+\n     -+\t# This is the commit message #2:\n     -+\n     -+\tre-two\n     -+\n     -+\t# This is the commit message #3:\n     -+\n     -+\tre-three\n     -+\n     -+\t# Please enter the commit message for the squash changes. Lines starting\n     -+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     -+\t# Changes to be committed:\n     -+\t#\tmodified:   file\n     -+\t#\n     -+\tEOF\n     -+\ttest_cmp expect edited &&\n     -+\tcheck_log_subjects -1 <<-\\EOF\n     -+\tcombined\n     -+\tEOF\n     -+'\n     -+\n     -+test_expect_success 'handles fixup!, squash! and amend! like rebase' '\n     -+\tgit reset --hard start &&\n     -+\ttest_commit --no-tag mark-base file b &&\n     -+\tstage_file c &&\n     -+\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n     -+\tstage_file d &&\n     -+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n     -+\tstage_file e &&\n     -+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n     -+\n     -+\twrite_script editor <<-\\EOF &&\n     -+\tcat \"$1\" >edited\n     -+\tEOF\n     -+\ttest_set_editor \"$(pwd)/editor\" &&\n     -+\tgit history squash start.. &&\n     -+\n     -+\tcat >expect <<-EOF &&\n     -+\t# This is a combination of 4 commits.\n     -+\t# This is the 1st commit message:\n     -+\n     -+\tmark-base\n     -+\n     -+\t# The commit message #2 will be skipped:\n     -+\n     -+\t# fixup! mark-base\n     -+\t#\n     -+\t# fixup body\n     -+\n     -+\t# This is the commit message #3:\n     -+\n     -+\t# squash! mark-base\n     -+\n     -+\tsquash remark\n     -+\n     -+\t# This is the commit message #4:\n     -+\n     -+\t# amend! mark-base\n     -+\n     -+\tamended message\n     -+\n     -+\t# Please enter the commit message for the squash changes. Lines starting\n     -+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     -+\t# Changes to be committed:\n     -+\t#\tmodified:   file\n     -+\t#\n     -+\tEOF\n     -+\ttest_cmp expect edited &&\n     -+\tcheck_log_messages -1 <<-\\EOF\n     -+\tmark-base\n     -+\n     -+\tsquash remark\n     -+\n     -+\tamended message\n     -+\n     -+\tEOF\n     -+'\n     -+\n     -+test_expect_success 'groups fixups under their targets in the editor' '\n     -+\tgit reset --hard start &&\n     -+\ttest_commit --no-tag alpha file a1 &&\n     -+\ttest_commit --no-tag beta file b1 &&\n     -+\tstage_file a2 &&\n     -+\tcommit_with_message \"fixup! alpha\\n\" &&\n     -+\tstage_file b2 &&\n     -+\tcommit_with_message \"fixup! beta\\n\" &&\n     -+\n     -+\twrite_script editor <<-\\EOF &&\n     -+\tcat \"$1\" >edited\n     -+\tEOF\n     -+\ttest_set_editor \"$(pwd)/editor\" &&\n     -+\tgit history squash start.. &&\n     -+\n     -+\tcat >expect <<-EOF &&\n     -+\t# This is a combination of 4 commits.\n     -+\t# This is the 1st commit message:\n     -+\n     -+\talpha\n     -+\n     -+\t# The commit message #2 will be skipped:\n     -+\n     -+\t# fixup! alpha\n     -+\n     -+\t# This is the commit message #3:\n     -+\n     -+\tbeta\n     -+\n     -+\t# The commit message #4 will be skipped:\n     -+\n     -+\t# fixup! beta\n     -+\n     -+\t# Please enter the commit message for the squash changes. Lines starting\n     -+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     -+\t# Changes to be committed:\n     -+\t#\tmodified:   file\n     -+\t#\n     -+\tEOF\n     -+\ttest_cmp expect edited\n     -+'\n     -+\n     -+test_expect_success 'lets amend! replace its target message in the editor' '\n     -+\tgit reset --hard start &&\n     -+\ttest_commit --no-tag mark-base file b &&\n     -+\tstage_file c &&\n     -+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n     -+\tstage_file d &&\n     -+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n     -+\n     -+\twrite_script editor <<-\\EOF &&\n     -+\tcat \"$1\" >edited\n     -+\tEOF\n     -+\ttest_set_editor \"$(pwd)/editor\" &&\n     -+\tgit history squash start.. &&\n     -+\n     -+\tcat >expect <<-EOF &&\n     -+\t# This is a combination of 3 commits.\n     -+\t# The 1st commit message will be skipped:\n     -+\n     -+\t# mark-base\n     -+\n     -+\t# This is the commit message #2:\n     -+\n     -+\t# amend! mark-base\n     -+\n     -+\tamended message\n     -+\n     -+\t# This is the commit message #3:\n     -+\n     -+\t# squash! mark-base\n     -+\n     -+\tsquash remark\n     -+\n     -+\t# Please enter the commit message for the squash changes. Lines starting\n     -+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n     -+\t# Changes to be committed:\n     -+\t#\tmodified:   file\n     -+\t#\n     -+\tEOF\n     -+\ttest_cmp expect edited &&\n     -+\tcheck_log_messages -1 <<-\\EOF\n     -+\tamended message\n     -+\n     -+\tsquash remark\n     -+\n     -+\tEOF\n     -+'\n     -+\n      +test_expect_success '--update-refs=head only moves HEAD' '\n      +\tgit reset --hard three &&\n      +\tgit branch -f other HEAD &&\n     @@ t/t3455-history-squash.sh (new)\n      +'\n      +\n      +test_expect_success 'refuses to fold a range a branch points into' '\n     -+\ttest_when_finished \\\n     -+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n     -+\t\t git branch -D feature\" &&\n     -+\tgit checkout -f -b feature start &&\n     -+\ttest_commit C1 &&\n     -+\ttest_commit C2 &&\n     -+\tgit checkout -b topic-1 start &&\n     -+\ttest_commit C3 &&\n     -+\ttest_commit C4 &&\n     -+\tgit checkout C3 &&\n     -+\ttest_commit C5 &&\n     -+\tgit checkout feature &&\n     -+\tgit merge C5 &&\n     -+\ttest_commit C6 &&\n     -+\tgit checkout -b topic-2 C2 &&\n     -+\ttest_commit C7 &&\n     -+\tgit checkout feature &&\n     -+\n     -+\ttest_must_fail git history squash start.. 2>err &&\n     -+\t# TODO: check the branch names when we print them (topic-1 & topic-2)\n     -+\ttest_grep \"^error: a branch points to\" err &&\n     + \ttest_when_finished \\\n     + \t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n     + \t\t git branch -D feature\" &&\n     +@@ t/t3455-history-squash.sh: test_expect_success 'prints branches that cannot follow the squash' '\n     + \ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n     + \ttest_grep \"^  topic-1$\" err &&\n     + \ttest_grep \"^  topic-2$\" err &&\n     +-\ttest_grep \"^hint: .* --update-refs=head\" err\n      +\ttest_grep \"^hint: .* --update-refs=head\" err &&\n      +\ttest_cmp_rev C6 HEAD &&\n      +\n     @@ t/t3455-history-squash.sh (new)\n      +\ttest_cmp_rev start HEAD^ &&\n      +\ttest_cmp_rev C6^{tree} HEAD^{tree} &&\n      +\ttest_cmp_rev C6 HEAD@{1}\n     -+'\n     -+\n     -+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n     -+\tgit reset --hard three &&\n     -+\tgit branch -f mid HEAD~1 &&\n     + '\n     + \n     + test_expect_success 'advice.historyUpdateRefs silences the hint' '\n     + \tgit reset --hard three &&\n     + \tgit branch -f mid HEAD~1 &&\n      +\thead_before=$(git rev-parse HEAD) &&\n     -+\n     -+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n     -+\t\thistory squash start.. 2>err &&\n     -+\ttest_grep \"^error: a branch points to\" err &&\n     -+\ttest_grep ! \"hint:\" err &&\n     + \n     + \ttest_must_fail git -c advice.historyUpdateRefs=false \\\n     + \t\thistory squash start.. 2>err &&\n     + \ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n     + \ttest_grep \"^  mid$\" err &&\n     + \ttest_grep ! \"hint:\" err &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n     -+\n     -+\tgit branch -D mid\n     -+'\n     -+\n     + \n     + \tgit branch -D mid\n     + '\n     + \n      +test_expect_success 'leaves tags and remote-tracking refs unchanged' '\n      +\tgit reset --hard three &&\n      +\tgit tag -f mark HEAD~1 &&\n     @@ t/t3455-history-squash.sh (new)\n      +\thead_before=$(git rev-parse HEAD) &&\n      +\n      +\ttest_must_fail git history squash start.. 2>err &&\n     -+\t# TODO: test for branch nome \"at-merge\"\n     -+\ttest_grep \"a branch points to a commit\" err &&\n     ++\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n     ++\ttest_grep \"^  at-merge$\" err &&\n      +\ttest_cmp_rev \"$head_before\" HEAD &&\n      +\n      +\tgit branch -D at-merge\n      +'\n      +\n     -+test_done\n     + test_done\n -:  ---------- > 8:  821217ed7c history: support editing squashed commit messages\n\n-- \ngitgitgadget\n"},{"id":"549949","messageId":"b175bdca54c1ba24b96a19eaf53c5ed81c78f8c0.1786088371.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","subject":"[PATCH v13 1/8] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:24Z","receivedAt":"2026-08-07T07:39:35Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 000155ad9c..c22715d848 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -164,6 +164,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -171,21 +190,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -475,18 +484,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -830,16 +831,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"549951","messageId":"965074f9e88738353dfe496a9ce3b704584b6ae6.1786088371.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","subject":"[PATCH v13 2/8] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:25Z","receivedAt":"2026-08-07T07:39:37Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex c22715d848..a60e8dbcd0 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -108,6 +108,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -675,7 +679,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -886,7 +890,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -903,7 +907,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"549952","messageId":"cdbdd766b26e467749266a778ddc97bd0492c278.1786088371.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","subject":"[PATCH v13 3/8] sequencer: share the squash message marker helpers and flags","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:26Z","receivedAt":"2026-08-07T07:39:39Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). Also move the\ntodo_item_flags enum to the header, so a caller reading the output of\ntodo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\nfrom a plain fixup!. A later change reuses all of this to give \"git\nhistory squash\" the same template.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 70 +++++++++++++++++++++++++++++------------------------\n sequencer.h | 30 +++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 31 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 83c3849205..4d2d59ecbe 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)\n \t}\n }\n \n-enum todo_item_flags {\n-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n-};\n-\n static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2012,20 +2032,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2090,9 +2103,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2116,12 +2128,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2140,9 +2149,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 64a9c7fb1b..b01f897020 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -119,6 +119,13 @@ enum todo_command {\n \tTODO_COMMENT\n };\n \n+/* Bits for the \"flags\" member of struct todo_item */\n+enum todo_item_flags {\n+\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n+\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n+\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n+};\n+\n struct todo_item {\n \tenum todo_command command;\n \tstruct commit *commit;\n@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"549953","messageId":"f7d953b081ed312770dd03d827114bb3156195b8.1786088371.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","subject":"[PATCH v13 4/8] history: add skeleton for squash subcommand","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:27Z","receivedAt":"2026-08-07T07:39:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd the entry point and option parsing for \"git history squash\". Pass\nthe remaining arguments through setup_revisions() so the command accepts\nrevision ranges and rev-list options, while restoring the ordering and\nsimplification settings required by the fold if an option changes them.\n\nRequire at least one BOTTOM revision. The squashed commit needs a commit\noutside the selected range to serve as its base, so a single positive\nrevision is not a sufficient range.\n\nKeep this step limited to defining the revision input contract so graph\nvalidation and the rewrite can be added independently.\n\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  1 +\n builtin/history.c              | 94 ++++++++++++++++++++++++++++++++++\n 2 files changed, 95 insertions(+)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 28b477cd37..b660baf94d 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\n \n DESCRIPTION\n -----------\ndiff --git a/builtin/history.c b/builtin/history.c\nindex a60e8dbcd0..4f5a7a46ce 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -34,6 +34,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -1004,6 +1006,96 @@ out:\n \treturn ret;\n }\n \n+static int setup_squash_revisions(struct repository *repo,\n+\t\t\t\t  int argc, const char **argv,\n+\t\t\t\t  struct rev_info *revs)\n+{\n+\trepo_init_revisions(repo, revs, NULL);\n+\trevs->reverse = 1;\n+\trevs->topo_order = 1;\n+\trevs->sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\trevs->simplify_history = 0;\n+\trevs->ancestry_path = 1;\n+\trevs->limited = 1;\n+\trevs->ancestry_path_implicit_bottoms = 1;\n+\n+\targc = setup_revisions(argc, argv, revs, NULL);\n+\tif (argc > 1)\n+\t\treturn error(_(\"unrecognized argument: %s\"), argv[1]);\n+\n+\tif (revs->reverse != 1 || revs->topo_order != 1 ||\n+\t    revs->sort_order != REV_SORT_IN_GRAPH_ORDER ||\n+\t    revs->simplify_history != 0 || revs->boundary == 1 ||\n+\t    revs->ancestry_path != 1 || revs->limited != 1 ||\n+\t    revs->ancestry_path_implicit_bottoms != 1) {\n+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n+\t\t\t  \"range is walked\"));\n+\t\trevs->reverse = 1;\n+\t\trevs->topo_order = 1;\n+\t\trevs->sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\t\trevs->simplify_history = 0;\n+\t\trevs->boundary = 0;\n+\t\trevs->ancestry_path = 1;\n+\t\trevs->limited = 1;\n+\t\trevs->ancestry_path_implicit_bottoms = 1;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (size_t i = 0; i < revs->cmdline.nr; i++)\n+\t\tif (revs->cmdline.rev[i].flags & BOTTOM)\n+\t\t\treturn 0;\n+\n+\treturn error(_(\"not a '<base>..<tip>' revision range\"));\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tint dry_run = 0;\n+\tint edit = 1;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BOOL('e', \"edit\", &edit,\n+\t\t\t N_(\"edit the commit message\")),\n+\t\tOPT_END(),\n+\t};\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage,\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n+\tif (argc < 2) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = error(_(\"squashing commits is not implemented yet\"));\n+\n+out:\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int update_worktree(struct repository *repo,\n \t\t\t   const struct commit *old_head,\n \t\t\t   const struct commit *new_head,\n@@ -1192,6 +1284,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -1200,6 +1293,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \n-- \ngitgitgadget\n\n"},{"id":"549954","messageId":"dbbf66ba02e3e4741d48c7f276a10496510f0f7a.1786088371.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","subject":"[PATCH v13 5/8] history: validate squash revision ranges","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:28Z","receivedAt":"2026-08-07T07:39:42Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWalk the selected commits in topological order from oldest to newest and\nmark each one as it is seen. Every parent after the oldest commit must\nalready be selected or also be a parent of the oldest commit. This\naccepts merges contained by the range while rejecting a merge arm that\nentered it from elsewhere.\n\nTrack the remaining graph tips during the same walk and require exactly\none. Also reject empty and single-commit ranges and any selection that\nreaches a root commit. These checks identify the oldest commit whose\nparents will be preserved and the single tip whose tree will be used by\nthe rewrite.\n\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c         | 119 ++++++++++++++++++++++++++++++++++----\n object.h                  |   1 +\n t/meson.build             |   1 +\n t/t3455-history-squash.sh |  65 +++++++++++++++++++++\n 4 files changed, 174 insertions(+), 12 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 4f5a7a46ce..6541a397e8 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1006,6 +1006,9 @@ out:\n \treturn ret;\n }\n \n+#define SQUASH_SEEN (1u << 11)\n+#define SQUASH_TIP (1u << 12)\n+\n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n \t\t\t\t  struct rev_info *revs)\n@@ -1052,6 +1055,104 @@ static int setup_squash_revisions(struct repository *repo,\n \treturn error(_(\"not a '<base>..<tip>' revision range\"));\n }\n \n+/*\n+ * Resolve a revision range into its oldest commit and single tip. Every\n+ * parent after the oldest commit must either be selected or also be a parent\n+ * of the oldest commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tint argc, const char **argv,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *oldest = NULL, *tip = NULL;\n+\tint ret, tip_count = 0;\n+\tbool walk_started = false;\n+\n+\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\twalk_started = true;\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tstruct commit_list *p;\n+\n+\t\tif (!commit->parents) {\n+\t\t\tret = error(_(\"cannot squash down to root commit\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t\tfor (p = commit->parents; oldest && p; p = p->next) {\n+\t\t\tstruct commit_list *q;\n+\t\t\tstruct object *o;\n+\t\t\tbool seen;\n+\n+\t\t\tif (repo_parse_commit(repo, p->item)) {\n+\t\t\t\tret = error(_(\"cannot parse commit\"));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\to = &p->item->object;\n+\t\t\tseen = o->flags & SQUASH_SEEN;\n+\t\t\t/*\n+\t\t\t * Allow parents that match the parents of the\n+\t\t\t * squashed commit.\n+\t\t\t */\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next)\n+\t\t\t\tif (p->item == q->item)\n+\t\t\t\t\tseen = true;\n+\t\t\tif (!seen) {\n+\t\t\t\tret = error(_(\"parent %s of commit %s is \"\n+\t\t\t\t\t      \"outside the revision range\"),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo, &o->oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t\t    &commit->object.oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tif (o->flags & SQUASH_TIP) {\n+\t\t\t\ttip_count--;\n+\t\t\t\to->flags &= ~SQUASH_TIP;\n+\t\t\t}\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\ttip = commit;\n+\t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n+\t\ttip_count++;\n+\t}\n+\n+\tif (!tip_count) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t} else if (tip_count != 1) {\n+\t\tret = error(_(\"the revision range contains more than one tip \"\n+\t\t\t      \"commit\"));\n+\t\tgoto out;\n+\t} else if (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t} else if (!oldest->parents) {\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\t}\n+\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\tif (walk_started)\n+\t\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1074,26 +1175,20 @@ static int cmd_history_squash(int argc,\n \t\t\t N_(\"edit the commit message\")),\n \t\tOPT_END(),\n \t};\n-\tstruct rev_info revs = { 0 };\n+\tstruct commit *oldest, *tip;\n \tint ret;\n \n \targc = parse_options(argc, argv, prefix, options, usage,\n \t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n-\tif (argc < 2) {\n-\t\tret = error(_(\"command expects a revision range\"));\n-\t\tgoto out;\n-\t}\n+\tif (argc < 2)\n+\t\treturn error(_(\"command expects a revision range\"));\n \trepo_config(repo, git_default_config, NULL);\n \n-\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tret = resolve_squash_range(repo, argc, argv, &oldest, &tip);\n \tif (ret < 0)\n-\t\tgoto out;\n-\n-\tret = error(_(\"squashing commits is not implemented yet\"));\n+\t\treturn ret;\n \n-out:\n-\trelease_revisions(&revs);\n-\treturn ret;\n+\treturn error(_(\"squashing commits is not implemented yet\"));\n }\n \n static int update_worktree(struct repository *repo,\ndiff --git a/object.h b/object.h\nindex 8fb03ff90a..dcf30156ca 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,6 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n+ * builtin/history.c:                    1112\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/meson.build b/t/meson.build\nindex a25f37d2f5..78b8ea54ad 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -406,6 +406,7 @@ integration_tests = [\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n   't3454-history-drop.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..df92aa4f6c\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,65 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history' '\n+\ttest_commit base file a start &&\n+\ttest_commit one file b &&\n+\ttest_commit two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err\n+'\n+\n+test_expect_success 'rejects a root commit' '\n+\toid=$(git commit-tree -m root three^{tree}) &&\n+\ttest_must_fail git history squash \\\n+\t\t--ancestry-path=start \"$oid..three\" 2>err &&\n+\ttest_grep \"cannot squash down to root commit\" err\n+'\n+\n+test_expect_success 'rejects multiple tips' '\n+\toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n+\ttest_must_fail git history squash ^start \"$oid\" three~1 2>err &&\n+\ttest_grep \"revision range contains more than one tip\" err\n+'\n+\n+test_expect_success 'rejects a merge parent outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"549955","messageId":"e71a8adfbe7a212d9c2c8a47a30605a4d5f11c1c.1786088371.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","subject":"[PATCH v13 6/8] history: protect branches when squashing a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:29Z","receivedAt":"2026-08-07T07:39:44Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nA local branch that descends from the selected graph without containing\nits tip cannot be replayed as a descendant of the squashed commit. Find\nthose branches with ref-filter before creating any replacement objects\nand refuse the operation unless --update-refs=head was requested.\n\nLimit this protection to local branches, matching the refs that the\ndefault history rewrite mode updates; tags and remote-tracking refs\nremain untouched. Sort the blocking refs and print their short branch\nnames so the user can decide whether to move them or leave them behind.\n\nAdd advice.historyUpdateRefs for the hint that points to\n--update-refs=head.\n\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |  4 ++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/history.c                | 70 ++++++++++++++++++++++++++++++--\n t/t3455-history-squash.sh        | 39 ++++++++++++++++++\n 5 files changed, 111 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 81f80a9274..3d91c90eda 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -59,6 +59,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a local branch\n+\t\tcannot be rewritten as a descendant of the squashed commit, to\n+\t\ttell the user about `--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/advice.c b/advice.c\nindex 63bf8b0c5f..401d047391 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 66f6cd6a77..3f0b4f0485 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 6541a397e8..e65b76b59f 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -16,10 +17,12 @@\n #include \"path.h\"\n #include \"read-cache.h\"\n #include \"refs.h\"\n+#include \"ref-filter.h\"\n #include \"replay.h\"\n #include \"reset.h\"\n #include \"revision.h\"\n #include \"sequencer.h\"\n+#include \"string-list.h\"\n #include \"strvec.h\"\n #include \"tree.h\"\n #include \"tree-walk.h\"\n@@ -1061,6 +1064,7 @@ static int setup_squash_revisions(struct repository *repo,\n  * of the oldest commit.\n  */\n static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tbool update_branches,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n \t\t\t\tstruct commit **tip_out)\n@@ -1069,6 +1073,8 @@ static int resolve_squash_range(struct repository *repo,\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array refs = { 0 };\n \n \tret = setup_squash_revisions(repo, argc, argv, &revs);\n \tif (ret < 0)\n@@ -1101,9 +1107,12 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t * Allow parents that match the parents of the\n \t\t\t * squashed commit.\n \t\t\t */\n-\t\t\tfor (q = oldest->parents; !seen && q; q = q->next)\n-\t\t\t\tif (p->item == q->item)\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next) {\n+\t\t\t\tif (p->item == q->item) {\n \t\t\t\t\tseen = true;\n+\t\t\t\t\tcommit_list_insert(commit, &filter.with_commit);\n+\t\t\t\t}\n+\t\t\t}\n \t\t\tif (!seen) {\n \t\t\t\tret = error(_(\"parent %s of commit %s is \"\n \t\t\t\t\t      \"outside the revision range\"),\n@@ -1119,12 +1128,17 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\to->flags &= ~SQUASH_TIP;\n \t\t\t}\n \t\t}\n-\t\tif (!oldest)\n+\t\tif (!oldest) {\n+\t\t\tcommit_list_insert(commit, &filter.with_commit);\n \t\t\toldest = commit;\n+\t\t}\n \t\ttip = commit;\n \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n \t\ttip_count++;\n \t}\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\treset_revision_walk();\n+\twalk_started = false;\n \n \tif (!tip_count) {\n \t\tret = error(_(\"the revision range is empty\"));\n@@ -1141,6 +1155,49 @@ static int resolve_squash_range(struct repository *repo,\n \t\tBUG(\"an in-range commit must have a parent\");\n \t}\n \n+\tcommit_list_insert(tip, &filter.no_commit);\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tif (update_branches &&\n+\t    filter_refs(&refs, &filter, filter.kind)) {\n+\t\tret = error(_(\"could not filter refs\"));\n+\t\tgoto out;\n+\t}\n+\tif (refs.nr) {\n+\t\tstruct ref_format format = REF_FORMAT_INIT;\n+\t\tstruct ref_sorting *sorting;\n+\t\tstruct string_list sorting_options = STRING_LIST_INIT_DUP;\n+\t\tstruct strbuf branches = STRBUF_INIT;\n+\t\tstruct strbuf err = STRBUF_INIT;\n+\n+\t\tformat.format = \"%(refname:short)\";\n+\t\tif (verify_ref_format(&format))\n+\t\t\tBUG(\"invalid branch format\");\n+\t\tstring_list_append(&sorting_options, \"refname\");\n+\t\tsorting = ref_sorting_options(&sorting_options);\n+\t\tref_array_sort(sorting, &refs);\n+\t\tfor (int i = 0; i < refs.nr; i++) {\n+\t\t\tstrbuf_reset(&err);\n+\t\t\tstrbuf_addstr(&branches, \"\\n  \");\n+\t\t\tif (format_ref_array_item(refs.items[i], &format,\n+\t\t\t\t\t\t  &branches, &err))\n+\t\t\t\tBUG(\"could not format branch name: %s\", err.buf);\n+\t\t}\n+\t\t/*\n+\t\t * TODO: also check HEADS from other worktrees.\n+\t\t */\n+\t\tret = error(_(\"the following branches cannot be rewritten as \"\n+\t\t\t      \"descendants of the squashed commit:%s\"), branches.buf);\n+\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t    \"the current branch and leave such branches \"\n+\t\t\t\t    \"untouched.\"));\n+\t\tstrbuf_release(&err);\n+\t\tstrbuf_release(&branches);\n+\t\tref_sorting_release(sorting);\n+\t\tstring_list_clear(&sorting_options, 0);\n+\t\tgoto out;\n+\t}\n+\n \t*oldest_out = oldest;\n \t*tip_out = tip;\n \tret = 0;\n@@ -1150,6 +1207,8 @@ out:\n \tif (walk_started)\n \t\treset_revision_walk();\n \trelease_revisions(&revs);\n+\tref_filter_clear(&filter);\n+\tref_array_clear(&refs);\n \treturn ret;\n }\n \n@@ -1183,8 +1242,11 @@ static int cmd_history_squash(int argc,\n \tif (argc < 2)\n \t\treturn error(_(\"command expects a revision range\"));\n \trepo_config(repo, git_default_config, NULL);\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n \n-\tret = resolve_squash_range(repo, argc, argv, &oldest, &tip);\n+\tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n+\t\t\t\t   argc, argv, &oldest, &tip);\n \tif (ret < 0)\n \t\treturn ret;\n \ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex df92aa4f6c..b1f65de5f5 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -62,4 +62,43 @@ test_expect_success 'rejects a merge parent outside the range' '\n \ttest_grep \"parent .* of commit .* is outside the revision range\" err\n '\n \n+test_expect_success 'prints branches that cannot follow the squash' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f -b feature start &&\n+\ttest_commit C1 &&\n+\ttest_commit C2 &&\n+\tgit checkout -b topic-1 start &&\n+\ttest_commit C3 &&\n+\ttest_commit C4 &&\n+\tgit checkout C3 &&\n+\ttest_commit C5 &&\n+\tgit checkout feature &&\n+\tgit merge C5 &&\n+\ttest_commit C6 &&\n+\tgit checkout -b topic-2 C2 &&\n+\ttest_commit C7 &&\n+\tgit checkout feature &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  topic-1$\" err &&\n+\ttest_grep \"^  topic-2$\" err &&\n+\ttest_grep \"^hint: .* --update-refs=head\" err\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  mid$\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\n+\tgit branch -D mid\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"549956","messageId":"8b3551d0d4ecb360775ea29507ee262c7bf9cd42.1786088371.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","subject":"[PATCH v13 7/8] history: create squashed commits without editing","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:30Z","receivedAt":"2026-08-07T07:39:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCreate one replacement commit from the resolved range when --no-edit is\nselected. Preserve the authorship and all parents of the oldest commit,\nuse the tip tree, and replay descendants through the existing history\nrewrite machinery. Record the complete revision expression in the\nreflog and retain dry-run and update-refs behavior.\n\nResolve fixup!, squash! and amend! subjects while walking the range.\nReject markers whose targets are not selected and refuse any no-edit\nfold that would discard a squash! or amend! message. A range made\nentirely from related markers can still be consolidated, with the last\napplicable amend! body supplying the message.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  44 ++-\n builtin/history.c              | 324 ++++++++++++++++-\n object.h                       |   2 +-\n t/t3455-history-squash.sh      | 633 +++++++++++++++++++++++++++++++--\n 4 files changed, 968 insertions(+), 35 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex b660baf94d..fb04a67685 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -44,8 +44,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold merges\n+that lie inside the selected range, subject to the restrictions below.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -114,6 +117,43 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's authorship and\n+\ttakes the tree of the range's newest commit, so the whole range\n+\tcollapses into a single commit. Commits above the range are replayed\n+\ton top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. Several revisions may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`. Rev-list options may also be given, but any that would\n+change how the range is walked are overridden with a warning.\n++\n+With `--no-edit`, the oldest commit's message is preserved, except that an\n+`amend!` commit targeting it replaces its message.\n++\n+The selected commits must form a connected graph with a single tip and must\n+not include a root commit. Every parent of a commit after the oldest one must\n+either be selected or also be a parent of the oldest commit. When the oldest\n+commit is a merge, all of its parents are preserved in the squashed commit.\n++\n+A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n+targets is also in the range, so the fold does not silently absorb a\n+marker meant for a commit outside it. As an exception, a range made up entirely\n+of markers for one target is combined into a single commit. With `--no-edit`,\n+the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n+otherwise refused if folding it would discard its message.\n++\n+A local branch descended from a selected commit but not from the range tip\n+cannot be rewritten as a descendant of the result, so with the default\n+`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n+to rewrite only the current branch and leave such branches unchanged. Tags\n+and remote-tracking refs are always left unchanged.\n+\n OPTIONS\n -------\n \ndiff --git a/builtin/history.c b/builtin/history.c\nindex e65b76b59f..915cfb0cb9 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1011,6 +1011,260 @@ out:\n \n #define SQUASH_SEEN (1u << 11)\n #define SQUASH_TIP (1u << 12)\n+#define SQUASH_AMEND_TARGET (1u << 13)\n+\n+static bool is_autosquash_subject(const char *s)\n+{\n+\treturn starts_with(s, \"amend!\") || starts_with(s, \"fixup!\") ||\n+\t\tstarts_with(s, \"squash!\");\n+}\n+\n+static bool skip_one_autosquash_prefix(const char *s, const char **out)\n+{\n+\tif (skip_prefix(s, \"amend!\", out) || skip_prefix(s, \"fixup!\", out) ||\n+\t    skip_prefix(s, \"squash!\", out)) {\n+\t\twhile (**out == ' ')\n+\t\t\t(*out)++;\n+\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static void truncate_message_to_subject(struct strbuf *msg)\n+{\n+\tconst char *eos = strstr(msg->buf, \"\\n\\n\");\n+\n+\tif (eos)\n+\t\tstrbuf_setlen(msg, eos - msg->buf + 1);\n+}\n+\n+struct subject_data {\n+\tstruct strintmap subjects;\n+\tstruct strbuf subject;\n+\tstruct strbuf squash_message;\n+\tconst char *message;\n+\tbool edit_message;\n+};\n+\n+#define SUBJECT_DATA_INIT {\t\t\\\n+\t.subjects = STRINTMAP_INIT,\t\\\n+\t.subject = STRBUF_INIT,\t\t\\\n+\t.squash_message = STRBUF_INIT,\t\\\n+}\n+\n+static void subject_data_clear(struct subject_data *data)\n+{\n+\tstrintmap_clear(&data->subjects);\n+\tstrbuf_release(&data->subject);\n+\tstrbuf_release(&data->squash_message);\n+}\n+\n+static int squash_amend_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (!*body) {\n+\t\twarning(_(\"ignoring %s (%s): message body is empty\"),\n+\t\t\trepo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\tDEFAULT_ABBREV),\n+\t\t\tdata->subject.buf);\n+\t\treturn 0;\n+\t}\n+\n+\tif (data->edit_message) {\n+\t\treturn 0;\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\"))\n+\t\t\treturn error(_(\"squashing %s (%s) would overwrite \"\n+\t\t\t\t       \"'squash!' message, please combine them \"\n+\t\t\t\t       \"using '--edit'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\"))\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"amend!\", 5);\n+\t\tif (starts_with(data->squash_message.buf, \"amend!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t} else {\n+\t\t\tstrbuf_reset(&data->squash_message);\n+\t\t}\n+\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--edit'\"),\n+\t\t\t     repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t     data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (data->edit_message) {\n+\t\treturn 0;\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"squash\", 6);\n+\t\t}\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\")) {\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t} else {\n+\t\t\treturn error(_(\"squashing %s (%s) would discard its \"\n+\t\t\t\t       \"message, please combine them using \"\n+\t\t\t\t       \"'--edit'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\t}\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--edit'\"),\n+\t\t\t      repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t      DEFAULT_ABBREV),\n+\t\t\t      data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_check_can_autosquash(struct repository *repo,\n+\t\t\t\t       struct commit *commit,\n+\t\t\t\t       struct subject_data *data,\n+\t\t\t\t       unsigned flags)\n+{\n+\tcommit->object.flags |= flags & SQUASH_AMEND_TARGET;\n+\tif (starts_with(data->subject.buf, \"amend!\"))\n+\t\treturn squash_amend_message(repo, commit, data, flags);\n+\telse if (starts_with(data->subject.buf, \"squash!\"))\n+\t\treturn squash_squash_message(repo, commit, data, flags);\n+\n+\treturn 0;\n+}\n+\n+static int squash_check_autosquash_subject(struct repository *repo,\n+\t\t\t\t\t   struct commit *commit,\n+\t\t\t\t\t   struct subject_data *data)\n+{\n+\tconst char* s = data->subject.buf;\n+\tstruct commit *target;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\t/* Try skipping autosquash prefixes one at a time to allow\n+\t * squashing\n+\t *     a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * where we may have started with\n+\t *     a commit\n+\t *     fixup! a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * and squashed the first fixup separately from the second\n+\t */\n+\twhile (skip_one_autosquash_prefix(s, &s)) {\n+\t\tunsigned flags = strintmap_get(&data->subjects, s);\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit, data, flags);\n+\t}\n+\t/*\n+\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n+\t * \"fixup! main\". If the target is not being squshed check the subject\n+\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n+\t * squashed together.\n+\t */\n+\ttarget = lookup_commit_reference_by_name(s);\n+\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n+\t\tunsigned flags =\n+\t\t\ttarget->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);\n+\t\tif (!flags) {\n+\t\t\tconst char *subject_start;\n+\t\t\tconst char *buffer = repo_logmsg_reencode(repo, target,\n+\t\t\t\t\t\t\t\t  NULL, NULL);\n+\t\t\tsize_t subject_len = find_commit_subject(buffer,\n+\t\t\t\t\t\t\t\t &subject_start);\n+\t\t\tchar *subject = xmemdupz(subject_start, subject_len);\n+\n+\t\t\tflags = strintmap_get(&data->subjects, subject);\n+\t\t\tfree(subject);\n+\t\t\trepo_unuse_commit_buffer(repo, target, buffer);\n+\t\t}\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t   data, flags);\n+\t}\n+\t/* Try subject prefix matches */\n+\tstrintmap_for_each_entry(&data->subjects, &iter, entry) {\n+\t\ts = data->subject.buf;\n+\t\twhile(skip_one_autosquash_prefix(s, &s)) {\n+\t\t\tif (starts_with(entry->key, s)) {\n+\t\t\t\tunsigned value = (intptr_t)entry->value;\n+\n+\t\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t\t   data, value);\n+\t\t\t}\n+\t\t}\n+\t}\n+\treturn error(_(\"cannot squash %s (%s): its target is not being \"\n+\t\t       \"squashed\"),\n+\t\t       repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t       DEFAULT_ABBREV),\n+\t\t       data->subject.buf);\n+}\n+\n+static int squash_check_subject(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data)\n+{\n+\tint ret = 0;\n+\tconst char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\tsize_t subject_len = find_commit_subject(buf, &data->message);\n+\n+\tstrbuf_reset(&data->subject);\n+\tstrbuf_add(&data->subject, data->message, subject_len);\n+\n+\tif (!strintmap_get_size(&data->subjects)) {\n+\t\tconst char *s;\n+\n+\t\tstrbuf_addstr(&data->squash_message, data->message);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t/*\n+\t\t * Strip a single autosquash prefix to allow squashing\n+\t\t *     fixup! base\n+\t\t *     amend! base\n+\t\t */\n+\t\ts = data->subject.buf;\n+\t\tskip_one_autosquash_prefix(s, &s);\n+\t\tstrintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);\n+\t\tcommit->object.flags |= SQUASH_AMEND_TARGET;\n+\t} else if (is_autosquash_subject(data->subject.buf)) {\n+\t\tret = squash_check_autosquash_subject(repo, commit, data);\n+\t} else {\n+\t\tstrintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);\n+\t}\n+\trepo_unuse_commit_buffer(repo, commit, buf);\n+\treturn ret;\n+}\n \n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n@@ -1067,9 +1321,11 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\tbool update_branches,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n-\t\t\t\tstruct commit **tip_out)\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tchar **message_out)\n {\n \tstruct rev_info revs;\n+\tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n@@ -1132,6 +1388,10 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\tcommit_list_insert(commit, &filter.with_commit);\n \t\t\toldest = commit;\n \t\t}\n+\t\tif (squash_check_subject(repo, commit, &subject_data)) {\n+\t\t\tret = -1;\n+\t\t\tgoto out;\n+\t\t}\n \t\ttip = commit;\n \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n \t\ttip_count++;\n@@ -1200,12 +1460,15 @@ static int resolve_squash_range(struct repository *repo,\n \n \t*oldest_out = oldest;\n \t*tip_out = tip;\n+\t*message_out = strbuf_detach(&subject_data.squash_message, NULL);\n \tret = 0;\n \n out:\n-\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP |\n+\t\t\t   SQUASH_AMEND_TARGET);\n \tif (walk_started)\n \t\treset_revision_walk();\n+\tsubject_data_clear(&subject_data);\n \trelease_revisions(&revs);\n \tref_filter_clear(&filter);\n \tref_array_clear(&refs);\n@@ -1234,23 +1497,68 @@ static int cmd_history_squash(int argc,\n \t\t\t N_(\"edit the commit message\")),\n \t\tOPT_END(),\n \t};\n-\tstruct commit *oldest, *tip;\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct commit *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tchar *message_template = NULL;\n+\tstruct rev_info revs = { 0 };\n \tint ret;\n \n \targc = parse_options(argc, argv, prefix, options, usage,\n \t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n-\tif (argc < 2)\n-\t\treturn error(_(\"command expects a revision range\"));\n+\tif (argc < 2) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n \trepo_config(repo, git_default_config, NULL);\n+\n \tif (action == REF_ACTION_DEFAULT)\n \t\taction = REF_ACTION_BRANCHES;\n \n+\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n+\tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n+\n \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n-\t\t\t\t   argc, argv, &oldest, &tip);\n+\t\t\t\t   argc, argv, &oldest, &tip,\n+\t\t\t\t   &message_template);\n \tif (ret < 0)\n-\t\treturn ret;\n+\t\tgoto out;\n+\tif (edit) {\n+\t\tret = error(_(\"message editing is not supported yet; use '--no-edit'\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo,\n+\t\t\t\t\toldest->parents->item)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n+\t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n+\t\t\t      &rewritten, 0);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n \n-\treturn error(_(\"squashing commits is not implemented yet\"));\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\trelease_revisions(&revs);\n+\tfree(message_template);\n+\treturn ret;\n }\n \n static int update_worktree(struct repository *repo,\ndiff --git a/object.h b/object.h\nindex dcf30156ca..46cade33fb 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,7 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n- * builtin/history.c:                    1112\n+ * builtin/history.c:                    11---13\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex b1f65de5f5..d2d8e5fdb9 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -4,11 +4,50 @@ test_description='tests for git-history squash subcommand'\n \n . ./test-lib.sh\n \n-test_expect_success 'setup linear history' '\n+stage_file () {\n+\tprintf \"%s\\n\" \"$1\" >file &&\n+\tgit add file\n+}\n+\n+commit_with_message () {\n+\tprintf \"%b\" \"$1\" >msg &&\n+\tgit commit --allow-empty -qF msg\n+}\n+\n+check_commit_count () {\n+\tgit rev-list --count \"$1\" >actual &&\n+\techo \"$2\" >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_subjects () {\n+\tgit log --format=\"%s\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_messages () {\n+\tgit log --format=\"%B\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+# Checks that the author data of two commits matches\n+# Usage: check_commit_author <rev1> <rev2>\n+check_commit_author () {\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$1\" >expect &&\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$2\" >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'setup linear history touching two files' '\n \ttest_commit base file a start &&\n-\ttest_commit one file b &&\n-\ttest_commit two file c &&\n-\ttest_commit three file d\n+\tGIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \\\n+\t\ttest_commit one other x &&\n+\tGIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \\\n+\t\ttest_commit two file c &&\n+\tGIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \\\n+\t\ttest_commit three file d\n '\n \n test_expect_success 'errors on missing range argument' '\n@@ -29,40 +68,331 @@ test_expect_success 'errors on a single revision that is not a range' '\n '\n \n test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n \ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n-\ttest_grep \"single commit; nothing to squash\" err\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n '\n \n-test_expect_success 'rejects a root commit' '\n+test_expect_success 'rejects root commit' '\n+\t# create a disconnected root commit\n \toid=$(git commit-tree -m root three^{tree}) &&\n-\ttest_must_fail git history squash \\\n-\t\t--ancestry-path=start \"$oid..three\" 2>err &&\n-\ttest_grep \"cannot squash down to root commit\" err\n+\t# because we pass --ancestry-path when calling setup_revs() it the\n+\t# revision walk will only include commits decended from $oid so\n+\t# we need to give it another --ancestry-path commit to actually walk\n+\t# any commits.\n+\ttest_must_fail git history squash --ancestry-path=start $oid..three 2>err &&\n+\techo \"error: cannot squash down to root commit\" >expect &&\n+\ttest_cmp expect err\n '\n \n test_expect_success 'rejects multiple tips' '\n \toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n-\ttest_must_fail git history squash ^start \"$oid\" three~1 2>err &&\n-\ttest_grep \"revision range contains more than one tip\" err\n+\ttest_must_fail git history squash ^start $oid three~1 2>err &&\n+\techo \"error: the revision range contains more than one tip commit\" >expect &&\n+\ttest_cmp expect err &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit ^start three~1 three &&\n+\ttest_cmp_rev HEAD~1 start^0 &&\n+\ttest_cmp_rev HEAD^{tree} three^{tree}\n '\n \n-test_expect_success 'rejects a merge parent outside the range' '\n-\tgit reset --hard start &&\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..HEAD ^keep &&\n+\n+\tgit reflog -1 --format=%gs >actual &&\n+\techo \"squash: updating start..HEAD ^keep\" >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n \tmain=$(git symbolic-ref --short HEAD) &&\n-\tgit checkout -b outside-parent &&\n-\ttest_commit --no-tag outside-parent outside x &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n \tgit checkout \"$main\" &&\n-\ttest_commit --no-tag outside-main file b &&\n-\tbase=$(git rev-parse HEAD) &&\n-\ttest_commit --no-tag outside-mid file c &&\n-\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n-\tgit branch -D outside-parent &&\n \n-\ttest_must_fail git history squash \"$base..\" 2>err &&\n-\ttest_grep \"parent .* of commit .* is outside the revision range\" err\n+\tgit history squash --no-edit start..off-history &&\n+\n+\tcheck_commit_count start..off-history 1 &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tone\n+\tEOF\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'sanitizes rev-list walk options, before and after --' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --date-order start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit -- --reverse start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..@~1 &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$root..\" &&\n+\n+\tcheck_commit_count \"$root..HEAD\" 1 &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashing a mix of fixups' '\n+\tgit reset --hard three &&\n+\techo fix >file &&\n+\tgit commit --fixup=two -a &&\n+\techo really fix >file &&\n+\tgit commit --fixup=one -a &&\n+\techo really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! two\n+\techo really really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! one\n+\n+\t# squashing fixup! with a target that is not being squashed fails\n+\ttest_must_fail git history squash one.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing fixup! into fixup! with a different target fails\n+\ttest_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing a sequence of fixup! commits into their targets\n+\tgit history squash --no-edit start..HEAD~1 &&\n+\ttest_cmp_rev start HEAD~2 &&\n+\tcheck_commit_author one HEAD~1 &&\n+\ttest_commit_message HEAD~1 -m one &&\n+\n+\t# squashing \"fixup! fixup! <target>\" into \"<target>\"\n+\tgit history squash --no-edit start.. &&\n+\ttest_cmp_rev start HEAD~1 &&\n+\tcheck_commit_author one HEAD &&\n+\ttest_commit_message HEAD -m one\n+'\n+\n+test_expect_success 'squashing \"squash!\" messages' '\n+\tgit reset --hard two &&\n+\techo fix >file &&\n+\tgit commit --fixup=HEAD -a &&\n+\toldest=$(git rev-parse HEAD) &&\n+\techo better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! $(git rev-parse two)\n+\n+\tAppend this\n+\tEOF\n+\n+\techo an even better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! squash! two\n+\n+\tAppend this as well\n+\tEOF\n+\n+\t# must edit when squashing \"squash!\" into its target\n+\ttest_must_fail git history squash --no-edit two^.. 2>err &&\n+\ttest_grep \"^error: squashing .* (squash! [a-f0-9]*) would discard its message\" err &&\n+\n+\t# squashing \"squash!\" into \"fixup!\" appends messages and changes\n+\t# subject prefix\n+\tgit history squash --no-edit two.. &&\n+\ttest_cmp_rev HEAD^ two &&\n+\ttest_commit_message HEAD <<-\\EOF &&\n+\tsquash! two\n+\n+\tAppend this\n+\n+\tAppend this as well\n+\tEOF\n+\tcheck_commit_author \"$oldest\" HEAD &&\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! two\n+\n+\tA new message\n+\tEOF\n+\n+\t# \"amend!\" does not replace \"squash!\"\n+\ttest_must_fail git history squash --no-edit HEAD~2.. 2>err &&\n+\ttest_grep \"^error: squashing .* (amend! two) would overwrite .squash!. message\" err\n+'\n+\n+test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n+\tgit reset --hard three &&\n+\twrite_script editor <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\techo fix >file &&\n+\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n+\tgit commit --allow-empty -F - <<-EOF &&\n+\tamend! $(git rev-parse --short HEAD)\n+\n+\tThe first reword\n+\n+\tMore detail\n+\tEOF\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! three\n+\n+\tThe second reword\n+\n+\tExtra detail\n+\tEOF\n+\n+\ttest_commit WIP &&\n+\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n+\n+\tThe third reword\n+\n+\tExcruciating detail\n+\tEOF\n+\n+\tgit commit --author=\"Someone Else <s.else@example.com>\" --allow-empty \\\n+\t\t-F msg &&\n+\n+\t# squashing amend! updates the commit message\n+\tgit history squash --no-edit three^.. &&\n+\tsed -e 1,2d msg | test_commit_message HEAD &&\n+\tcheck_commit_author three HEAD &&\n+\ttest_cmp_rev HEAD^ three^ &&\n+\n+\t# squashing amend! into fixup! updates subject prefix\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash --no-edit three.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\tcheck_commit_author HEAD@{1}~4 HEAD &&\n+\ttest_cmp_rev HEAD^ three &&\n+\n+\t# squashing amend! into amend! keeps original subject line\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash --no-edit HEAD~3.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\ttest_cmp_rev HEAD~3 three &&\n+\n+\t# all amend! messages must target the first commit\n+\tgit reset --hard HEAD@{1} &&\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! WIP\n+\n+\tThe real message\n+\tEOF\n+\n+\ttest_must_fail git history squash --no-edit HEAD~4.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* that does not target\" err &&\n+\n+\t# amend! message that targets commit that is not in range is rejected\n+\ttest_must_fail git history squash --no-edit HEAD~3.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* target is not being squashed\" err &&\n+\ttest_set_editor :\n '\n \n-test_expect_success 'prints branches that cannot follow the squash' '\n+test_expect_success 'squashing fixups into a merge' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f start &&\n+\ttest_commit F1 &&\n+\tgit checkout -b feature start &&\n+\ttest_commit F2 &&\n+\tgit merge F1 &&\n+\techo fixed >F1.t &&\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse HEAD)\n+\n+\tmerge F1 and F2\n+\n+\treworded\n+\tEOF\n+\n+\tgit commit -a -F msg &&\n+\tgit history squash --no-edit HEAD^^! HEAD &&\n+\ttest_cmp_rev HEAD^1 F2 &&\n+\ttest_cmp_rev HEAD^2 F1 &&\n+\ttest_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&\n+\tsed 1,2d msg | test_commit_message HEAD\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a branch points into' '\n \ttest_when_finished \\\n \t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n \t\t git branch -D feature\" &&\n@@ -85,20 +415,275 @@ test_expect_success 'prints branches that cannot follow the squash' '\n \ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n \ttest_grep \"^  topic-1$\" err &&\n \ttest_grep \"^  topic-2$\" err &&\n-\ttest_grep \"^hint: .* --update-refs=head\" err\n+\ttest_grep \"^hint: .* --update-refs=head\" err &&\n+\ttest_cmp_rev C6 HEAD &&\n+\n+\t# squash succeeds with --update-refs=head\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest_cmp_rev C6^{tree} HEAD^{tree} &&\n+\ttest_cmp_rev C6 HEAD@{1}\n '\n \n test_expect_success 'advice.historyUpdateRefs silences the hint' '\n \tgit reset --hard three &&\n \tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n \n \ttest_must_fail git -c advice.historyUpdateRefs=false \\\n \t\thistory squash start.. 2>err &&\n \ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n \ttest_grep \"^  mid$\" err &&\n \ttest_grep ! \"hint:\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n \n \tgit branch -D mid\n '\n \n+test_expect_success 'leaves tags and remote-tracking refs unchanged' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\tgit update-ref refs/remotes/origin/mark HEAD~1 &&\n+\tmark_before=$(git rev-parse mark) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest_cmp_rev \"$mark_before\" mark &&\n+\ttest_cmp_rev \"$mark_before\" refs/remotes/origin/mark &&\n+\n+\tgit tag -d mark &&\n+\tgit update-ref -d refs/remotes/origin/mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tbefore-side\n+\tEOF\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$base..\" &&\n+\n+\tcheck_commit_count \"$base..HEAD\" 1 &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash --no-edit start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  at-merge$\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"549957","messageId":"821217ed7ca8e10aac84344be0fc9560157b359c.1786088371.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v13.git.git.1786088371.gitgitgadget@gmail.com","subject":"[PATCH v13 8/8] history: support editing squashed commit messages","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-07T07:39:31Z","receivedAt":"2026-08-07T07:39:47Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nOpen the editor by default when squashing and provide --no-edit as the\nopt-out. Record the exact commits selected by the revision walk,\nrearrange that todo list with the sequencer's autosquash machinery, and\nbuild the message template from the resulting order.\n\nMatch interactive rebase's treatment of marker messages: comment out\nfixup! messages, retain squash! bodies, and let amend! replace its target\nunless a preceding squash! requires both bodies. This keeps message\nediting aligned with the marker validation used by the no-edit path.\n\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  18 ++-\n builtin/history.c              |  97 +++++++++++++++-\n t/t3455-history-squash.sh      | 197 +++++++++++++++++++++++++++++++++\n 3 files changed, 305 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex fb04a67685..2e2e31f521 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -133,8 +133,9 @@ given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n already on `topic`. Rev-list options may also be given, but any that would\n change how the range is walked are overridden with a warning.\n +\n-With `--no-edit`, the oldest commit's message is preserved, except that an\n-`amend!` commit targeting it replaces its message.\n+An editor opens pre-filled with the messages of all the folded commits so you\n+can combine them. With `--no-edit`, the oldest commit's message is preserved\n+instead, except that an `amend!` commit targeting it replaces its message.\n +\n The selected commits must form a connected graph with a single tip and must\n not include a root commit. Every parent of a commit after the oldest one must\n@@ -148,6 +149,13 @@ of markers for one target is combined into a single commit. With `--no-edit`,\n the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n otherwise refused if folding it would discard its message.\n +\n+The editor template mirrors `git rebase -i --autosquash`: each `fixup!`,\n+`squash!`, or `amend!` is grouped under the commit it targets rather than\n+shown in commit order. A `fixup!` message is dropped (commented out in full),\n+a `squash!` keeps its body with only the marker subject commented, and an\n+`amend!` replaces its target's message, unless a `squash!` folded into that\n+target first, in which case it keeps its body like a `squash!`.\n++\n A local branch descended from a selected commit but not from the range tip\n cannot be rewritten as a descendant of the result, so with the default\n `--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n@@ -163,6 +171,12 @@ OPTIONS\n \tobjects will be written into the repository, so applying these printed\n \tref updates is generally safe.\n \n+`--edit`::\n+`--no-edit`::\n+\tFor `squash`, open an editor to combine the messages of the folded commits.\n+\tThis is the default; use `--no-edit` to keep the selected message without\n+\topening an editor.\n+\n `--reedit-message`::\n \tOpen an editor to modify the target commit's message.\n \ndiff --git a/builtin/history.c b/builtin/history.c\nindex 915cfb0cb9..db35a7451a 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1266,6 +1266,10 @@ static int squash_check_subject(struct repository *repo,\n \treturn ret;\n }\n \n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tconst struct strbuf *todo_buf,\n+\t\t\t\tstruct strbuf *out);\n+\n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n \t\t\t\t  struct rev_info *revs)\n@@ -1319,6 +1323,7 @@ static int setup_squash_revisions(struct repository *repo,\n  */\n static int resolve_squash_range(struct repository *repo,\n \t\t\t\tbool update_branches,\n+\t\t\t\tbool edit_message,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n \t\t\t\tstruct commit **tip_out,\n@@ -1327,11 +1332,13 @@ static int resolve_squash_range(struct repository *repo,\n \tstruct rev_info revs;\n \tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n+\tstruct strbuf todo_buf = STRBUF_INIT;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n \tstruct ref_filter filter = REF_FILTER_INIT;\n \tstruct ref_array refs = { 0 };\n \n+\tsubject_data.edit_message = edit_message;\n \tret = setup_squash_revisions(repo, argc, argv, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1344,6 +1351,10 @@ static int resolve_squash_range(struct repository *repo,\n \twhile ((commit = get_revision(&revs))) {\n \t\tstruct commit_list *p;\n \n+\t\tif (edit_message)\n+\t\t\tstrbuf_addf(&todo_buf, \"pick %s\\n\",\n+\t\t\t\t    oid_to_hex(&commit->object.oid));\n+\n \t\tif (!commit->parents) {\n \t\t\tret = error(_(\"cannot squash down to root commit\"));\n \t\t\tgoto out;\n@@ -1457,6 +1468,13 @@ static int resolve_squash_range(struct repository *repo,\n \t\tstring_list_clear(&sorting_options, 0);\n \t\tgoto out;\n \t}\n+\tif (edit_message) {\n+\t\tstrbuf_reset(&subject_data.squash_message);\n+\t\tret = build_squash_message(repo, &todo_buf,\n+\t\t\t\t\t   &subject_data.squash_message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n \n \t*oldest_out = oldest;\n \t*tip_out = tip;\n@@ -1464,6 +1482,7 @@ static int resolve_squash_range(struct repository *repo,\n \tret = 0;\n \n out:\n+\tstrbuf_release(&todo_buf);\n \tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP |\n \t\t\t   SQUASH_AMEND_TARGET);\n \tif (walk_started)\n@@ -1475,6 +1494,76 @@ out:\n \treturn ret;\n }\n \n+static bool amend_replaces_target(struct todo_list *todo, int target)\n+{\n+\tfor (int i = target + 1; i < todo->nr &&\n+\t\t\t\t todo->items[i].command != TODO_PICK; i++) {\n+\t\tif (todo->items[i].command == TODO_SQUASH)\n+\t\t\treturn false;\n+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tconst struct strbuf *todo_buf,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tint nr_commits, ret;\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo_buf->buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not prepare the squash message\"));\n+\t\tgoto out;\n+\t}\n+\n+\tnr_commits = todo.nr;\n+\tfor (int i = 0; i < nr_commits; i++) {\n+\t\tstruct todo_item *item = &todo.items[i];\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tbool skip, squashing;\n+\n+\t\tsquashing = item->command == TODO_SQUASH ||\n+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n+\t\tif (item->command == TODO_PICK)\n+\t\t\tskip = amend_replaces_target(&todo, i);\n+\t\telse\n+\t\t\tskip = !squashing;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tif (skip)\n+\t\t\tcommented_len = strlen(body);\n+\t\telse if (squashing)\n+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n+\t\telse\n+\t\t\tcommented_len = 0;\n+\n+\t\tif (!i)\n+\t\t\tadd_squash_combination_header(out, nr_commits);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, i + 1, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1519,14 +1608,11 @@ static int cmd_history_squash(int argc,\n \tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n \n \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n+\t\t\t\t   edit,\n \t\t\t\t   argc, argv, &oldest, &tip,\n \t\t\t\t   &message_template);\n \tif (ret < 0)\n \t\tgoto out;\n-\tif (edit) {\n-\t\tret = error(_(\"message editing is not supported yet; use '--no-edit'\"));\n-\t\tgoto out;\n-\t}\n \n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n@@ -1538,7 +1624,8 @@ static int cmd_history_squash(int argc,\n \n \tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n \t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n-\t\t\t      &rewritten, 0);\n+\t\t\t      &rewritten,\n+\t\t\t      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n \t\tgoto out;\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex d2d8e5fdb9..591463cb86 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -381,6 +381,203 @@ test_expect_success 'squashing fixups into a merge' '\n \tsed 1,2d msg | test_commit_message HEAD\n '\n \n+test_expect_success 'edits every message and aborts on an empty result' '\n+\tgit reset --hard start &&\n+\tstage_file b &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script empty-editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/empty-editor\" &&\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"Aborting commit due to empty commit message\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tcombined\n+\tEOF\n+'\n+\n+test_expect_success 'handles fixup!, squash! and amend! like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\tstage_file e &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tmark-base\n+\n+\tsquash remark\n+\n+\tamended message\n+\n+\tEOF\n+'\n+\n+test_expect_success 'groups fixups under their targets in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag alpha file a1 &&\n+\ttest_commit --no-tag beta file b1 &&\n+\tstage_file a2 &&\n+\tcommit_with_message \"fixup! alpha\\n\" &&\n+\tstage_file b2 &&\n+\tcommit_with_message \"fixup! beta\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\talpha\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! alpha\n+\n+\t# This is the commit message #3:\n+\n+\tbeta\n+\n+\t# The commit message #4 will be skipped:\n+\n+\t# fixup! beta\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited\n+'\n+\n+test_expect_success 'lets amend! replace its target message in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# The 1st commit message will be skipped:\n+\n+\t# mark-base\n+\n+\t# This is the commit message #2:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended message\n+\n+\tsquash remark\n+\n+\tEOF\n+'\n+\n test_expect_success '--update-refs=head only moves HEAD' '\n \tgit reset --hard three &&\n \tgit branch -f other HEAD &&\n-- \ngitgitgadget\n"},{"id":"549996","messageId":"bf9384e9-3707-4d34-82bf-cfda84a17d94@gmail.com","threadId":"65812","inReplyTo":"xmqq8q6ltwsb.fsf@gitster.g","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-07T13:47:53Z","receivedAt":"2026-08-07T13:48:02Z","isPatch":true,"body":"On 04/08/2026 22:12, Junio C Hamano wrote:\n> Harald Nordgren <haraldnordgren@gmail.com> writes:\n> \n>> Hi Phillip and Junio!\n>>\n>> I admit I’m trying to expedite the process. Especially given what\n>> feels like endless review cycles, and some frustration that this topic\n>> was queued for ’master’ and now it feels like we’re miles away again.\n>> I recognize that this mindset is not a good collaborative attitude, so\n>> I’ll try to step back and re-adjust my mindset.\n>>\n>> I am used to a work process where moving fast is paramount, and good\n>> enough is good enough in terms of code quality.\n\nWe don't have the luxury of being able to easily roll-back features that \nhave been merged and are being used in the wider world so need to have a \nconsidered approach to designing new features.\n\nHardly anyone who is employed to work on git is given work time to \nreview random patches that are not of direct interest to their employer, \n   the reviews are mostly from people volunteering their own time. \nTherefore comparing it to what happens inside a company where it is part \nof the job to review others code is not a realistic comparison.\n\n>> I’m not seeing a\n>> healthy code review process here — any feedback halts the process\n>> completely. Should it really take months to merge a topic?\n\nI'm not sure what you mean by \"any feedback halts the process \ncompletely\". If you spot something you want to improve, you can flag \nthat on the list and still work on your patches while you're waiting for \na review. Though, if you're frequently doing that, then it is an \nindication that you're posting patches that are not ready for review.\n\nThe communication around the code is just as important as the code \nitself and, especially in the early stages of a topic, when the design \nis being thrashed out, it is more important than the code. Sending out a \nre-roll immediately in response to every comment is not productive, as \nit leads to a multitude of different versions with small changes between \nthem. It's far better to reply to any questions and comments, and allow \nothers time to comment as well, so that we reach a consensus before the \nnext set of patches are posted. If you're not following other topics on \nthe list then it might be a good idea to start - you'll see others are \nnot posting 20 revisions before their work is merged.\n\n> When you reroll too fast without waiting for reviewers, it\n> invalidates almost-done-but-yet-unpublished reviews.  If you\n> repeatedly do so, it exacerbates the problem by discouraging\n> reviewers from even looking at your topic, as they wonder if a\n> rapid reroll will invalidate their reviews yet again.\n\nYes, I've definitely waited a couple of days to see if another iteration \nis going to appear before starting a review.\n\n> If it is taking months, I suspect that half of that is due to your\n> rapid rerolls, which have discouraged people from engaging, as they\n> cannot make a time commitment to a bottomless time sink.  The summer\n> being a relatively slow season certainly has played a role, too.\n\nI've found these series taking more time than I'd like to review. Part \nof that is the rapid re-rolls, the other part is the very brief cover \nletters that are not very detailed or explicit with regard to what \nexactly has changed in the implementation and behavior [1] since the \nlast version.\n\nI'm going to be off the list next week, so it be a while before I look \nat these patches again. I'm hoping to see a reply to [2] when I'm back.\n\nThanks\n\nPhillip\n\n[1] For example in <pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com>\n\n     * Protect branches transitively required by a surviving local\n       upstream stack. Traverse upstream chains once and defer delete-set\n       mutation until traversal completes.\n\nThat was actually a change in behavior from the previous version but it \nwas not clear to me at all. It would have been much more helpful to \nexplicitly call that out as a change in behavior and explain why the \nbehavior had changed.\n\n[2] \nhttps://lore.kernel.org/git/ddd0160c-7f4c-41c7-855f-58288db00050@gmail.com\n\n"},{"id":"550008","messageId":"20260807143200.my1sI%taahol@utu.fi","threadId":"65812","inReplyTo":"CAHwyqnW-py0QZNWUikzYO3yyqrYWdZcHQ+axW41GMv94hHh0nw@mail.gmail.com","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Tuomas Ahola","fromEmail":"taahol@utu.fi","sentAt":"2026-08-07T14:32:00Z","receivedAt":"2026-08-07T14:47:25Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> wrote:\n\n> Btw, I have now twice seen this message from Google when using Reply\n> All, which sends to Phillip's reply-to email, whereas mails are\n> actually sent from phillip.wood123@gmail.com:\n> \n> \"Your message wasn't delivered to phillip.wood@dunlem.org.uk because\n> the domain dunlem.org.uk couldn't be found\n> \n> DNS Error: DNS type 'mx' lookup of dunlem.org.uk responded with code\n> NXDOMAIN Domain name not found: dunlem.org.uk For more information, go\n> to https://support.google.com/mail/?p=BadRcptDomain\"\n> \n> \n> Harald\n> \n\nThere is a typo in the domain---should be dunelm, not dunlem.\n\n--Tuomas\n"},{"id":"550046","messageId":"CAHwyqnUDJ05hrS3BeFR1b9Ei=VWCT+gD2AGJveL+Vq+5bg0PSw@mail.gmail.com","threadId":"65812","inReplyTo":"bf9384e9-3707-4d34-82bf-cfda84a17d94@gmail.com","subject":"Re: [PATCH v10 3/5] history: add squash subcommand to fold a range","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-08-07T18:31:04Z","receivedAt":"2026-08-07T18:31:44Z","isPatch":true,"body":"> We don't have the luxury of being able to easily roll-back features that\n> have been merged and are being used in the wider world so need to have a\n> considered approach to designing new features.\n\nThis is fair and an important point since Git is distributed as a\nbinary. However, I still think there is a balance to be struck.\n\n> Hardly anyone who is employed to work on git is given work time to\n> review random patches that are not of direct interest to their employer,\n>    the reviews are mostly from people volunteering their own time.\n> Therefore comparing it to what happens inside a company where it is part\n> of the job to review others code is not a realistic comparison.\n\nThis is very fair and of course makes a lot of sense!\n\n> > When you reroll too fast without waiting for reviewers, it\n> > invalidates almost-done-but-yet-unpublished reviews.  If you\n> > repeatedly do so, it exacerbates the problem by discouraging\n> > reviewers from even looking at your topic, as they wonder if a\n> > rapid reroll will invalidate their reviews yet again.\n>\n> Yes, I've definitely waited a couple of days to see if another iteration\n> is going to appear before starting a review.\n\nThis puts us on a \"resonant frequency\" since I often finish the work\nand then wait a day before sending out. I think there are definitely\ncases where rerolling quicker would also be beneficial.\n\n> [2]\n> https://lore.kernel.org/git/ddd0160c-7f4c-41c7-855f-58288db00050@gmail.com\n\nMy main opposition to this is two-fold:\n\n1. I don't see the value that the new format would bring; I don't\nunderstand that the proposed format is better than the current one (I\nam not sure if I actually understand the new format at all), and Junio\nexpressed a similar sentiment earlier so I have been counting on this\ndiscussion dying down organically.\n\n2. It's consistent to keep them the same, which is in itself valuable.\nAnd it requires work to implement a new format, so there should be a\ngood reason to do that, which goes back to my point 1.\n\n\nHarald\n"},{"id":"550546","messageId":"xmqq4igyszeb.fsf@gitster.g","threadId":"65812","inReplyTo":"8b3551d0d4ecb360775ea29507ee262c7bf9cd42.1786088371.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-13T17:28:44Z","receivedAt":"2026-08-13T17:28:47Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> +test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n> +\tgit reset --hard three &&\n> +\twrite_script editor <<-\\EOF &&\n> +\texit 1\n> +\tEOF\n> +\ttest_set_editor \"$(pwd)/editor\" &&\n> +\techo fix >file &&\n> +\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n> +\tgit commit --allow-empty -F - <<-EOF &&\n> +\tamend! $(git rev-parse --short HEAD)\n> +\n> +\tThe first reword\n> +\n> +\tMore detail\n> +\tEOF\n> +\n> +\tgit commit --allow-empty -F - <<-\\EOF &&\n> +\tamend! three\n> +\n> +\tThe second reword\n> +\n> +\tExtra detail\n> +\tEOF\n> +\n> +\ttest_commit WIP &&\n> +\n> +\tcat >msg <<-EOF &&\n> +\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n> +\n> +\tThe third reword\n> +\n> +\tExcruciating detail\n> +\tEOF\n\nCare to explain why you need to (1) eat the exit status of the 'git\nrev-parse' command and (2) munge the commit object name by piping it\ninto \"tr\"?\n\nWhen another in-flight topic is in effect, this test will break due\nto \"amend!\" followed by a string that is *not* an object name, as\nthe other topic declares that uppercase letters are not valid in a\nhexadecimal string.\n\nThanks.\n"},{"id":"550583","messageId":"xmqqtsoxr8lm.fsf@gitster.g","threadId":"65812","inReplyTo":"xmqq4igyszeb.fsf@gitster.g","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-13T21:52:53Z","receivedAt":"2026-08-13T21:52:57Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n>\n>> +test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n>> +\tgit reset --hard three &&\n>> +\twrite_script editor <<-\\EOF &&\n>> +\texit 1\n>> +\tEOF\n>> +\ttest_set_editor \"$(pwd)/editor\" &&\n>> +\techo fix >file &&\n>> +\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n>> +\tgit commit --allow-empty -F - <<-EOF &&\n>> +\tamend! $(git rev-parse --short HEAD)\n>> +\n>> +\tThe first reword\n>> +\n>> +\tMore detail\n>> +\tEOF\n>> +\n>> +\tgit commit --allow-empty -F - <<-\\EOF &&\n>> +\tamend! three\n>> +\n>> +\tThe second reword\n>> +\n>> +\tExtra detail\n>> +\tEOF\n>> +\n>> +\ttest_commit WIP &&\n>> +\n>> +\tcat >msg <<-EOF &&\n>> +\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n>> +\n>> +\tThe third reword\n>> +\n>> +\tExcruciating detail\n>> +\tEOF\n>\n> Care to explain why you need to (1) eat the exit status of the 'git\n> rev-parse' command and (2) munge the commit object name by piping it\n> into \"tr\"?\n>\n> When another in-flight topic is in effect, this test will break due\n> to \"amend!\" followed by a string that is *not* an object name, as\n> the other topic declares that uppercase letters are not valid in a\n> hexadecimal string.\n\nIn the meantime, I've queued the following on top of the series\nbefore rebuilding 'seen'.\n\n\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex 591463cb86..d21e9d9fc4 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -309,7 +309,7 @@ test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n \ttest_commit WIP &&\n \n \tcat >msg <<-EOF &&\n-\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n+\tamend! $(git rev-parse --short HEAD^)\n \n \tThe third reword\n \n-- \n2.55.0-758-g31b934252d\n\n"},{"id":"550695","messageId":"f34669ad-9157-4f13-a3c1-c0abbc660497@gmail.com","threadId":"65812","inReplyTo":"xmqq4igyszeb.fsf@gitster.g","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-17T08:26:43Z","receivedAt":"2026-08-17T08:26:48Z","isPatch":true,"body":"On 13/08/2026 18:28, Junio C Hamano wrote:\n\n > \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n >\n >> Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n >> Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n >> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n\nThanks for the Helped-by: trailer, but this and several of the other \npatches are missing my sign off from the fixup patches I sent. My sign \noff should come above yours to reflect the chain of custody.\n\n>> +test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n>> +\tgit reset --hard three &&\n>> +\twrite_script editor <<-\\EOF &&\n>> +\texit 1\n>> +\tEOF\n>> +\ttest_set_editor \"$(pwd)/editor\" &&\n>> +\techo fix >file &&\n>> +\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n>> +\tgit commit --allow-empty -F - <<-EOF &&\n>> +\tamend! $(git rev-parse --short HEAD)\n>> +\n>> +\tThe first reword\n>> +\n>> +\tMore detail\n>> +\tEOF\n>> +\n>> +\tgit commit --allow-empty -F - <<-\\EOF &&\n>> +\tamend! three\n>> +\n>> +\tThe second reword\n>> +\n>> +\tExtra detail\n>> +\tEOF\n>> +\n>> +\ttest_commit WIP &&\n>> +\n>> +\tcat >msg <<-EOF &&\n>> +\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n>> +\n>> +\tThe third reword\n>> +\n>> +\tExcruciating detail\n>> +\tEOF\n> \n> Care to explain why you need to (1) eat the exit status of the 'git\n> rev-parse' command and (2) munge the commit object name by piping it\n> into \"tr\"?\n\nI chose to put the command substitution in the here doc, rather than \nmove it out and use a variable because it was more convenient and it \nclearly shows what's going to be in the subject line. To me the benefits \nof that outweigh the minute risk that we'll miss a bug, that's not \ntriggered elsewhere in the test suite, where \"git rev-parse\" produces \nthe correct output but does not exit cleanly.\n\nIt's piped into \"tr\" to check that\n\n\ttarget = lookup_commit_reference_by_name(s);\n\tif (target && istarts_with(oid_to_hex(&target->object.oid), s))\n\nuses istarts_with() so that we accept uppercase object ids.\n\n> When another in-flight topic is in effect, this test will break due\n> to \"amend!\" followed by a string that is *not* an object name, as\n> the other topic declares that uppercase letters are not valid in a\n> hexadecimal string.\n\nI've just had a quick look at that other thread, it would be more \nconvincing to me if there was some explanation of the security issues \nthat accepting uppercase object ids causes. While I appreciate brian may \nnot be able to talk about specific vulnerabilities in particular \nproducts, it would help to outline the security issues in general terms.\n\nThanks\n\nPhillip\n\n\n\n\n\n"},{"id":"550705","messageId":"xmqqbjb07osr.fsf@gitster.g","threadId":"65812","inReplyTo":"f34669ad-9157-4f13-a3c1-c0abbc660497@gmail.com","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-17T15:24:36Z","receivedAt":"2026-08-17T15:24:39Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> It's piped into \"tr\" to check that\n>\n> \ttarget = lookup_commit_reference_by_name(s);\n> \tif (target && istarts_with(oid_to_hex(&target->object.oid), s))\n>\n> uses istarts_with() so that we accept uppercase object ids.\n\nYou should check in with brian about istarts_with() that is used\nhere, as I suspect he missed it in his effort to declare uppercase\nhexadecimal literals invalid.  In the meantime, '| tr' must be\nremoved if this topic wants to play nicely with the\n'bc/restrict-hex-to-lowercase' topic.  In my fixup patch, I changed\nit to use 'rev-parse --short' to test at least the \"starts with\"\naspect of this code.\n\n> I've just had a quick look at that other thread, it would be more \n> convincing to me if there was some explanation of the security issues \n> that accepting uppercase object ids causes. While I appreciate brian may \n> not be able to talk about specific vulnerabilities in particular \n> products, it would help to outline the security issues in general terms.\n\nTrue.\n\n\nThanks.\n"},{"id":"550706","messageId":"xmqq7blo7o98.fsf@gitster.g","threadId":"65812","inReplyTo":"f34669ad-9157-4f13-a3c1-c0abbc660497@gmail.com","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-17T15:36:19Z","receivedAt":"2026-08-17T15:36:22Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> I've just had a quick look at that other thread, it would be more \n> convincing to me if there was some explanation of the security issues \n> that accepting uppercase object ids causes. While I appreciate brian may \n> not be able to talk about specific vulnerabilities in particular \n> products, it would help to outline the security issues in general terms.\n\nIn any case, I do not see any new use of istarts_with() in the patch\nto deliberately allow upppercase hexadecimal.  It is not like there\nis an existing machinery that has generated uppercase hexadecimal\nfor a long time and we need to support their output, or anything\nlike that.\n"},{"id":"550707","messageId":"xmqq5x187o8g.fsf@gitster.g","threadId":"65812","inReplyTo":"f34669ad-9157-4f13-a3c1-c0abbc660497@gmail.com","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-17T15:36:47Z","receivedAt":"2026-08-17T15:36:50Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> I've just had a quick look at that other thread, it would be more \n> convincing to me if there was some explanation of the security issues \n> that accepting uppercase object ids causes. While I appreciate brian may \n> not be able to talk about specific vulnerabilities in particular \n> products, it would help to outline the security issues in general terms.\n\nIn any case, I do not see any reason to use istarts_with() in the\npatch to deliberately allow upppercase hexadecimal.  It is not like\nthere is an existing machinery that has generated uppercase\nhexadecimal for a long time and we need to support their output, or\nanything like that.\n"},{"id":"550710","messageId":"xmqqecfw663u.fsf@gitster.g","threadId":"65812","inReplyTo":"xmqqbjb07osr.fsf@gitster.g","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-17T16:53:41Z","receivedAt":"2026-08-17T16:53:48Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Phillip Wood <phillip.wood123@gmail.com> writes:\n>\n>> It's piped into \"tr\" to check that\n>>\n>> \ttarget = lookup_commit_reference_by_name(s);\n>> \tif (target && istarts_with(oid_to_hex(&target->object.oid), s))\n>>\n>> uses istarts_with() so that we accept uppercase object ids.\n>\n> You should check in with brian about istarts_with() that is used\n> here, as I suspect he missed it in his effort to declare uppercase\n> hexadecimal literals invalid.\n\nAh no, no, no.  This is a new code introduced by this series.\n\nAnd as I said, I do not see why we want to be case insensitive with\nthis comparison.\n"},{"id":"550713","messageId":"CAHwyqnXVepLAsNZHZe_qqXbqYjBt6RpDXVQUP3s7vPwbEg2FXw@mail.gmail.com","threadId":"65812","inReplyTo":"f34669ad-9157-4f13-a3c1-c0abbc660497@gmail.com","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-08-17T17:38:05Z","receivedAt":"2026-08-17T17:38:43Z","isPatch":true,"body":">  >> Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n>  >> Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n>  >> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>\n> Thanks for the Helped-by: trailer, but this and several of the other\n> patches are missing my sign off from the fixup patches I sent. My sign\n> off should come above yours to reflect the chain of custody.\n\nGot it, should I use both headers?\n\n    Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n    Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n    Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n\n\nHarald\n"},{"id":"550722","messageId":"29d56a3e-6bde-49a2-b7b2-3eeb4c439e01@app.fastmail.com","threadId":"65812","inReplyTo":"CAHwyqnXVepLAsNZHZe_qqXbqYjBt6RpDXVQUP3s7vPwbEg2FXw@mail.gmail.com","subject":"Re: [PATCH v13 7/8] history: create squashed commits without editing","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-08-17T20:07:48Z","receivedAt":"2026-08-17T20:08:17Z","isPatch":true,"body":"On Mon, Aug 17, 2026, at 19:38, Harald Nordgren wrote:\n>>  >> Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n>>  >> Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n>>  >> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>>\n>> Thanks for the Helped-by: trailer, but this and several of the other\n>> patches are missing my sign off from the fixup patches I sent. My sign\n>> off should come above yours to reflect the chain of custody.\n>\n> Got it, should I use both headers?\n>\n>     Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n>     Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n>     Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n\nIn general swapping the first and second makes more sense.\n\n    S: PW\n    H: PW\n    S: HN\n\nBecause as a chain of custody this says: Phillip\nsigned off, then Harald said that PW helped him,\nthen Harald signed off on what he got from PW\nwith perhaps his own changes mixed in.\n\nsent from mobile\n"},{"id":"550924","messageId":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.git.git.1781465141.gitgitgadget@gmail.com","subject":"[PATCH v14 0/8] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:24Z","receivedAt":"2026-08-20T18:10:35Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v14:\n\n * Update commit message trailers.\n * Simplify rev-parse and tr complexity in the tests.\n\nChanges in v13:\n\n * Split the squash implementation into five patches covering parsing, range\n   validation, branch protection, commit creation, and message editing.\n * Print the sorted names of local branches that prevent a squash, with\n   clearer advice for --update-refs=head.\n * Isolate --no-edit behavior from the final patch that enables default\n   editor-based message combining.\n\nChanges in v12:\n\n * Incorporated fixups from Phillip:\n   * Reworks range validation into a single walk that rejects roots and\n     multiple tips while preserving every parent when squashing into a\n     merge.\n   * Resolves fixup!, squash! and amend! targets directly, preserving\n     message intent and safely consolidating related markers.\n   * Builds the editor template from the exact selected revisions, including\n     exclusions, while retaining --no-edit behavior.\n   * Protects descendant local branches while leaving tags and\n     remote-tracking refs unchanged.\n\nChanges in v11:\n\n * Make message editing the default with the autosquash-style template, add\n   --no-edit instead of squash-specific --reedit-message.\n * Validate one actual boundary and tip, rejecting root-reaching and\n   multi-tip ranges.\n * Protect only interior local branches, leaving tags and remote-tracking\n   refs unchanged.\n * Move sequencer preparation before squash and fold the later\n   message-editing patch into the feature commit.\n\nChanges in v10:\n\n * Record the full revision expression in squash reflog.\n * Preserve the boundary-walk invariant when sanitizing rev-list options.\n * Clarify amend! and --reedit-message documentation.\n\nChanges in v9:\n\n * Use the last amend! targeting the oldest folded commit as the default\n   squashed message. Ignore amend! markers targeting later commits while\n   selecting that replacement message.\n * Improve tests.\n\nChanges in v8:\n\n * --reedit-message now builds the same editor template as git rebase -i\n   --autosquash: fixup!, squash! and amend! commits are grouped under the\n   commit they target instead of shown in commit order, and an amend!\n   replaces its target's message.\n * A fixup!, squash! or amend! is refused only when its target is outside\n   the range, so several fixups for an in-range commit fold together. A\n   range that is entirely markers for one below-range target is combined\n   into a single commit, keeping the last amend! message.\n * Merges inside the range are folded when the range has a single base, with\n   no dedicated opt-in flag, --ancestry-path ensures only commits descended\n   from the base are folded, and a range reaching more than one base is\n   rejected.\n * Rev-list options are accepted and sanitized the way git replay does,\n   forcing the walk order back with a warning, which also fixes git history\n   squash -- --reverse slipping past the previous option check.\n * Kept this as an explicit squash subcommand rather than making\n   --reedit-message the default or renaming the command.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (8):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  sequencer: share the squash message marker helpers and flags\n  history: add skeleton for squash subcommand\n  history: validate squash revision ranges\n  history: protect branches when squashing a range\n  history: create squashed commits without editing\n  history: support editing squashed commit messages\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  59 +-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 720 +++++++++++++++++++++++--\n object.h                         |   1 +\n sequencer.c                      |  70 +--\n sequencer.h                      |  30 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 886 +++++++++++++++++++++++++++++++\n 10 files changed, 1702 insertions(+), 71 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: 18e66859d87fb4b76599f73460b54f0848c76b16\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v14\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v14\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v13:\n\n 1:  b175bdca54 = 1:  19c5e311a3 history: extract helper for a commit's parent tree\n 2:  965074f9e8 = 2:  aadb7a5df4 history: give commit_tree_ext a message template\n 3:  cdbdd766b2 = 3:  e1621f656e sequencer: share the squash message marker helpers and flags\n 4:  f7d953b081 ! 4:  016ae0fd42 history: add skeleton for squash subcommand\n     @@ Commit message\n          Keep this step limited to defining the revision input contract so graph\n          validation and the rewrite can be added independently.\n      \n     +    Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n 5:  dbbf66ba02 ! 5:  4992d14ce4 history: validate squash revision ranges\n     @@ Commit message\n          parents will be preserved and the single tip whose tree will be used by\n          the rewrite.\n      \n     +    Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n 6:  e71a8adfbe ! 6:  b862a5b4c3 history: protect branches when squashing a range\n     @@ Commit message\n          Add advice.historyUpdateRefs for the hint that points to\n          --update-refs=head.\n      \n     +    Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n 7:  8b3551d0d4 ! 7:  03528d3b34 history: create squashed commits without editing\n     @@ Commit message\n          applicable amend! body supplying the message.\n      \n          Inspired-by: Sergey Chernov <serega.morph@gmail.com>\n     +    Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ t/t3455-history-squash.sh: test_expect_success 'errors on a single revision that\n      +\ttest_commit WIP &&\n      +\n      +\tcat >msg <<-EOF &&\n     -+\tamend! $(git rev-parse HEAD^ | tr a-f A-F)\n     ++\tamend! $(git rev-parse --short HEAD^)\n      +\n      +\tThe third reword\n      +\n 8:  821217ed7c ! 8:  98aa3922cb history: support editing squashed commit messages\n     @@ Commit message\n          unless a preceding squash! requires both bodies. This keeps message\n          editing aligned with the marker validation used by the no-edit path.\n      \n     +    Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n\n-- \ngitgitgadget\n"},{"id":"550925","messageId":"19c5e311a34978345223b3c589fdd9810c4eb207.1787249432.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","subject":"[PATCH v14 1/8] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:25Z","receivedAt":"2026-08-20T18:10:37Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 000155ad9c..c22715d848 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -164,6 +164,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -171,21 +190,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -475,18 +484,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -830,16 +831,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"550926","messageId":"aadb7a5df4366639cfffaac135d51c2a83739d08.1787249432.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","subject":"[PATCH v14 2/8] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:26Z","receivedAt":"2026-08-20T18:10:38Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex c22715d848..a60e8dbcd0 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -108,6 +108,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -675,7 +679,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -886,7 +890,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -903,7 +907,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"550927","messageId":"e1621f656eaec2680181afe1f1ac79b7d71badaf.1787249432.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","subject":"[PATCH v14 3/8] sequencer: share the squash message marker helpers and flags","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:27Z","receivedAt":"2026-08-20T18:10:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). Also move the\ntodo_item_flags enum to the header, so a caller reading the output of\ntodo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\nfrom a plain fixup!. A later change reuses all of this to give \"git\nhistory squash\" the same template.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 70 +++++++++++++++++++++++++++++------------------------\n sequencer.h | 30 +++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 31 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 83c3849205..4d2d59ecbe 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)\n \t}\n }\n \n-enum todo_item_flags {\n-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n-};\n-\n static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2012,20 +2032,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2090,9 +2103,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2116,12 +2128,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2140,9 +2149,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 64a9c7fb1b..b01f897020 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -119,6 +119,13 @@ enum todo_command {\n \tTODO_COMMENT\n };\n \n+/* Bits for the \"flags\" member of struct todo_item */\n+enum todo_item_flags {\n+\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n+\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n+\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n+};\n+\n struct todo_item {\n \tenum todo_command command;\n \tstruct commit *commit;\n@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"550928","messageId":"016ae0fd4286641cbff5117e26decf16cd60eddc.1787249432.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","subject":"[PATCH v14 4/8] history: add skeleton for squash subcommand","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:28Z","receivedAt":"2026-08-20T18:10:41Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd the entry point and option parsing for \"git history squash\". Pass\nthe remaining arguments through setup_revisions() so the command accepts\nrevision ranges and rev-list options, while restoring the ordering and\nsimplification settings required by the fold if an option changes them.\n\nRequire at least one BOTTOM revision. The squashed commit needs a commit\noutside the selected range to serve as its base, so a single positive\nrevision is not a sufficient range.\n\nKeep this step limited to defining the revision input contract so graph\nvalidation and the rewrite can be added independently.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  1 +\n builtin/history.c              | 94 ++++++++++++++++++++++++++++++++++\n 2 files changed, 95 insertions(+)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 28b477cd37..b660baf94d 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\n \n DESCRIPTION\n -----------\ndiff --git a/builtin/history.c b/builtin/history.c\nindex a60e8dbcd0..4f5a7a46ce 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -34,6 +34,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -1004,6 +1006,96 @@ out:\n \treturn ret;\n }\n \n+static int setup_squash_revisions(struct repository *repo,\n+\t\t\t\t  int argc, const char **argv,\n+\t\t\t\t  struct rev_info *revs)\n+{\n+\trepo_init_revisions(repo, revs, NULL);\n+\trevs->reverse = 1;\n+\trevs->topo_order = 1;\n+\trevs->sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\trevs->simplify_history = 0;\n+\trevs->ancestry_path = 1;\n+\trevs->limited = 1;\n+\trevs->ancestry_path_implicit_bottoms = 1;\n+\n+\targc = setup_revisions(argc, argv, revs, NULL);\n+\tif (argc > 1)\n+\t\treturn error(_(\"unrecognized argument: %s\"), argv[1]);\n+\n+\tif (revs->reverse != 1 || revs->topo_order != 1 ||\n+\t    revs->sort_order != REV_SORT_IN_GRAPH_ORDER ||\n+\t    revs->simplify_history != 0 || revs->boundary == 1 ||\n+\t    revs->ancestry_path != 1 || revs->limited != 1 ||\n+\t    revs->ancestry_path_implicit_bottoms != 1) {\n+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n+\t\t\t  \"range is walked\"));\n+\t\trevs->reverse = 1;\n+\t\trevs->topo_order = 1;\n+\t\trevs->sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\t\trevs->simplify_history = 0;\n+\t\trevs->boundary = 0;\n+\t\trevs->ancestry_path = 1;\n+\t\trevs->limited = 1;\n+\t\trevs->ancestry_path_implicit_bottoms = 1;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (size_t i = 0; i < revs->cmdline.nr; i++)\n+\t\tif (revs->cmdline.rev[i].flags & BOTTOM)\n+\t\t\treturn 0;\n+\n+\treturn error(_(\"not a '<base>..<tip>' revision range\"));\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tint dry_run = 0;\n+\tint edit = 1;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BOOL('e', \"edit\", &edit,\n+\t\t\t N_(\"edit the commit message\")),\n+\t\tOPT_END(),\n+\t};\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage,\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n+\tif (argc < 2) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = error(_(\"squashing commits is not implemented yet\"));\n+\n+out:\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int update_worktree(struct repository *repo,\n \t\t\t   const struct commit *old_head,\n \t\t\t   const struct commit *new_head,\n@@ -1192,6 +1284,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -1200,6 +1293,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \n-- \ngitgitgadget\n\n"},{"id":"550929","messageId":"4992d14ce48f436197101c0627ffe7d436597781.1787249432.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","subject":"[PATCH v14 5/8] history: validate squash revision ranges","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:29Z","receivedAt":"2026-08-20T18:10:43Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWalk the selected commits in topological order from oldest to newest and\nmark each one as it is seen. Every parent after the oldest commit must\nalready be selected or also be a parent of the oldest commit. This\naccepts merges contained by the range while rejecting a merge arm that\nentered it from elsewhere.\n\nTrack the remaining graph tips during the same walk and require exactly\none. Also reject empty and single-commit ranges and any selection that\nreaches a root commit. These checks identify the oldest commit whose\nparents will be preserved and the single tip whose tree will be used by\nthe rewrite.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c         | 119 ++++++++++++++++++++++++++++++++++----\n object.h                  |   1 +\n t/meson.build             |   1 +\n t/t3455-history-squash.sh |  65 +++++++++++++++++++++\n 4 files changed, 174 insertions(+), 12 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 4f5a7a46ce..6541a397e8 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1006,6 +1006,9 @@ out:\n \treturn ret;\n }\n \n+#define SQUASH_SEEN (1u << 11)\n+#define SQUASH_TIP (1u << 12)\n+\n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n \t\t\t\t  struct rev_info *revs)\n@@ -1052,6 +1055,104 @@ static int setup_squash_revisions(struct repository *repo,\n \treturn error(_(\"not a '<base>..<tip>' revision range\"));\n }\n \n+/*\n+ * Resolve a revision range into its oldest commit and single tip. Every\n+ * parent after the oldest commit must either be selected or also be a parent\n+ * of the oldest commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tint argc, const char **argv,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *oldest = NULL, *tip = NULL;\n+\tint ret, tip_count = 0;\n+\tbool walk_started = false;\n+\n+\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\twalk_started = true;\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tstruct commit_list *p;\n+\n+\t\tif (!commit->parents) {\n+\t\t\tret = error(_(\"cannot squash down to root commit\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t\tfor (p = commit->parents; oldest && p; p = p->next) {\n+\t\t\tstruct commit_list *q;\n+\t\t\tstruct object *o;\n+\t\t\tbool seen;\n+\n+\t\t\tif (repo_parse_commit(repo, p->item)) {\n+\t\t\t\tret = error(_(\"cannot parse commit\"));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\to = &p->item->object;\n+\t\t\tseen = o->flags & SQUASH_SEEN;\n+\t\t\t/*\n+\t\t\t * Allow parents that match the parents of the\n+\t\t\t * squashed commit.\n+\t\t\t */\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next)\n+\t\t\t\tif (p->item == q->item)\n+\t\t\t\t\tseen = true;\n+\t\t\tif (!seen) {\n+\t\t\t\tret = error(_(\"parent %s of commit %s is \"\n+\t\t\t\t\t      \"outside the revision range\"),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo, &o->oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t\t    &commit->object.oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tif (o->flags & SQUASH_TIP) {\n+\t\t\t\ttip_count--;\n+\t\t\t\to->flags &= ~SQUASH_TIP;\n+\t\t\t}\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\ttip = commit;\n+\t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n+\t\ttip_count++;\n+\t}\n+\n+\tif (!tip_count) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t} else if (tip_count != 1) {\n+\t\tret = error(_(\"the revision range contains more than one tip \"\n+\t\t\t      \"commit\"));\n+\t\tgoto out;\n+\t} else if (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t} else if (!oldest->parents) {\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\t}\n+\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\tif (walk_started)\n+\t\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1074,26 +1175,20 @@ static int cmd_history_squash(int argc,\n \t\t\t N_(\"edit the commit message\")),\n \t\tOPT_END(),\n \t};\n-\tstruct rev_info revs = { 0 };\n+\tstruct commit *oldest, *tip;\n \tint ret;\n \n \targc = parse_options(argc, argv, prefix, options, usage,\n \t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n-\tif (argc < 2) {\n-\t\tret = error(_(\"command expects a revision range\"));\n-\t\tgoto out;\n-\t}\n+\tif (argc < 2)\n+\t\treturn error(_(\"command expects a revision range\"));\n \trepo_config(repo, git_default_config, NULL);\n \n-\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tret = resolve_squash_range(repo, argc, argv, &oldest, &tip);\n \tif (ret < 0)\n-\t\tgoto out;\n-\n-\tret = error(_(\"squashing commits is not implemented yet\"));\n+\t\treturn ret;\n \n-out:\n-\trelease_revisions(&revs);\n-\treturn ret;\n+\treturn error(_(\"squashing commits is not implemented yet\"));\n }\n \n static int update_worktree(struct repository *repo,\ndiff --git a/object.h b/object.h\nindex 8fb03ff90a..dcf30156ca 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,6 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n+ * builtin/history.c:                    1112\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/meson.build b/t/meson.build\nindex 2133c840da..a349bb73b5 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -407,6 +407,7 @@ integration_tests = [\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n   't3454-history-drop.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..df92aa4f6c\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,65 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history' '\n+\ttest_commit base file a start &&\n+\ttest_commit one file b &&\n+\ttest_commit two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err\n+'\n+\n+test_expect_success 'rejects a root commit' '\n+\toid=$(git commit-tree -m root three^{tree}) &&\n+\ttest_must_fail git history squash \\\n+\t\t--ancestry-path=start \"$oid..three\" 2>err &&\n+\ttest_grep \"cannot squash down to root commit\" err\n+'\n+\n+test_expect_success 'rejects multiple tips' '\n+\toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n+\ttest_must_fail git history squash ^start \"$oid\" three~1 2>err &&\n+\ttest_grep \"revision range contains more than one tip\" err\n+'\n+\n+test_expect_success 'rejects a merge parent outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"550930","messageId":"b862a5b4c3a297cf614e8cff2b5e57ec86deb82b.1787249432.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","subject":"[PATCH v14 6/8] history: protect branches when squashing a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:30Z","receivedAt":"2026-08-20T18:10:45Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nA local branch that descends from the selected graph without containing\nits tip cannot be replayed as a descendant of the squashed commit. Find\nthose branches with ref-filter before creating any replacement objects\nand refuse the operation unless --update-refs=head was requested.\n\nLimit this protection to local branches, matching the refs that the\ndefault history rewrite mode updates; tags and remote-tracking refs\nremain untouched. Sort the blocking refs and print their short branch\nnames so the user can decide whether to move them or leave them behind.\n\nAdd advice.historyUpdateRefs for the hint that points to\n--update-refs=head.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |  4 ++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/history.c                | 70 ++++++++++++++++++++++++++++++--\n t/t3455-history-squash.sh        | 39 ++++++++++++++++++\n 5 files changed, 111 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 81f80a9274..3d91c90eda 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -59,6 +59,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a local branch\n+\t\tcannot be rewritten as a descendant of the squashed commit, to\n+\t\ttell the user about `--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/advice.c b/advice.c\nindex 63bf8b0c5f..401d047391 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 66f6cd6a77..3f0b4f0485 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 6541a397e8..e65b76b59f 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -16,10 +17,12 @@\n #include \"path.h\"\n #include \"read-cache.h\"\n #include \"refs.h\"\n+#include \"ref-filter.h\"\n #include \"replay.h\"\n #include \"reset.h\"\n #include \"revision.h\"\n #include \"sequencer.h\"\n+#include \"string-list.h\"\n #include \"strvec.h\"\n #include \"tree.h\"\n #include \"tree-walk.h\"\n@@ -1061,6 +1064,7 @@ static int setup_squash_revisions(struct repository *repo,\n  * of the oldest commit.\n  */\n static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tbool update_branches,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n \t\t\t\tstruct commit **tip_out)\n@@ -1069,6 +1073,8 @@ static int resolve_squash_range(struct repository *repo,\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array refs = { 0 };\n \n \tret = setup_squash_revisions(repo, argc, argv, &revs);\n \tif (ret < 0)\n@@ -1101,9 +1107,12 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t * Allow parents that match the parents of the\n \t\t\t * squashed commit.\n \t\t\t */\n-\t\t\tfor (q = oldest->parents; !seen && q; q = q->next)\n-\t\t\t\tif (p->item == q->item)\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next) {\n+\t\t\t\tif (p->item == q->item) {\n \t\t\t\t\tseen = true;\n+\t\t\t\t\tcommit_list_insert(commit, &filter.with_commit);\n+\t\t\t\t}\n+\t\t\t}\n \t\t\tif (!seen) {\n \t\t\t\tret = error(_(\"parent %s of commit %s is \"\n \t\t\t\t\t      \"outside the revision range\"),\n@@ -1119,12 +1128,17 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\to->flags &= ~SQUASH_TIP;\n \t\t\t}\n \t\t}\n-\t\tif (!oldest)\n+\t\tif (!oldest) {\n+\t\t\tcommit_list_insert(commit, &filter.with_commit);\n \t\t\toldest = commit;\n+\t\t}\n \t\ttip = commit;\n \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n \t\ttip_count++;\n \t}\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\treset_revision_walk();\n+\twalk_started = false;\n \n \tif (!tip_count) {\n \t\tret = error(_(\"the revision range is empty\"));\n@@ -1141,6 +1155,49 @@ static int resolve_squash_range(struct repository *repo,\n \t\tBUG(\"an in-range commit must have a parent\");\n \t}\n \n+\tcommit_list_insert(tip, &filter.no_commit);\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tif (update_branches &&\n+\t    filter_refs(&refs, &filter, filter.kind)) {\n+\t\tret = error(_(\"could not filter refs\"));\n+\t\tgoto out;\n+\t}\n+\tif (refs.nr) {\n+\t\tstruct ref_format format = REF_FORMAT_INIT;\n+\t\tstruct ref_sorting *sorting;\n+\t\tstruct string_list sorting_options = STRING_LIST_INIT_DUP;\n+\t\tstruct strbuf branches = STRBUF_INIT;\n+\t\tstruct strbuf err = STRBUF_INIT;\n+\n+\t\tformat.format = \"%(refname:short)\";\n+\t\tif (verify_ref_format(&format))\n+\t\t\tBUG(\"invalid branch format\");\n+\t\tstring_list_append(&sorting_options, \"refname\");\n+\t\tsorting = ref_sorting_options(&sorting_options);\n+\t\tref_array_sort(sorting, &refs);\n+\t\tfor (int i = 0; i < refs.nr; i++) {\n+\t\t\tstrbuf_reset(&err);\n+\t\t\tstrbuf_addstr(&branches, \"\\n  \");\n+\t\t\tif (format_ref_array_item(refs.items[i], &format,\n+\t\t\t\t\t\t  &branches, &err))\n+\t\t\t\tBUG(\"could not format branch name: %s\", err.buf);\n+\t\t}\n+\t\t/*\n+\t\t * TODO: also check HEADS from other worktrees.\n+\t\t */\n+\t\tret = error(_(\"the following branches cannot be rewritten as \"\n+\t\t\t      \"descendants of the squashed commit:%s\"), branches.buf);\n+\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t    \"the current branch and leave such branches \"\n+\t\t\t\t    \"untouched.\"));\n+\t\tstrbuf_release(&err);\n+\t\tstrbuf_release(&branches);\n+\t\tref_sorting_release(sorting);\n+\t\tstring_list_clear(&sorting_options, 0);\n+\t\tgoto out;\n+\t}\n+\n \t*oldest_out = oldest;\n \t*tip_out = tip;\n \tret = 0;\n@@ -1150,6 +1207,8 @@ out:\n \tif (walk_started)\n \t\treset_revision_walk();\n \trelease_revisions(&revs);\n+\tref_filter_clear(&filter);\n+\tref_array_clear(&refs);\n \treturn ret;\n }\n \n@@ -1183,8 +1242,11 @@ static int cmd_history_squash(int argc,\n \tif (argc < 2)\n \t\treturn error(_(\"command expects a revision range\"));\n \trepo_config(repo, git_default_config, NULL);\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n \n-\tret = resolve_squash_range(repo, argc, argv, &oldest, &tip);\n+\tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n+\t\t\t\t   argc, argv, &oldest, &tip);\n \tif (ret < 0)\n \t\treturn ret;\n \ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex df92aa4f6c..b1f65de5f5 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -62,4 +62,43 @@ test_expect_success 'rejects a merge parent outside the range' '\n \ttest_grep \"parent .* of commit .* is outside the revision range\" err\n '\n \n+test_expect_success 'prints branches that cannot follow the squash' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f -b feature start &&\n+\ttest_commit C1 &&\n+\ttest_commit C2 &&\n+\tgit checkout -b topic-1 start &&\n+\ttest_commit C3 &&\n+\ttest_commit C4 &&\n+\tgit checkout C3 &&\n+\ttest_commit C5 &&\n+\tgit checkout feature &&\n+\tgit merge C5 &&\n+\ttest_commit C6 &&\n+\tgit checkout -b topic-2 C2 &&\n+\ttest_commit C7 &&\n+\tgit checkout feature &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  topic-1$\" err &&\n+\ttest_grep \"^  topic-2$\" err &&\n+\ttest_grep \"^hint: .* --update-refs=head\" err\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  mid$\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\n+\tgit branch -D mid\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"550931","messageId":"03528d3b34c202b990cc42865a009a5786255b7c.1787249432.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","subject":"[PATCH v14 7/8] history: create squashed commits without editing","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:31Z","receivedAt":"2026-08-20T18:10:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCreate one replacement commit from the resolved range when --no-edit is\nselected. Preserve the authorship and all parents of the oldest commit,\nuse the tip tree, and replay descendants through the existing history\nrewrite machinery. Record the complete revision expression in the\nreflog and retain dry-run and update-refs behavior.\n\nResolve fixup!, squash! and amend! subjects while walking the range.\nReject markers whose targets are not selected and refuse any no-edit\nfold that would discard a squash! or amend! message. A range made\nentirely from related markers can still be consolidated, with the last\napplicable amend! body supplying the message.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  44 ++-\n builtin/history.c              | 324 ++++++++++++++++-\n object.h                       |   2 +-\n t/t3455-history-squash.sh      | 633 +++++++++++++++++++++++++++++++--\n 4 files changed, 968 insertions(+), 35 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex b660baf94d..fb04a67685 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -44,8 +44,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold merges\n+that lie inside the selected range, subject to the restrictions below.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -114,6 +117,43 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's authorship and\n+\ttakes the tree of the range's newest commit, so the whole range\n+\tcollapses into a single commit. Commits above the range are replayed\n+\ton top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. Several revisions may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`. Rev-list options may also be given, but any that would\n+change how the range is walked are overridden with a warning.\n++\n+With `--no-edit`, the oldest commit's message is preserved, except that an\n+`amend!` commit targeting it replaces its message.\n++\n+The selected commits must form a connected graph with a single tip and must\n+not include a root commit. Every parent of a commit after the oldest one must\n+either be selected or also be a parent of the oldest commit. When the oldest\n+commit is a merge, all of its parents are preserved in the squashed commit.\n++\n+A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n+targets is also in the range, so the fold does not silently absorb a\n+marker meant for a commit outside it. As an exception, a range made up entirely\n+of markers for one target is combined into a single commit. With `--no-edit`,\n+the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n+otherwise refused if folding it would discard its message.\n++\n+A local branch descended from a selected commit but not from the range tip\n+cannot be rewritten as a descendant of the result, so with the default\n+`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n+to rewrite only the current branch and leave such branches unchanged. Tags\n+and remote-tracking refs are always left unchanged.\n+\n OPTIONS\n -------\n \ndiff --git a/builtin/history.c b/builtin/history.c\nindex e65b76b59f..915cfb0cb9 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1011,6 +1011,260 @@ out:\n \n #define SQUASH_SEEN (1u << 11)\n #define SQUASH_TIP (1u << 12)\n+#define SQUASH_AMEND_TARGET (1u << 13)\n+\n+static bool is_autosquash_subject(const char *s)\n+{\n+\treturn starts_with(s, \"amend!\") || starts_with(s, \"fixup!\") ||\n+\t\tstarts_with(s, \"squash!\");\n+}\n+\n+static bool skip_one_autosquash_prefix(const char *s, const char **out)\n+{\n+\tif (skip_prefix(s, \"amend!\", out) || skip_prefix(s, \"fixup!\", out) ||\n+\t    skip_prefix(s, \"squash!\", out)) {\n+\t\twhile (**out == ' ')\n+\t\t\t(*out)++;\n+\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static void truncate_message_to_subject(struct strbuf *msg)\n+{\n+\tconst char *eos = strstr(msg->buf, \"\\n\\n\");\n+\n+\tif (eos)\n+\t\tstrbuf_setlen(msg, eos - msg->buf + 1);\n+}\n+\n+struct subject_data {\n+\tstruct strintmap subjects;\n+\tstruct strbuf subject;\n+\tstruct strbuf squash_message;\n+\tconst char *message;\n+\tbool edit_message;\n+};\n+\n+#define SUBJECT_DATA_INIT {\t\t\\\n+\t.subjects = STRINTMAP_INIT,\t\\\n+\t.subject = STRBUF_INIT,\t\t\\\n+\t.squash_message = STRBUF_INIT,\t\\\n+}\n+\n+static void subject_data_clear(struct subject_data *data)\n+{\n+\tstrintmap_clear(&data->subjects);\n+\tstrbuf_release(&data->subject);\n+\tstrbuf_release(&data->squash_message);\n+}\n+\n+static int squash_amend_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (!*body) {\n+\t\twarning(_(\"ignoring %s (%s): message body is empty\"),\n+\t\t\trepo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\tDEFAULT_ABBREV),\n+\t\t\tdata->subject.buf);\n+\t\treturn 0;\n+\t}\n+\n+\tif (data->edit_message) {\n+\t\treturn 0;\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\"))\n+\t\t\treturn error(_(\"squashing %s (%s) would overwrite \"\n+\t\t\t\t       \"'squash!' message, please combine them \"\n+\t\t\t\t       \"using '--edit'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\"))\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"amend!\", 5);\n+\t\tif (starts_with(data->squash_message.buf, \"amend!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t} else {\n+\t\t\tstrbuf_reset(&data->squash_message);\n+\t\t}\n+\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--edit'\"),\n+\t\t\t     repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t     data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (data->edit_message) {\n+\t\treturn 0;\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"squash\", 6);\n+\t\t}\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\")) {\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t} else {\n+\t\t\treturn error(_(\"squashing %s (%s) would discard its \"\n+\t\t\t\t       \"message, please combine them using \"\n+\t\t\t\t       \"'--edit'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\t}\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--edit'\"),\n+\t\t\t      repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t      DEFAULT_ABBREV),\n+\t\t\t      data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_check_can_autosquash(struct repository *repo,\n+\t\t\t\t       struct commit *commit,\n+\t\t\t\t       struct subject_data *data,\n+\t\t\t\t       unsigned flags)\n+{\n+\tcommit->object.flags |= flags & SQUASH_AMEND_TARGET;\n+\tif (starts_with(data->subject.buf, \"amend!\"))\n+\t\treturn squash_amend_message(repo, commit, data, flags);\n+\telse if (starts_with(data->subject.buf, \"squash!\"))\n+\t\treturn squash_squash_message(repo, commit, data, flags);\n+\n+\treturn 0;\n+}\n+\n+static int squash_check_autosquash_subject(struct repository *repo,\n+\t\t\t\t\t   struct commit *commit,\n+\t\t\t\t\t   struct subject_data *data)\n+{\n+\tconst char* s = data->subject.buf;\n+\tstruct commit *target;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\t/* Try skipping autosquash prefixes one at a time to allow\n+\t * squashing\n+\t *     a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * where we may have started with\n+\t *     a commit\n+\t *     fixup! a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * and squashed the first fixup separately from the second\n+\t */\n+\twhile (skip_one_autosquash_prefix(s, &s)) {\n+\t\tunsigned flags = strintmap_get(&data->subjects, s);\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit, data, flags);\n+\t}\n+\t/*\n+\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n+\t * \"fixup! main\". If the target is not being squshed check the subject\n+\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n+\t * squashed together.\n+\t */\n+\ttarget = lookup_commit_reference_by_name(s);\n+\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n+\t\tunsigned flags =\n+\t\t\ttarget->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);\n+\t\tif (!flags) {\n+\t\t\tconst char *subject_start;\n+\t\t\tconst char *buffer = repo_logmsg_reencode(repo, target,\n+\t\t\t\t\t\t\t\t  NULL, NULL);\n+\t\t\tsize_t subject_len = find_commit_subject(buffer,\n+\t\t\t\t\t\t\t\t &subject_start);\n+\t\t\tchar *subject = xmemdupz(subject_start, subject_len);\n+\n+\t\t\tflags = strintmap_get(&data->subjects, subject);\n+\t\t\tfree(subject);\n+\t\t\trepo_unuse_commit_buffer(repo, target, buffer);\n+\t\t}\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t   data, flags);\n+\t}\n+\t/* Try subject prefix matches */\n+\tstrintmap_for_each_entry(&data->subjects, &iter, entry) {\n+\t\ts = data->subject.buf;\n+\t\twhile(skip_one_autosquash_prefix(s, &s)) {\n+\t\t\tif (starts_with(entry->key, s)) {\n+\t\t\t\tunsigned value = (intptr_t)entry->value;\n+\n+\t\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t\t   data, value);\n+\t\t\t}\n+\t\t}\n+\t}\n+\treturn error(_(\"cannot squash %s (%s): its target is not being \"\n+\t\t       \"squashed\"),\n+\t\t       repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t       DEFAULT_ABBREV),\n+\t\t       data->subject.buf);\n+}\n+\n+static int squash_check_subject(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data)\n+{\n+\tint ret = 0;\n+\tconst char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\tsize_t subject_len = find_commit_subject(buf, &data->message);\n+\n+\tstrbuf_reset(&data->subject);\n+\tstrbuf_add(&data->subject, data->message, subject_len);\n+\n+\tif (!strintmap_get_size(&data->subjects)) {\n+\t\tconst char *s;\n+\n+\t\tstrbuf_addstr(&data->squash_message, data->message);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t/*\n+\t\t * Strip a single autosquash prefix to allow squashing\n+\t\t *     fixup! base\n+\t\t *     amend! base\n+\t\t */\n+\t\ts = data->subject.buf;\n+\t\tskip_one_autosquash_prefix(s, &s);\n+\t\tstrintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);\n+\t\tcommit->object.flags |= SQUASH_AMEND_TARGET;\n+\t} else if (is_autosquash_subject(data->subject.buf)) {\n+\t\tret = squash_check_autosquash_subject(repo, commit, data);\n+\t} else {\n+\t\tstrintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);\n+\t}\n+\trepo_unuse_commit_buffer(repo, commit, buf);\n+\treturn ret;\n+}\n \n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n@@ -1067,9 +1321,11 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\tbool update_branches,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n-\t\t\t\tstruct commit **tip_out)\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tchar **message_out)\n {\n \tstruct rev_info revs;\n+\tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n@@ -1132,6 +1388,10 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\tcommit_list_insert(commit, &filter.with_commit);\n \t\t\toldest = commit;\n \t\t}\n+\t\tif (squash_check_subject(repo, commit, &subject_data)) {\n+\t\t\tret = -1;\n+\t\t\tgoto out;\n+\t\t}\n \t\ttip = commit;\n \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n \t\ttip_count++;\n@@ -1200,12 +1460,15 @@ static int resolve_squash_range(struct repository *repo,\n \n \t*oldest_out = oldest;\n \t*tip_out = tip;\n+\t*message_out = strbuf_detach(&subject_data.squash_message, NULL);\n \tret = 0;\n \n out:\n-\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP |\n+\t\t\t   SQUASH_AMEND_TARGET);\n \tif (walk_started)\n \t\treset_revision_walk();\n+\tsubject_data_clear(&subject_data);\n \trelease_revisions(&revs);\n \tref_filter_clear(&filter);\n \tref_array_clear(&refs);\n@@ -1234,23 +1497,68 @@ static int cmd_history_squash(int argc,\n \t\t\t N_(\"edit the commit message\")),\n \t\tOPT_END(),\n \t};\n-\tstruct commit *oldest, *tip;\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct commit *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tchar *message_template = NULL;\n+\tstruct rev_info revs = { 0 };\n \tint ret;\n \n \targc = parse_options(argc, argv, prefix, options, usage,\n \t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n-\tif (argc < 2)\n-\t\treturn error(_(\"command expects a revision range\"));\n+\tif (argc < 2) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n \trepo_config(repo, git_default_config, NULL);\n+\n \tif (action == REF_ACTION_DEFAULT)\n \t\taction = REF_ACTION_BRANCHES;\n \n+\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n+\tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n+\n \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n-\t\t\t\t   argc, argv, &oldest, &tip);\n+\t\t\t\t   argc, argv, &oldest, &tip,\n+\t\t\t\t   &message_template);\n \tif (ret < 0)\n-\t\treturn ret;\n+\t\tgoto out;\n+\tif (edit) {\n+\t\tret = error(_(\"message editing is not supported yet; use '--no-edit'\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo,\n+\t\t\t\t\toldest->parents->item)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n+\t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n+\t\t\t      &rewritten, 0);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n \n-\treturn error(_(\"squashing commits is not implemented yet\"));\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\trelease_revisions(&revs);\n+\tfree(message_template);\n+\treturn ret;\n }\n \n static int update_worktree(struct repository *repo,\ndiff --git a/object.h b/object.h\nindex dcf30156ca..46cade33fb 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,7 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n- * builtin/history.c:                    1112\n+ * builtin/history.c:                    11---13\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex b1f65de5f5..fb06637aaf 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -4,11 +4,50 @@ test_description='tests for git-history squash subcommand'\n \n . ./test-lib.sh\n \n-test_expect_success 'setup linear history' '\n+stage_file () {\n+\tprintf \"%s\\n\" \"$1\" >file &&\n+\tgit add file\n+}\n+\n+commit_with_message () {\n+\tprintf \"%b\" \"$1\" >msg &&\n+\tgit commit --allow-empty -qF msg\n+}\n+\n+check_commit_count () {\n+\tgit rev-list --count \"$1\" >actual &&\n+\techo \"$2\" >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_subjects () {\n+\tgit log --format=\"%s\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_messages () {\n+\tgit log --format=\"%B\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+# Checks that the author data of two commits matches\n+# Usage: check_commit_author <rev1> <rev2>\n+check_commit_author () {\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$1\" >expect &&\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$2\" >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'setup linear history touching two files' '\n \ttest_commit base file a start &&\n-\ttest_commit one file b &&\n-\ttest_commit two file c &&\n-\ttest_commit three file d\n+\tGIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \\\n+\t\ttest_commit one other x &&\n+\tGIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \\\n+\t\ttest_commit two file c &&\n+\tGIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \\\n+\t\ttest_commit three file d\n '\n \n test_expect_success 'errors on missing range argument' '\n@@ -29,40 +68,331 @@ test_expect_success 'errors on a single revision that is not a range' '\n '\n \n test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n \ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n-\ttest_grep \"single commit; nothing to squash\" err\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n '\n \n-test_expect_success 'rejects a root commit' '\n+test_expect_success 'rejects root commit' '\n+\t# create a disconnected root commit\n \toid=$(git commit-tree -m root three^{tree}) &&\n-\ttest_must_fail git history squash \\\n-\t\t--ancestry-path=start \"$oid..three\" 2>err &&\n-\ttest_grep \"cannot squash down to root commit\" err\n+\t# because we pass --ancestry-path when calling setup_revs() it the\n+\t# revision walk will only include commits decended from $oid so\n+\t# we need to give it another --ancestry-path commit to actually walk\n+\t# any commits.\n+\ttest_must_fail git history squash --ancestry-path=start $oid..three 2>err &&\n+\techo \"error: cannot squash down to root commit\" >expect &&\n+\ttest_cmp expect err\n '\n \n test_expect_success 'rejects multiple tips' '\n \toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n-\ttest_must_fail git history squash ^start \"$oid\" three~1 2>err &&\n-\ttest_grep \"revision range contains more than one tip\" err\n+\ttest_must_fail git history squash ^start $oid three~1 2>err &&\n+\techo \"error: the revision range contains more than one tip commit\" >expect &&\n+\ttest_cmp expect err &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit ^start three~1 three &&\n+\ttest_cmp_rev HEAD~1 start^0 &&\n+\ttest_cmp_rev HEAD^{tree} three^{tree}\n '\n \n-test_expect_success 'rejects a merge parent outside the range' '\n-\tgit reset --hard start &&\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..HEAD ^keep &&\n+\n+\tgit reflog -1 --format=%gs >actual &&\n+\techo \"squash: updating start..HEAD ^keep\" >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n \tmain=$(git symbolic-ref --short HEAD) &&\n-\tgit checkout -b outside-parent &&\n-\ttest_commit --no-tag outside-parent outside x &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n \tgit checkout \"$main\" &&\n-\ttest_commit --no-tag outside-main file b &&\n-\tbase=$(git rev-parse HEAD) &&\n-\ttest_commit --no-tag outside-mid file c &&\n-\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n-\tgit branch -D outside-parent &&\n \n-\ttest_must_fail git history squash \"$base..\" 2>err &&\n-\ttest_grep \"parent .* of commit .* is outside the revision range\" err\n+\tgit history squash --no-edit start..off-history &&\n+\n+\tcheck_commit_count start..off-history 1 &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tone\n+\tEOF\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'sanitizes rev-list walk options, before and after --' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --date-order start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit -- --reverse start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..@~1 &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$root..\" &&\n+\n+\tcheck_commit_count \"$root..HEAD\" 1 &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashing a mix of fixups' '\n+\tgit reset --hard three &&\n+\techo fix >file &&\n+\tgit commit --fixup=two -a &&\n+\techo really fix >file &&\n+\tgit commit --fixup=one -a &&\n+\techo really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! two\n+\techo really really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! one\n+\n+\t# squashing fixup! with a target that is not being squashed fails\n+\ttest_must_fail git history squash one.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing fixup! into fixup! with a different target fails\n+\ttest_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing a sequence of fixup! commits into their targets\n+\tgit history squash --no-edit start..HEAD~1 &&\n+\ttest_cmp_rev start HEAD~2 &&\n+\tcheck_commit_author one HEAD~1 &&\n+\ttest_commit_message HEAD~1 -m one &&\n+\n+\t# squashing \"fixup! fixup! <target>\" into \"<target>\"\n+\tgit history squash --no-edit start.. &&\n+\ttest_cmp_rev start HEAD~1 &&\n+\tcheck_commit_author one HEAD &&\n+\ttest_commit_message HEAD -m one\n+'\n+\n+test_expect_success 'squashing \"squash!\" messages' '\n+\tgit reset --hard two &&\n+\techo fix >file &&\n+\tgit commit --fixup=HEAD -a &&\n+\toldest=$(git rev-parse HEAD) &&\n+\techo better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! $(git rev-parse two)\n+\n+\tAppend this\n+\tEOF\n+\n+\techo an even better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! squash! two\n+\n+\tAppend this as well\n+\tEOF\n+\n+\t# must edit when squashing \"squash!\" into its target\n+\ttest_must_fail git history squash --no-edit two^.. 2>err &&\n+\ttest_grep \"^error: squashing .* (squash! [a-f0-9]*) would discard its message\" err &&\n+\n+\t# squashing \"squash!\" into \"fixup!\" appends messages and changes\n+\t# subject prefix\n+\tgit history squash --no-edit two.. &&\n+\ttest_cmp_rev HEAD^ two &&\n+\ttest_commit_message HEAD <<-\\EOF &&\n+\tsquash! two\n+\n+\tAppend this\n+\n+\tAppend this as well\n+\tEOF\n+\tcheck_commit_author \"$oldest\" HEAD &&\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! two\n+\n+\tA new message\n+\tEOF\n+\n+\t# \"amend!\" does not replace \"squash!\"\n+\ttest_must_fail git history squash --no-edit HEAD~2.. 2>err &&\n+\ttest_grep \"^error: squashing .* (amend! two) would overwrite .squash!. message\" err\n+'\n+\n+test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n+\tgit reset --hard three &&\n+\twrite_script editor <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\techo fix >file &&\n+\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n+\tgit commit --allow-empty -F - <<-EOF &&\n+\tamend! $(git rev-parse --short HEAD)\n+\n+\tThe first reword\n+\n+\tMore detail\n+\tEOF\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! three\n+\n+\tThe second reword\n+\n+\tExtra detail\n+\tEOF\n+\n+\ttest_commit WIP &&\n+\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse --short HEAD^)\n+\n+\tThe third reword\n+\n+\tExcruciating detail\n+\tEOF\n+\n+\tgit commit --author=\"Someone Else <s.else@example.com>\" --allow-empty \\\n+\t\t-F msg &&\n+\n+\t# squashing amend! updates the commit message\n+\tgit history squash --no-edit three^.. &&\n+\tsed -e 1,2d msg | test_commit_message HEAD &&\n+\tcheck_commit_author three HEAD &&\n+\ttest_cmp_rev HEAD^ three^ &&\n+\n+\t# squashing amend! into fixup! updates subject prefix\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash --no-edit three.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\tcheck_commit_author HEAD@{1}~4 HEAD &&\n+\ttest_cmp_rev HEAD^ three &&\n+\n+\t# squashing amend! into amend! keeps original subject line\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash --no-edit HEAD~3.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\ttest_cmp_rev HEAD~3 three &&\n+\n+\t# all amend! messages must target the first commit\n+\tgit reset --hard HEAD@{1} &&\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! WIP\n+\n+\tThe real message\n+\tEOF\n+\n+\ttest_must_fail git history squash --no-edit HEAD~4.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* that does not target\" err &&\n+\n+\t# amend! message that targets commit that is not in range is rejected\n+\ttest_must_fail git history squash --no-edit HEAD~3.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* target is not being squashed\" err &&\n+\ttest_set_editor :\n '\n \n-test_expect_success 'prints branches that cannot follow the squash' '\n+test_expect_success 'squashing fixups into a merge' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f start &&\n+\ttest_commit F1 &&\n+\tgit checkout -b feature start &&\n+\ttest_commit F2 &&\n+\tgit merge F1 &&\n+\techo fixed >F1.t &&\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse HEAD)\n+\n+\tmerge F1 and F2\n+\n+\treworded\n+\tEOF\n+\n+\tgit commit -a -F msg &&\n+\tgit history squash --no-edit HEAD^^! HEAD &&\n+\ttest_cmp_rev HEAD^1 F2 &&\n+\ttest_cmp_rev HEAD^2 F1 &&\n+\ttest_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&\n+\tsed 1,2d msg | test_commit_message HEAD\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a branch points into' '\n \ttest_when_finished \\\n \t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n \t\t git branch -D feature\" &&\n@@ -85,20 +415,275 @@ test_expect_success 'prints branches that cannot follow the squash' '\n \ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n \ttest_grep \"^  topic-1$\" err &&\n \ttest_grep \"^  topic-2$\" err &&\n-\ttest_grep \"^hint: .* --update-refs=head\" err\n+\ttest_grep \"^hint: .* --update-refs=head\" err &&\n+\ttest_cmp_rev C6 HEAD &&\n+\n+\t# squash succeeds with --update-refs=head\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest_cmp_rev C6^{tree} HEAD^{tree} &&\n+\ttest_cmp_rev C6 HEAD@{1}\n '\n \n test_expect_success 'advice.historyUpdateRefs silences the hint' '\n \tgit reset --hard three &&\n \tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n \n \ttest_must_fail git -c advice.historyUpdateRefs=false \\\n \t\thistory squash start.. 2>err &&\n \ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n \ttest_grep \"^  mid$\" err &&\n \ttest_grep ! \"hint:\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n \n \tgit branch -D mid\n '\n \n+test_expect_success 'leaves tags and remote-tracking refs unchanged' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\tgit update-ref refs/remotes/origin/mark HEAD~1 &&\n+\tmark_before=$(git rev-parse mark) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest_cmp_rev \"$mark_before\" mark &&\n+\ttest_cmp_rev \"$mark_before\" refs/remotes/origin/mark &&\n+\n+\tgit tag -d mark &&\n+\tgit update-ref -d refs/remotes/origin/mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tbefore-side\n+\tEOF\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$base..\" &&\n+\n+\tcheck_commit_count \"$base..HEAD\" 1 &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash --no-edit start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  at-merge$\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"550932","messageId":"98aa3922cb93d9704654ab0677e632da277d19c6.1787249432.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v14.git.git.1787249432.gitgitgadget@gmail.com","subject":"[PATCH v14 8/8] history: support editing squashed commit messages","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-20T18:10:32Z","receivedAt":"2026-08-20T18:10:48Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nOpen the editor by default when squashing and provide --no-edit as the\nopt-out. Record the exact commits selected by the revision walk,\nrearrange that todo list with the sequencer's autosquash machinery, and\nbuild the message template from the resulting order.\n\nMatch interactive rebase's treatment of marker messages: comment out\nfixup! messages, retain squash! bodies, and let amend! replace its target\nunless a preceding squash! requires both bodies. This keeps message\nediting aligned with the marker validation used by the no-edit path.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  18 ++-\n builtin/history.c              |  97 +++++++++++++++-\n t/t3455-history-squash.sh      | 197 +++++++++++++++++++++++++++++++++\n 3 files changed, 305 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex fb04a67685..2e2e31f521 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -133,8 +133,9 @@ given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n already on `topic`. Rev-list options may also be given, but any that would\n change how the range is walked are overridden with a warning.\n +\n-With `--no-edit`, the oldest commit's message is preserved, except that an\n-`amend!` commit targeting it replaces its message.\n+An editor opens pre-filled with the messages of all the folded commits so you\n+can combine them. With `--no-edit`, the oldest commit's message is preserved\n+instead, except that an `amend!` commit targeting it replaces its message.\n +\n The selected commits must form a connected graph with a single tip and must\n not include a root commit. Every parent of a commit after the oldest one must\n@@ -148,6 +149,13 @@ of markers for one target is combined into a single commit. With `--no-edit`,\n the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n otherwise refused if folding it would discard its message.\n +\n+The editor template mirrors `git rebase -i --autosquash`: each `fixup!`,\n+`squash!`, or `amend!` is grouped under the commit it targets rather than\n+shown in commit order. A `fixup!` message is dropped (commented out in full),\n+a `squash!` keeps its body with only the marker subject commented, and an\n+`amend!` replaces its target's message, unless a `squash!` folded into that\n+target first, in which case it keeps its body like a `squash!`.\n++\n A local branch descended from a selected commit but not from the range tip\n cannot be rewritten as a descendant of the result, so with the default\n `--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n@@ -163,6 +171,12 @@ OPTIONS\n \tobjects will be written into the repository, so applying these printed\n \tref updates is generally safe.\n \n+`--edit`::\n+`--no-edit`::\n+\tFor `squash`, open an editor to combine the messages of the folded commits.\n+\tThis is the default; use `--no-edit` to keep the selected message without\n+\topening an editor.\n+\n `--reedit-message`::\n \tOpen an editor to modify the target commit's message.\n \ndiff --git a/builtin/history.c b/builtin/history.c\nindex 915cfb0cb9..db35a7451a 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1266,6 +1266,10 @@ static int squash_check_subject(struct repository *repo,\n \treturn ret;\n }\n \n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tconst struct strbuf *todo_buf,\n+\t\t\t\tstruct strbuf *out);\n+\n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n \t\t\t\t  struct rev_info *revs)\n@@ -1319,6 +1323,7 @@ static int setup_squash_revisions(struct repository *repo,\n  */\n static int resolve_squash_range(struct repository *repo,\n \t\t\t\tbool update_branches,\n+\t\t\t\tbool edit_message,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n \t\t\t\tstruct commit **tip_out,\n@@ -1327,11 +1332,13 @@ static int resolve_squash_range(struct repository *repo,\n \tstruct rev_info revs;\n \tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n+\tstruct strbuf todo_buf = STRBUF_INIT;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n \tstruct ref_filter filter = REF_FILTER_INIT;\n \tstruct ref_array refs = { 0 };\n \n+\tsubject_data.edit_message = edit_message;\n \tret = setup_squash_revisions(repo, argc, argv, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1344,6 +1351,10 @@ static int resolve_squash_range(struct repository *repo,\n \twhile ((commit = get_revision(&revs))) {\n \t\tstruct commit_list *p;\n \n+\t\tif (edit_message)\n+\t\t\tstrbuf_addf(&todo_buf, \"pick %s\\n\",\n+\t\t\t\t    oid_to_hex(&commit->object.oid));\n+\n \t\tif (!commit->parents) {\n \t\t\tret = error(_(\"cannot squash down to root commit\"));\n \t\t\tgoto out;\n@@ -1457,6 +1468,13 @@ static int resolve_squash_range(struct repository *repo,\n \t\tstring_list_clear(&sorting_options, 0);\n \t\tgoto out;\n \t}\n+\tif (edit_message) {\n+\t\tstrbuf_reset(&subject_data.squash_message);\n+\t\tret = build_squash_message(repo, &todo_buf,\n+\t\t\t\t\t   &subject_data.squash_message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n \n \t*oldest_out = oldest;\n \t*tip_out = tip;\n@@ -1464,6 +1482,7 @@ static int resolve_squash_range(struct repository *repo,\n \tret = 0;\n \n out:\n+\tstrbuf_release(&todo_buf);\n \tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP |\n \t\t\t   SQUASH_AMEND_TARGET);\n \tif (walk_started)\n@@ -1475,6 +1494,76 @@ out:\n \treturn ret;\n }\n \n+static bool amend_replaces_target(struct todo_list *todo, int target)\n+{\n+\tfor (int i = target + 1; i < todo->nr &&\n+\t\t\t\t todo->items[i].command != TODO_PICK; i++) {\n+\t\tif (todo->items[i].command == TODO_SQUASH)\n+\t\t\treturn false;\n+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tconst struct strbuf *todo_buf,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tint nr_commits, ret;\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo_buf->buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not prepare the squash message\"));\n+\t\tgoto out;\n+\t}\n+\n+\tnr_commits = todo.nr;\n+\tfor (int i = 0; i < nr_commits; i++) {\n+\t\tstruct todo_item *item = &todo.items[i];\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tbool skip, squashing;\n+\n+\t\tsquashing = item->command == TODO_SQUASH ||\n+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n+\t\tif (item->command == TODO_PICK)\n+\t\t\tskip = amend_replaces_target(&todo, i);\n+\t\telse\n+\t\t\tskip = !squashing;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tif (skip)\n+\t\t\tcommented_len = strlen(body);\n+\t\telse if (squashing)\n+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n+\t\telse\n+\t\t\tcommented_len = 0;\n+\n+\t\tif (!i)\n+\t\t\tadd_squash_combination_header(out, nr_commits);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, i + 1, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1519,14 +1608,11 @@ static int cmd_history_squash(int argc,\n \tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n \n \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n+\t\t\t\t   edit,\n \t\t\t\t   argc, argv, &oldest, &tip,\n \t\t\t\t   &message_template);\n \tif (ret < 0)\n \t\tgoto out;\n-\tif (edit) {\n-\t\tret = error(_(\"message editing is not supported yet; use '--no-edit'\"));\n-\t\tgoto out;\n-\t}\n \n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n@@ -1538,7 +1624,8 @@ static int cmd_history_squash(int argc,\n \n \tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n \t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n-\t\t\t      &rewritten, 0);\n+\t\t\t      &rewritten,\n+\t\t\t      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n \t\tgoto out;\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex fb06637aaf..d21e9d9fc4 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -381,6 +381,203 @@ test_expect_success 'squashing fixups into a merge' '\n \tsed 1,2d msg | test_commit_message HEAD\n '\n \n+test_expect_success 'edits every message and aborts on an empty result' '\n+\tgit reset --hard start &&\n+\tstage_file b &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script empty-editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/empty-editor\" &&\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"Aborting commit due to empty commit message\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tcombined\n+\tEOF\n+'\n+\n+test_expect_success 'handles fixup!, squash! and amend! like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\tstage_file e &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tmark-base\n+\n+\tsquash remark\n+\n+\tamended message\n+\n+\tEOF\n+'\n+\n+test_expect_success 'groups fixups under their targets in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag alpha file a1 &&\n+\ttest_commit --no-tag beta file b1 &&\n+\tstage_file a2 &&\n+\tcommit_with_message \"fixup! alpha\\n\" &&\n+\tstage_file b2 &&\n+\tcommit_with_message \"fixup! beta\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\talpha\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! alpha\n+\n+\t# This is the commit message #3:\n+\n+\tbeta\n+\n+\t# The commit message #4 will be skipped:\n+\n+\t# fixup! beta\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited\n+'\n+\n+test_expect_success 'lets amend! replace its target message in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# The 1st commit message will be skipped:\n+\n+\t# mark-base\n+\n+\t# This is the commit message #2:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended message\n+\n+\tsquash remark\n+\n+\tEOF\n+'\n+\n test_expect_success '--update-refs=head only moves HEAD' '\n \tgit reset --hard three &&\n \tgit branch -f other HEAD &&\n-- \ngitgitgadget\n"},{"id":"550940","messageId":"xmqq4igov9h9.fsf@gitster.g","threadId":"65812","inReplyTo":"03528d3b34c202b990cc42865a009a5786255b7c.1787249432.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v14 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-20T20:10:42Z","receivedAt":"2026-08-20T20:10:45Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> +\t/*\n> +\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n> +\t * \"fixup! main\". If the target is not being squshed check the subject\n> +\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n> +\t * squashed together.\n> +\t */\n> +\ttarget = lookup_commit_reference_by_name(s);\n> +\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n\nWhy istarts_with()?  \"fixup! ABCdef\" should not be accepted, should it?\n"},{"id":"551115","messageId":"29ada18c-b849-4bc3-aad3-b4fdc09c81f9@gmail.com","threadId":"65812","inReplyTo":"xmqq4igov9h9.fsf@gitster.g","subject":"Re: [PATCH v14 7/8] history: create squashed commits without editing","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-24T09:18:27Z","receivedAt":"2026-08-24T09:18:37Z","isPatch":true,"body":"On 20/08/2026 21:10, Junio C Hamano wrote:\n> \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n> \n>> +\t/*\n>> +\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n>> +\t * \"fixup! main\". If the target is not being squshed check the subject\n>> +\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n>> +\t * squashed together.\n>> +\t */\n>> +\ttarget = lookup_commit_reference_by_name(s);\n>> +\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n> \n> Why istarts_with()?  \"fixup! ABCdef\" should not be accepted, should it?\n\nI agree there isn't really a compelling case for mixed case oids, but \naccepting all uppercase, or all lowercase seems reasonable, or are we \nplanning to completely ban uppercase oids as brain has suggested? The \naim here is to accept any oid that rebase would, but not accept ref names.\n\nThanks\n\nPhillip\n\n"},{"id":"551136","messageId":"xmqqbjarsgjd.fsf@gitster.g","threadId":"65812","inReplyTo":"29ada18c-b849-4bc3-aad3-b4fdc09c81f9@gmail.com","subject":"Re: [PATCH v14 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-24T15:07:50Z","receivedAt":"2026-08-24T15:07:54Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> On 20/08/2026 21:10, Junio C Hamano wrote:\n>> \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n>> \n>>> +\t/*\n>>> +\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n>>> +\t * \"fixup! main\". If the target is not being squshed check the subject\n>>> +\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n>>> +\t * squashed together.\n>>> +\t */\n>>> +\ttarget = lookup_commit_reference_by_name(s);\n>>> +\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n>> \n>> Why istarts_with()?  \"fixup! ABCdef\" should not be accepted, should it?\n>\n> I agree there isn't really a compelling case for mixed case oids, but \n> accepting all uppercase, or all lowercase seems reasonable, or are we \n\nTell that to brian who wrote the bc/restrict-hex-to-lowercase topic\nin <20260729233215.398654-7-sandals@crustytoothpaste.net>.\n\n> planning to completely ban uppercase oids as brain has suggested? The \n> aim here is to accept any oid that rebase would, but not accept ref names.\n\nI understand that we never emitted hexadecimal containing uppercase\nletters ourselves, so 'commit --fixup' and friends wouldn't have\nadded anything that requires istarts_with().  So accepting any oid\nthat we would have given the users after \"fixup!\" but not refnames\ncan be achieved by starts_with() just fine, no?\n"},{"id":"551171","messageId":"39664e65-6997-40d4-83d4-1d2cae27ac50@gmail.com","threadId":"65812","inReplyTo":"xmqqbjarsgjd.fsf@gitster.g","subject":"Re: [PATCH v14 7/8] history: create squashed commits without editing","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-25T09:08:17Z","receivedAt":"2026-08-25T09:08:27Z","isPatch":true,"body":"On 24/08/2026 16:07, Junio C Hamano wrote:\n> Phillip Wood <phillip.wood123@gmail.com> writes:\n> \n>> On 20/08/2026 21:10, Junio C Hamano wrote:\n>>> \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n>>>\n>>>> +\t/*\n>>>> +\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n>>>> +\t * \"fixup! main\". If the target is not being squshed check the subject\n>>>> +\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n>>>> +\t * squashed together.\n>>>> +\t */\n>>>> +\ttarget = lookup_commit_reference_by_name(s);\n>>>> +\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n>>>\n>>> Why istarts_with()?  \"fixup! ABCdef\" should not be accepted, should it?\n>>\n>> I agree there isn't really a compelling case for mixed case oids, but\n>> accepting all uppercase, or all lowercase seems reasonable, or are we\n> \n> Tell that to brian who wrote the bc/restrict-hex-to-lowercase topic\n> in <20260729233215.398654-7-sandals@crustytoothpaste.net>.\n> >> planning to completely ban uppercase oids as brain has suggested? The\n>> aim here is to accept any oid that rebase would, but not accept ref names.\n> \n> I understand that we never emitted hexadecimal containing uppercase\n> letters ourselves, so 'commit --fixup' and friends wouldn't have\n> added anything that requires istarts_with().  So accepting any oid\n> that we would have given the users after \"fixup!\" but not refnames\n> can be achieved by starts_with() just fine, no?\n\nIf they're using \"git commit --fixup\" then yes. If we're going to reject \nuppercase hex everywhere then clearly we should be using starts_with() \nhere, but if we keep accepting uppercase hex elsewhere why should we \nreject it here?\n\nThanks\n\nPhillip\n\n"},{"id":"551200","messageId":"xmqqik4ym81k.fsf@gitster.g","threadId":"65812","inReplyTo":"39664e65-6997-40d4-83d4-1d2cae27ac50@gmail.com","subject":"Re: [PATCH v14 7/8] history: create squashed commits without editing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-25T17:20:07Z","receivedAt":"2026-08-25T17:20:10Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> On 24/08/2026 16:07, Junio C Hamano wrote:\n>> Phillip Wood <phillip.wood123@gmail.com> writes:\n>> \n>>> On 20/08/2026 21:10, Junio C Hamano wrote:\n>>>> \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n>>>>\n>>>>> +\t/*\n>>>>> +\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n>>>>> +\t * \"fixup! main\". If the target is not being squshed check the subject\n>>>>> +\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n>>>>> +\t * squashed together.\n>>>>> +\t */\n>>>>> +\ttarget = lookup_commit_reference_by_name(s);\n>>>>> +\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n>>>>\n>>>> Why istarts_with()?  \"fixup! ABCdef\" should not be accepted, should it?\n>>>\n>>> I agree there isn't really a compelling case for mixed case oids, but\n>>> accepting all uppercase, or all lowercase seems reasonable, or are we\n>> \n>> Tell that to brian who wrote the bc/restrict-hex-to-lowercase topic\n>> in <20260729233215.398654-7-sandals@crustytoothpaste.net>.\n>> >> planning to completely ban uppercase oids as brain has suggested? The\n>>> aim here is to accept any oid that rebase would, but not accept ref names.\n>> \n>> I understand that we never emitted hexadecimal containing uppercase\n>> letters ourselves, so 'commit --fixup' and friends wouldn't have\n>> added anything that requires istarts_with().  So accepting any oid\n>> that we would have given the users after \"fixup!\" but not refnames\n>> can be achieved by starts_with() just fine, no?\n>\n> If they're using \"git commit --fixup\" then yes. If we're going to reject \n> uppercase hex everywhere then clearly we should be using starts_with() \n> here, but if we keep accepting uppercase hex elsewhere why should we \n> reject it here?\n\nThe point is not to add any *new* places that accept uppercase hex,\nand this is to participate in the move to discourge the use of\nuppercase hex.\n"},{"id":"552251","messageId":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.git.git.1781465141.gitgitgadget@gmail.com","subject":"[PATCH v15 0/8] history: add squash subcommand to fold a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:51Z","receivedAt":"2026-09-08T20:42:02Z","isPatch":true,"body":"Adds git history squash <revision-range> to fold a range of commits.\n\nChanges in v15:\n\n * Fixed istarts_with() → starts_with() in squash_check_autosquash_subject()\n   so fixup! ABCdef (mixed/upper-case hex) is no longer accepted.\n * Fixed a typo in the adjacent comment (squshed → squashed).\n\nChanges in v14:\n\n * Update commit message trailers.\n * Simplify rev-parse and tr complexity in the tests.\n\nChanges in v13:\n\n * Split the squash implementation into five patches covering parsing, range\n   validation, branch protection, commit creation, and message editing.\n * Print the sorted names of local branches that prevent a squash, with\n   clearer advice for --update-refs=head.\n * Isolate --no-edit behavior from the final patch that enables default\n   editor-based message combining.\n\nChanges in v12:\n\n * Incorporated fixups from Phillip:\n   * Reworks range validation into a single walk that rejects roots and\n     multiple tips while preserving every parent when squashing into a\n     merge.\n   * Resolves fixup!, squash! and amend! targets directly, preserving\n     message intent and safely consolidating related markers.\n   * Builds the editor template from the exact selected revisions, including\n     exclusions, while retaining --no-edit behavior.\n   * Protects descendant local branches while leaving tags and\n     remote-tracking refs unchanged.\n\nChanges in v11:\n\n * Make message editing the default with the autosquash-style template, add\n   --no-edit instead of squash-specific --reedit-message.\n * Validate one actual boundary and tip, rejecting root-reaching and\n   multi-tip ranges.\n * Protect only interior local branches, leaving tags and remote-tracking\n   refs unchanged.\n * Move sequencer preparation before squash and fold the later\n   message-editing patch into the feature commit.\n\nChanges in v10:\n\n * Record the full revision expression in squash reflog.\n * Preserve the boundary-walk invariant when sanitizing rev-list options.\n * Clarify amend! and --reedit-message documentation.\n\nChanges in v9:\n\n * Use the last amend! targeting the oldest folded commit as the default\n   squashed message. Ignore amend! markers targeting later commits while\n   selecting that replacement message.\n * Improve tests.\n\nChanges in v8:\n\n * --reedit-message now builds the same editor template as git rebase -i\n   --autosquash: fixup!, squash! and amend! commits are grouped under the\n   commit they target instead of shown in commit order, and an amend!\n   replaces its target's message.\n * A fixup!, squash! or amend! is refused only when its target is outside\n   the range, so several fixups for an in-range commit fold together. A\n   range that is entirely markers for one below-range target is combined\n   into a single commit, keeping the last amend! message.\n * Merges inside the range are folded when the range has a single base, with\n   no dedicated opt-in flag, --ancestry-path ensures only commits descended\n   from the base are folded, and a range reaching more than one base is\n   rejected.\n * Rev-list options are accepted and sanitized the way git replay does,\n   forcing the walk order back with a warning, which also fixes git history\n   squash -- --reverse slipping past the previous option check.\n * Kept this as an explicit squash subcommand rather than making\n   --reedit-message the default or renaming the command.\n\nChanges in v7:\n\n * --reedit-message now builds the same editor template git rebase -i shows\n   for a squash (a combination of N commits banner with each folded message\n   under its own header) and follows autosquash for markers: a fixup!\n   message falls out (commented under a will be skipped header), while a\n   squash! or amend! keeps its body with only the marker subject commented\n   so its remark can be reworded in. Only the message text is affected,\n   every commit's changes are always folded in.\n * Reuse git rebase -i's squash-message code: a preparatory sequencer:\n   commit extracts the banner, header and marker-comment helpers so both\n   rebase and git history squash build the identical template from one\n   source.\n * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since\n   the marker's target cannot be inside the range.\n * Reorder the squash usage so dashed options come before <revision-range>,\n   and spell out HEAD instead of @ in the documentation and examples.\n * Expand the squash commit message and documentation with this overview,\n   and scope the merge limitation so it no longer contradicts squash folding\n   a single-base interior merge.\n\nChanges in v6:\n\n * git history squash now accepts multiple revision arguments, read like the\n   arguments to git-rev-list, so a compound range such as @~3.. ^topic\n   works.\n * The base to reparent onto is now the oldest in-range commit's parent; a\n   boundary other than that base means the range has more than one base and\n   is rejected. This also fixes the earlier overly-restrictive handling of\n   merges and side branches.\n * A single-commit range (e.g. @^!) is rejected with \"nothing to squash\"\n   (this also covers the @^!-style example that previously succeeded\n   silently).\n * Commit messages reworded: the squash commit now gives an overview of\n   fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.\n\nChanges in v5:\n\n * The range walk now uses --ancestry-path, so only commits descended from\n   the base are folded; a single revision such as HEAD or HEAD~1 is now\n   rejected as \"not a <base>..<tip> range\" rather than treated as a squash\n   down to the root.\n * This adopts the --ancestry-path suggestion; the multi-base rejection is\n   unchanged, so a side branch that forked before the base and merged in is\n   still refused.\n * Added tests covering more merge topologies: two interior merges, a nested\n   merge, an octopus merge, an octopus arm forked before the base, a merge\n   among the descendants replayed above the range, and a ref pointing at an\n   interior merge commit.\n\nChanges in v4:\n\n * git history squash now detects when another ref points at a commit inside\n   the range being folded and refuses, with an advice.historyUpdateRefs hint\n   to use --update-refs=head.\n * A merge inside the range is folded fine as long as the range has a single\n   base; a range with merge commit at the tip or base also folds correctly.\n   Only a range with more than one base is rejected.\n\nChanges in v3:\n\n * Moved the feature out of git rebase and into a new git history squash\n   <revision-range> subcommand, per the list discussion. git rebase --squash\n   is dropped.\n * Takes an arbitrary range (git history squash @~3.., git history squash\n   @~5..@~2), folding it into the oldest commit and replaying any\n   descendants on top.\n * Implemented as a single tree operation rather than picking each commit,\n   so there are no repeated conflict stops (addresses Phillip's efficiency\n   point).\n * A merge inside the range is folded fine, only a range with more than one\n   base is rejected.\n * --reedit-message seeds the editor with every folded-in message, not just\n   the oldest.\n\nHarald Nordgren (8):\n  history: extract helper for a commit's parent tree\n  history: give commit_tree_ext a message template\n  sequencer: share the squash message marker helpers and flags\n  history: add skeleton for squash subcommand\n  history: validate squash revision ranges\n  history: protect branches when squashing a range\n  history: create squashed commits without editing\n  history: support editing squashed commit messages\n\n Documentation/config/advice.adoc |   4 +\n Documentation/git-history.adoc   |  59 +-\n advice.c                         |   1 +\n advice.h                         |   1 +\n builtin/history.c                | 720 +++++++++++++++++++++++--\n object.h                         |   1 +\n sequencer.c                      |  70 +--\n sequencer.h                      |  30 ++\n t/meson.build                    |   1 +\n t/t3455-history-squash.sh        | 886 +++++++++++++++++++++++++++++++\n t/t9902-completion.sh            |   1 +\n 11 files changed, 1703 insertions(+), 71 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\n\nbase-commit: b8242b093d9e941a34460d715e3ce616a34ac3fe\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v15\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v15\nPull-Request: https://github.com/git/git/pull/2337\n\nRange-diff vs v14:\n\n 1:  19c5e311a3 = 1:  98a1b756bc history: extract helper for a commit's parent tree\n 2:  aadb7a5df4 = 2:  9d99354988 history: give commit_tree_ext a message template\n 3:  e1621f656e = 3:  b7a1796838 sequencer: share the squash message marker helpers and flags\n 4:  016ae0fd42 ! 4:  8ad1faaa9a history: add skeleton for squash subcommand\n     @@ builtin/history.c: int cmd_history(int argc,\n       \t\tOPT_END(),\n       \t};\n       \n     +\n     + ## t/t9902-completion.sh ##\n     +@@ t/t9902-completion.sh: test_expect_success 'git history subcommands' '\n     + \tfixup Z\n     + \treword Z\n     + \tsplit Z\n     ++\tsquash Z\n     + \tEOF\n     + \ttest_completion \"git history --\" \"\"\n     + '\n 5:  4992d14ce4 = 5:  4389729c83 history: validate squash revision ranges\n 6:  b862a5b4c3 = 6:  4d30ff1936 history: protect branches when squashing a range\n 7:  03528d3b34 ! 7:  96349a802e history: create squashed commits without editing\n     @@ builtin/history.c: out:\n      +\t}\n      +\t/*\n      +\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n     -+\t * \"fixup! main\". If the target is not being squshed check the subject\n     ++\t * \"fixup! main\". If the target is not being squashed check the subject\n      +\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n      +\t * squashed together.\n      +\t */\n      +\ttarget = lookup_commit_reference_by_name(s);\n     -+\tif (target && istarts_with(oid_to_hex(&target->object.oid), s)) {\n     ++\tif (target && starts_with(oid_to_hex(&target->object.oid), s)) {\n      +\t\tunsigned flags =\n      +\t\t\ttarget->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);\n      +\t\tif (!flags) {\n 8:  98aa3922cb = 8:  8da0333646 history: support editing squashed commit messages\n\n-- \ngitgitgadget\n"},{"id":"552252","messageId":"98a1b756bcf13cf47f71141633df57dd1336aaed.1788900119.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","subject":"[PATCH v15 1/8] history: extract helper for a commit's parent tree","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:52Z","receivedAt":"2026-09-08T20:42:02Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nThree places resolve the tree of a commit's first parent, falling back\nto the empty tree for a root commit, each repeating the same parse and\noidcpy dance. Extract a first_parent_tree_oid() helper and route the\nexisting callers through it.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 58 +++++++++++++++++++++--------------------------\n 1 file changed, 26 insertions(+), 32 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 000155ad9c..c22715d848 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -164,6 +164,25 @@ out:\n \treturn ret;\n }\n \n+static int first_parent_tree_oid(struct repository *repo,\n+\t\t\t\t struct commit *commit,\n+\t\t\t\t struct object_id *out)\n+{\n+\tstruct commit *parent = commit->parents ? commit->parents->item : NULL;\n+\n+\tif (!parent) {\n+\t\toidcpy(out, repo->hash_algo->empty_tree);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_parse_commit(repo, parent))\n+\t\treturn error(_(\"unable to parse parent commit %s\"),\n+\t\t\t     oid_to_hex(&parent->object.oid));\n+\n+\toidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);\n+\treturn 0;\n+}\n+\n static int commit_tree_with_edited_message(struct repository *repo,\n \t\t\t\t\t   const char *action,\n \t\t\t\t\t   struct commit *original,\n@@ -171,21 +190,11 @@ static int commit_tree_with_edited_message(struct repository *repo,\n {\n \tstruct object_id parent_tree_oid;\n \tconst struct object_id *tree_oid;\n-\tstruct commit *parent;\n \n \ttree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tparent = original->parents ? original->parents->item : NULL;\n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent)) {\n-\t\t\treturn error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t     oid_to_hex(&parent->object.oid));\n-\t\t}\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn commit_tree_ext(repo, action, original, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n@@ -475,18 +484,10 @@ static int commit_became_empty(struct repository *repo,\n \t\t\t       struct commit *original,\n \t\t\t       struct tree *result)\n {\n-\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n \tstruct object_id parent_tree_oid;\n \n-\tif (parent) {\n-\t\tif (repo_parse_commit(repo, parent))\n-\t\t\treturn error(_(\"unable to parse parent of %s\"),\n-\t\t\t\t     oid_to_hex(&original->object.oid));\n-\n-\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n-\t}\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n+\t\treturn -1;\n \n \treturn oideq(&result->object.oid, &parent_tree_oid);\n }\n@@ -830,16 +831,9 @@ static int split_commit(struct repository *repo,\n \tstruct tree *split_tree;\n \tint ret;\n \n-\tif (original->parents) {\n-\t\tif (repo_parse_commit(repo, original->parents->item)) {\n-\t\t\tret = error(_(\"unable to parse parent commit %s\"),\n-\t\t\t\t    oid_to_hex(&original->parents->item->object.oid));\n-\t\t\tgoto out;\n-\t\t}\n-\n-\t\tparent_tree_oid = *get_commit_tree_oid(original->parents->item);\n-\t} else {\n-\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n \t}\n \toriginal_commit_tree_oid = get_commit_tree_oid(original);\n \n-- \ngitgitgadget\n\n"},{"id":"552253","messageId":"9d99354988def36e76d27419975fe2424c799b72.1788900119.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","subject":"[PATCH v15 2/8] history: give commit_tree_ext a message template","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:53Z","receivedAt":"2026-09-08T20:42:04Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ncommit_tree_ext() reuses the message of the commit it is handed. A\ncaller that folds several commits together wants to seed the message\nfrom more than that single commit, so add an optional message_template\nparameter. When NULL, the behavior is unchanged.\n\nPass NULL from the existing fixup and split callers.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c | 16 ++++++++++------\n 1 file changed, 10 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex c22715d848..a60e8dbcd0 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -108,6 +108,7 @@ enum commit_tree_flags {\n static int commit_tree_ext(struct repository *repo,\n \t\t\t   const char *action,\n \t\t\t   struct commit *commit_with_message,\n+\t\t\t   const char *message_template,\n \t\t\t   const struct commit_list *parents,\n \t\t\t   const struct object_id *old_tree,\n \t\t\t   const struct object_id *new_tree,\n@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n+\tif (!message_template)\n+\t\tmessage_template = original_body;\n+\n \tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n \t\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t\t  original_body, action, &commit_message);\n+\t\t\t\t\t  message_template, action, &commit_message);\n \t\tif (ret < 0)\n \t\t\tgoto out;\n \t} else {\n-\t\tstrbuf_addstr(&commit_message, original_body);\n+\t\tstrbuf_addstr(&commit_message, message_template);\n \t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \tif (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)\n \t\treturn -1;\n \n-\treturn commit_tree_ext(repo, action, original, original->parents,\n+\treturn commit_tree_ext(repo, action, original, NULL, original->parents,\n \t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n@@ -675,7 +679,7 @@ static int cmd_history_fixup(int argc,\n \t\tgoto out;\n \n \tif (!skip_commit) {\n-\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, NULL, original->parents,\n \t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n \t\t\t\t      &rewritten, flags);\n \t\tif (ret < 0) {\n@@ -886,7 +890,7 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, original->parents, &parent_tree_oid,\n \t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n@@ -903,7 +907,7 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\tret = commit_tree_ext(repo, \"split-out\", original, NULL, parents, old_tree_oid,\n \t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n-- \ngitgitgadget\n\n"},{"id":"552254","messageId":"b7a1796838099d0fa2c9782f614c8db70d516a53.1788900119.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","subject":"[PATCH v15 3/8] sequencer: share the squash message marker helpers and flags","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:54Z","receivedAt":"2026-09-08T20:42:05Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWhen \"git rebase -i\" squashes commits it builds an editor template with a\n\"This is a combination of N commits.\" banner, a \"This is the 1st/Nth\ncommit message:\" header above each kept message (or a \"will be skipped\"\nheader for a dropped one), and a commented-out subject for any fixup!,\nsquash! or amend! commit. The banner, the headers and the\nsubject-commenting all live in static helpers in sequencer.c wired to the\nrebase state, so no other command can present a squash the same way.\n\nPull the three pieces out into add_squash_combination_header(),\nadd_squash_message_header() (which takes a flag for the \"will be skipped\"\nvariant) and squash_subject_comment_len(), and use them from\nupdate_squash_messages() and append_squash_message(). Also move the\ntodo_item_flags enum to the header, so a caller reading the output of\ntodo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)\nfrom a plain fixup!. A later change reuses all of this to give \"git\nhistory squash\" the same template.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n sequencer.c | 70 +++++++++++++++++++++++++++++------------------------\n sequencer.h | 30 +++++++++++++++++++++++\n 2 files changed, 69 insertions(+), 31 deletions(-)\n\ndiff --git a/sequencer.c b/sequencer.c\nindex 65afd100d9..557149d62f 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -1881,18 +1881,38 @@ static int is_pick_or_similar(enum todo_command command)\n \t}\n }\n \n-enum todo_item_flags {\n-\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n-\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n-\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n-};\n-\n static const char first_commit_msg_str[] = N_(\"This is the 1st commit message:\");\n static const char nth_commit_msg_fmt[] = N_(\"This is the commit message #%d:\");\n static const char skip_first_commit_msg_str[] = N_(\"The 1st commit message will be skipped:\");\n static const char skip_nth_commit_msg_fmt[] = N_(\"The commit message #%d will be skipped:\");\n static const char combined_commit_msg_fmt[] = N_(\"This is a combination of %d commits.\");\n \n+void add_squash_combination_header(struct strbuf *buf, int n)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tstrbuf_addf(buf, _(combined_commit_msg_fmt), n);\n+}\n+\n+void add_squash_message_header(struct strbuf *buf, int n, int skip)\n+{\n+\tstrbuf_addf(buf, \"%s \", comment_line_str);\n+\tif (n == 1)\n+\t\tstrbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :\n+\t\t\t\t   _(first_commit_msg_str));\n+\telse\n+\t\tstrbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :\n+\t\t\t    _(nth_commit_msg_fmt), n);\n+}\n+\n+size_t squash_subject_comment_len(const char *body, int squashing)\n+{\n+\tif (starts_with(body, \"amend!\") ||\n+\t    (squashing && (starts_with(body, \"squash!\") ||\n+\t\t\t   starts_with(body, \"fixup!\"))))\n+\t\treturn commit_subject_length(body);\n+\treturn 0;\n+}\n+\n static int is_fixup_flag(enum todo_command command, unsigned flag)\n {\n \treturn command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||\n@@ -2013,20 +2033,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,\n {\n \tstruct replay_ctx *ctx = opts->ctx;\n \tconst char *fixup_msg;\n-\tsize_t commented_len = 0, fixup_off;\n-\t/*\n-\t * amend is non-interactive and not normally used with fixup!\n-\t * or squash! commits, so only comment out those subjects when\n-\t * squashing commit messages.\n-\t */\n-\tif (starts_with(body, \"amend!\") ||\n-\t    ((command == TODO_SQUASH || seen_squash(ctx)) &&\n-\t     (starts_with(body, \"squash!\") || starts_with(body, \"fixup!\"))))\n-\t\tcommented_len = commit_subject_length(body);\n+\tsize_t commented_len, fixup_off;\n+\n+\tcommented_len = squash_subject_comment_len(body,\n+\t\t\t\tcommand == TODO_SQUASH || seen_squash(ctx));\n \n-\tstrbuf_addf(buf, \"\\n%s \", comment_line_str);\n-\tstrbuf_addf(buf, _(nth_commit_msg_fmt),\n-\t\t    ++ctx->current_fixup_count + 1);\n+\tstrbuf_addch(buf, '\\n');\n+\tadd_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);\n \tstrbuf_addstr(buf, \"\\n\\n\");\n \tstrbuf_add_commented_lines(buf, body, commented_len, comment_line_str);\n \t/* buf->buf may be reallocated so store an offset into the buffer */\n@@ -2091,9 +2104,8 @@ static int update_squash_messages(struct repository *r,\n \t\teol = !starts_with(buf.buf, comment_line_str) ?\n \t\t\tbuf.buf : strchrnul(buf.buf, '\\n');\n \n-\t\tstrbuf_addf(&header, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&header, _(combined_commit_msg_fmt),\n-\t\t\t    ctx->current_fixup_count + 2);\n+\t\tadd_squash_combination_header(&header,\n+\t\t\t\t\t      ctx->current_fixup_count + 2);\n \t\tstrbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);\n \t\tstrbuf_release(&header);\n \t\tif (is_fixup_flag(command, flag) && !seen_squash(ctx))\n@@ -2117,12 +2129,9 @@ static int update_squash_messages(struct repository *r,\n \t\t\trepo_unuse_commit_buffer(r, head_commit, head_message);\n \t\t\treturn error(_(\"cannot write '%s'\"), rebase_path_fixup_msg());\n \t\t}\n-\t\tstrbuf_addf(&buf, \"%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(combined_commit_msg_fmt), 2);\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addstr(&buf, is_fixup_flag(command, flag) ?\n-\t\t\t      _(skip_first_commit_msg_str) :\n-\t\t\t      _(first_commit_msg_str));\n+\t\tadd_squash_combination_header(&buf, 2);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, 1, is_fixup_flag(command, flag));\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tif (is_fixup_flag(command, flag))\n \t\t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n@@ -2141,9 +2150,8 @@ static int update_squash_messages(struct repository *r,\n \tif (command == TODO_SQUASH || is_fixup_flag(command, flag)) {\n \t\tres = append_squash_message(&buf, body, command, opts, flag);\n \t} else if (command == TODO_FIXUP) {\n-\t\tstrbuf_addf(&buf, \"\\n%s \", comment_line_str);\n-\t\tstrbuf_addf(&buf, _(skip_nth_commit_msg_fmt),\n-\t\t\t    ++ctx->current_fixup_count + 1);\n+\t\tstrbuf_addch(&buf, '\\n');\n+\t\tadd_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);\n \t\tstrbuf_addstr(&buf, \"\\n\\n\");\n \t\tstrbuf_add_commented_lines(&buf, body, strlen(body),\n \t\t\t\t\t   comment_line_str);\ndiff --git a/sequencer.h b/sequencer.h\nindex 64a9c7fb1b..b01f897020 100644\n--- a/sequencer.h\n+++ b/sequencer.h\n@@ -119,6 +119,13 @@ enum todo_command {\n \tTODO_COMMENT\n };\n \n+/* Bits for the \"flags\" member of struct todo_item */\n+enum todo_item_flags {\n+\tTODO_EDIT_MERGE_MSG    = (1 << 0),\n+\tTODO_REPLACE_FIXUP_MSG = (1 << 1),\n+\tTODO_EDIT_FIXUP_MSG    = (1 << 2),\n+};\n+\n struct todo_item {\n \tenum todo_command command;\n \tstruct commit *commit;\n@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);\n  */\n void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);\n \n+/*\n+ * Append the \"This is a combination of N commits.\" banner that \"git rebase\n+ * -i\" writes at the top of a squashed commit's message, commented out with\n+ * the comment character.\n+ */\n+void add_squash_combination_header(struct strbuf *buf, int n);\n+\n+/*\n+ * Append the header (1-based N) that \"git rebase -i\" writes above each message\n+ * when squashing, commented out with the comment character. With SKIP it reads\n+ * \"The ... commit message will be skipped\" for a message that is dropped (a\n+ * fixup), otherwise \"This is the ... commit message\".\n+ */\n+void add_squash_message_header(struct strbuf *buf, int n, int skip);\n+\n+/*\n+ * Return the length of the leading subject of BODY when it should be commented\n+ * out in a squash message, or 0 otherwise. An \"amend!\" subject always\n+ * qualifies; \"squash!\" and \"fixup!\" subjects only when SQUASHING, since a\n+ * plain fixup chain keeps them.\n+ */\n+size_t squash_subject_comment_len(const char *body, int squashing);\n+\n void append_conflicts_hint(struct index_state *istate,\n \t\tstruct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);\n enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,\n-- \ngitgitgadget\n\n"},{"id":"552255","messageId":"8ad1faaa9a4ab71560b4e9321886706164518e9e.1788900120.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","subject":"[PATCH v15 4/8] history: add skeleton for squash subcommand","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:55Z","receivedAt":"2026-09-08T20:42:05Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd the entry point and option parsing for \"git history squash\". Pass\nthe remaining arguments through setup_revisions() so the command accepts\nrevision ranges and rev-list options, while restoring the ordering and\nsimplification settings required by the fold if an option changes them.\n\nRequire at least one BOTTOM revision. The squashed commit needs a commit\noutside the selected range to serve as its base, so a single positive\nrevision is not a sufficient range.\n\nKeep this step limited to defining the revision input contract so graph\nvalidation and the rewrite can be added independently.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  1 +\n builtin/history.c              | 94 ++++++++++++++++++++++++++++++++++\n t/t9902-completion.sh          |  1 +\n 3 files changed, 96 insertions(+)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 28b477cd37..b660baf94d 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(\n git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\n \n DESCRIPTION\n -----------\ndiff --git a/builtin/history.c b/builtin/history.c\nindex a60e8dbcd0..4f5a7a46ce 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -34,6 +34,8 @@\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n \tN_(\"git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\")\n+#define GIT_HISTORY_SQUASH_USAGE \\\n+\tN_(\"git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>\")\n \n static void change_data_free(void *util, const char *str UNUSED)\n {\n@@ -1004,6 +1006,96 @@ out:\n \treturn ret;\n }\n \n+static int setup_squash_revisions(struct repository *repo,\n+\t\t\t\t  int argc, const char **argv,\n+\t\t\t\t  struct rev_info *revs)\n+{\n+\trepo_init_revisions(repo, revs, NULL);\n+\trevs->reverse = 1;\n+\trevs->topo_order = 1;\n+\trevs->sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\trevs->simplify_history = 0;\n+\trevs->ancestry_path = 1;\n+\trevs->limited = 1;\n+\trevs->ancestry_path_implicit_bottoms = 1;\n+\n+\targc = setup_revisions(argc, argv, revs, NULL);\n+\tif (argc > 1)\n+\t\treturn error(_(\"unrecognized argument: %s\"), argv[1]);\n+\n+\tif (revs->reverse != 1 || revs->topo_order != 1 ||\n+\t    revs->sort_order != REV_SORT_IN_GRAPH_ORDER ||\n+\t    revs->simplify_history != 0 || revs->boundary == 1 ||\n+\t    revs->ancestry_path != 1 || revs->limited != 1 ||\n+\t    revs->ancestry_path_implicit_bottoms != 1) {\n+\t\twarning(_(\"ignoring rev-list options that would change how the \"\n+\t\t\t  \"range is walked\"));\n+\t\trevs->reverse = 1;\n+\t\trevs->topo_order = 1;\n+\t\trevs->sort_order = REV_SORT_IN_GRAPH_ORDER;\n+\t\trevs->simplify_history = 0;\n+\t\trevs->boundary = 0;\n+\t\trevs->ancestry_path = 1;\n+\t\trevs->limited = 1;\n+\t\trevs->ancestry_path_implicit_bottoms = 1;\n+\t}\n+\n+\t/*\n+\t * A squash needs a base to reparent onto, so the range has to exclude\n+\t * something, as in \"<base>..<tip>\". A revision range with no such\n+\t * bottom commit cannot be squashed.\n+\t */\n+\tfor (size_t i = 0; i < revs->cmdline.nr; i++)\n+\t\tif (revs->cmdline.rev[i].flags & BOTTOM)\n+\t\t\treturn 0;\n+\n+\treturn error(_(\"not a '<base>..<tip>' revision range\"));\n+}\n+\n+static int cmd_history_squash(int argc,\n+\t\t\t      const char **argv,\n+\t\t\t      const char *prefix,\n+\t\t\t      struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tint dry_run = 0;\n+\tint edit = 1;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BOOL('e', \"edit\", &edit,\n+\t\t\t N_(\"edit the commit message\")),\n+\t\tOPT_END(),\n+\t};\n+\tstruct rev_info revs = { 0 };\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage,\n+\t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n+\tif (argc < 2) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tret = error(_(\"squashing commits is not implemented yet\"));\n+\n+out:\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int update_worktree(struct repository *repo,\n \t\t\t   const struct commit *old_head,\n \t\t\t   const struct commit *new_head,\n@@ -1192,6 +1284,7 @@ int cmd_history(int argc,\n \t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n+\t\tGIT_HISTORY_SQUASH_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n@@ -1200,6 +1293,7 @@ int cmd_history(int argc,\n \t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n+\t\tOPT_SUBCOMMAND(\"squash\", &fn, cmd_history_squash),\n \t\tOPT_END(),\n \t};\n \ndiff --git a/t/t9902-completion.sh b/t/t9902-completion.sh\nindex 38cf221718..978c42c629 100755\n--- a/t/t9902-completion.sh\n+++ b/t/t9902-completion.sh\n@@ -3227,6 +3227,7 @@ test_expect_success 'git history subcommands' '\n \tfixup Z\n \treword Z\n \tsplit Z\n+\tsquash Z\n \tEOF\n \ttest_completion \"git history --\" \"\"\n '\n-- \ngitgitgadget\n\n"},{"id":"552256","messageId":"4389729c8340a01c8e2e4165254bfc50b1fe1093.1788900120.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","subject":"[PATCH v15 5/8] history: validate squash revision ranges","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:56Z","receivedAt":"2026-09-08T20:42:07Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWalk the selected commits in topological order from oldest to newest and\nmark each one as it is seen. Every parent after the oldest commit must\nalready be selected or also be a parent of the oldest commit. This\naccepts merges contained by the range while rejecting a merge arm that\nentered it from elsewhere.\n\nTrack the remaining graph tips during the same walk and require exactly\none. Also reject empty and single-commit ranges and any selection that\nreaches a root commit. These checks identify the oldest commit whose\nparents will be preserved and the single tip whose tree will be used by\nthe rewrite.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/history.c         | 119 ++++++++++++++++++++++++++++++++++----\n object.h                  |   1 +\n t/meson.build             |   1 +\n t/t3455-history-squash.sh |  65 +++++++++++++++++++++\n 4 files changed, 174 insertions(+), 12 deletions(-)\n create mode 100755 t/t3455-history-squash.sh\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 4f5a7a46ce..6541a397e8 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1006,6 +1006,9 @@ out:\n \treturn ret;\n }\n \n+#define SQUASH_SEEN (1u << 11)\n+#define SQUASH_TIP (1u << 12)\n+\n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n \t\t\t\t  struct rev_info *revs)\n@@ -1052,6 +1055,104 @@ static int setup_squash_revisions(struct repository *repo,\n \treturn error(_(\"not a '<base>..<tip>' revision range\"));\n }\n \n+/*\n+ * Resolve a revision range into its oldest commit and single tip. Every\n+ * parent after the oldest commit must either be selected or also be a parent\n+ * of the oldest commit.\n+ */\n+static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tint argc, const char **argv,\n+\t\t\t\tstruct commit **oldest_out,\n+\t\t\t\tstruct commit **tip_out)\n+{\n+\tstruct rev_info revs;\n+\tstruct commit *commit, *oldest = NULL, *tip = NULL;\n+\tint ret, tip_count = 0;\n+\tbool walk_started = false;\n+\n+\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tif (prepare_revision_walk(&revs) < 0) {\n+\t\tret = error(_(\"error preparing revisions\"));\n+\t\tgoto out;\n+\t}\n+\twalk_started = true;\n+\twhile ((commit = get_revision(&revs))) {\n+\t\tstruct commit_list *p;\n+\n+\t\tif (!commit->parents) {\n+\t\t\tret = error(_(\"cannot squash down to root commit\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t\tfor (p = commit->parents; oldest && p; p = p->next) {\n+\t\t\tstruct commit_list *q;\n+\t\t\tstruct object *o;\n+\t\t\tbool seen;\n+\n+\t\t\tif (repo_parse_commit(repo, p->item)) {\n+\t\t\t\tret = error(_(\"cannot parse commit\"));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\to = &p->item->object;\n+\t\t\tseen = o->flags & SQUASH_SEEN;\n+\t\t\t/*\n+\t\t\t * Allow parents that match the parents of the\n+\t\t\t * squashed commit.\n+\t\t\t */\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next)\n+\t\t\t\tif (p->item == q->item)\n+\t\t\t\t\tseen = true;\n+\t\t\tif (!seen) {\n+\t\t\t\tret = error(_(\"parent %s of commit %s is \"\n+\t\t\t\t\t      \"outside the revision range\"),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo, &o->oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV),\n+\t\t\t\t\t    repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t\t    &commit->object.oid,\n+\t\t\t\t\t\t\t\t    DEFAULT_ABBREV));\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\t\t\tif (o->flags & SQUASH_TIP) {\n+\t\t\t\ttip_count--;\n+\t\t\t\to->flags &= ~SQUASH_TIP;\n+\t\t\t}\n+\t\t}\n+\t\tif (!oldest)\n+\t\t\toldest = commit;\n+\t\ttip = commit;\n+\t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n+\t\ttip_count++;\n+\t}\n+\n+\tif (!tip_count) {\n+\t\tret = error(_(\"the revision range is empty\"));\n+\t\tgoto out;\n+\t} else if (tip_count != 1) {\n+\t\tret = error(_(\"the revision range contains more than one tip \"\n+\t\t\t      \"commit\"));\n+\t\tgoto out;\n+\t} else if (oldest == tip) {\n+\t\tret = error(_(\"the revision range holds a single commit; \"\n+\t\t\t      \"nothing to squash\"));\n+\t\tgoto out;\n+\t} else if (!oldest->parents) {\n+\t\tBUG(\"an in-range commit must have a parent\");\n+\t}\n+\n+\t*oldest_out = oldest;\n+\t*tip_out = tip;\n+\tret = 0;\n+\n+out:\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\tif (walk_started)\n+\t\treset_revision_walk();\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1074,26 +1175,20 @@ static int cmd_history_squash(int argc,\n \t\t\t N_(\"edit the commit message\")),\n \t\tOPT_END(),\n \t};\n-\tstruct rev_info revs = { 0 };\n+\tstruct commit *oldest, *tip;\n \tint ret;\n \n \targc = parse_options(argc, argv, prefix, options, usage,\n \t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n-\tif (argc < 2) {\n-\t\tret = error(_(\"command expects a revision range\"));\n-\t\tgoto out;\n-\t}\n+\tif (argc < 2)\n+\t\treturn error(_(\"command expects a revision range\"));\n \trepo_config(repo, git_default_config, NULL);\n \n-\tret = setup_squash_revisions(repo, argc, argv, &revs);\n+\tret = resolve_squash_range(repo, argc, argv, &oldest, &tip);\n \tif (ret < 0)\n-\t\tgoto out;\n-\n-\tret = error(_(\"squashing commits is not implemented yet\"));\n+\t\treturn ret;\n \n-out:\n-\trelease_revisions(&revs);\n-\treturn ret;\n+\treturn error(_(\"squashing commits is not implemented yet\"));\n }\n \n static int update_worktree(struct repository *repo,\ndiff --git a/object.h b/object.h\nindex 8fb03ff90a..dcf30156ca 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,6 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n+ * builtin/history.c:                    1112\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/meson.build b/t/meson.build\nindex 7f53cca7d1..ba6f33cdff 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -407,6 +407,7 @@ integration_tests = [\n   't3452-history-split.sh',\n   't3453-history-fixup.sh',\n   't3454-history-drop.sh',\n+  't3455-history-squash.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nnew file mode 100755\nindex 0000000000..df92aa4f6c\n--- /dev/null\n+++ b/t/t3455-history-squash.sh\n@@ -0,0 +1,65 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history squash subcommand'\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup linear history' '\n+\ttest_commit base file a start &&\n+\ttest_commit one file b &&\n+\ttest_commit two file c &&\n+\ttest_commit three file d\n+'\n+\n+test_expect_success 'errors on missing range argument' '\n+\ttest_must_fail git history squash 2>err &&\n+\ttest_grep \"expects a revision range\" err\n+'\n+\n+test_expect_success 'errors on an empty range' '\n+\ttest_must_fail git history squash HEAD..HEAD 2>err &&\n+\ttest_grep \"the revision range is empty\" err\n+'\n+\n+test_expect_success 'errors on a single revision that is not a range' '\n+\ttest_must_fail git history squash HEAD 2>err &&\n+\ttest_grep \"not a .*range\" err &&\n+\ttest_must_fail git history squash HEAD~1 2>err &&\n+\ttest_grep \"not a .*range\" err\n+'\n+\n+test_expect_success 'errors on a range holding a single commit' '\n+\ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n+\ttest_grep \"single commit; nothing to squash\" err\n+'\n+\n+test_expect_success 'rejects a root commit' '\n+\toid=$(git commit-tree -m root three^{tree}) &&\n+\ttest_must_fail git history squash \\\n+\t\t--ancestry-path=start \"$oid..three\" 2>err &&\n+\ttest_grep \"cannot squash down to root commit\" err\n+'\n+\n+test_expect_success 'rejects multiple tips' '\n+\toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n+\ttest_must_fail git history squash ^start \"$oid\" three~1 2>err &&\n+\ttest_grep \"revision range contains more than one tip\" err\n+'\n+\n+test_expect_success 'rejects a merge parent outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"552257","messageId":"4d30ff193697145e45692bc0fd022da43c76b868.1788900120.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","subject":"[PATCH v15 6/8] history: protect branches when squashing a range","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:57Z","receivedAt":"2026-09-08T20:42:08Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nA local branch that descends from the selected graph without containing\nits tip cannot be replayed as a descendant of the squashed commit. Find\nthose branches with ref-filter before creating any replacement objects\nand refuse the operation unless --update-refs=head was requested.\n\nLimit this protection to local branches, matching the refs that the\ndefault history rewrite mode updates; tags and remote-tracking refs\nremain untouched. Sort the blocking refs and print their short branch\nnames so the user can decide whether to move them or leave them behind.\n\nAdd advice.historyUpdateRefs for the hint that points to\n--update-refs=head.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/advice.adoc |  4 ++\n advice.c                         |  1 +\n advice.h                         |  1 +\n builtin/history.c                | 70 ++++++++++++++++++++++++++++++--\n t/t3455-history-squash.sh        | 39 ++++++++++++++++++\n 5 files changed, 111 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc\nindex 81f80a9274..3d91c90eda 100644\n--- a/Documentation/config/advice.adoc\n+++ b/Documentation/config/advice.adoc\n@@ -59,6 +59,10 @@ all advice messages.\n \tforceDeleteBranch::\n \t\tShown when the user tries to delete a not fully merged\n \t\tbranch without the force option set.\n+\thistoryUpdateRefs::\n+\t\tShown when `git history squash` refuses because a local branch\n+\t\tcannot be rewritten as a descendant of the squashed commit, to\n+\t\ttell the user about `--update-refs=head`.\n \tignoredHook::\n \t\tShown when a hook is ignored because the hook is not\n \t\tset as executable.\ndiff --git a/advice.c b/advice.c\nindex 63bf8b0c5f..401d047391 100644\n--- a/advice.c\n+++ b/advice.c\n@@ -58,6 +58,7 @@ static struct {\n \t[ADVICE_FETCH_SHOW_FORCED_UPDATES]\t\t= { \"fetchShowForcedUpdates\" },\n \t[ADVICE_FORCE_DELETE_BRANCH]\t\t\t= { \"forceDeleteBranch\" },\n \t[ADVICE_GRAFT_FILE_DEPRECATED]\t\t\t= { \"graftFileDeprecated\" },\n+\t[ADVICE_HISTORY_UPDATE_REFS]\t\t\t= { \"historyUpdateRefs\" },\n \t[ADVICE_IGNORED_HOOK]\t\t\t\t= { \"ignoredHook\" },\n \t[ADVICE_IMPLICIT_IDENTITY]\t\t\t= { \"implicitIdentity\" },\n \t[ADVICE_MERGE_CONFLICT]\t\t\t\t= { \"mergeConflict\" },\ndiff --git a/advice.h b/advice.h\nindex 66f6cd6a77..3f0b4f0485 100644\n--- a/advice.h\n+++ b/advice.h\n@@ -25,6 +25,7 @@ enum advice_type {\n \tADVICE_FETCH_SHOW_FORCED_UPDATES,\n \tADVICE_FORCE_DELETE_BRANCH,\n \tADVICE_GRAFT_FILE_DEPRECATED,\n+\tADVICE_HISTORY_UPDATE_REFS,\n \tADVICE_IGNORED_HOOK,\n \tADVICE_IMPLICIT_IDENTITY,\n \tADVICE_MERGE_CONFLICT,\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 6541a397e8..e65b76b59f 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1,6 +1,7 @@\n #define USE_THE_REPOSITORY_VARIABLE\n \n #include \"builtin.h\"\n+#include \"advice.h\"\n #include \"cache-tree.h\"\n #include \"commit.h\"\n #include \"commit-reach.h\"\n@@ -16,10 +17,12 @@\n #include \"path.h\"\n #include \"read-cache.h\"\n #include \"refs.h\"\n+#include \"ref-filter.h\"\n #include \"replay.h\"\n #include \"reset.h\"\n #include \"revision.h\"\n #include \"sequencer.h\"\n+#include \"string-list.h\"\n #include \"strvec.h\"\n #include \"tree.h\"\n #include \"tree-walk.h\"\n@@ -1061,6 +1064,7 @@ static int setup_squash_revisions(struct repository *repo,\n  * of the oldest commit.\n  */\n static int resolve_squash_range(struct repository *repo,\n+\t\t\t\tbool update_branches,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n \t\t\t\tstruct commit **tip_out)\n@@ -1069,6 +1073,8 @@ static int resolve_squash_range(struct repository *repo,\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array refs = { 0 };\n \n \tret = setup_squash_revisions(repo, argc, argv, &revs);\n \tif (ret < 0)\n@@ -1101,9 +1107,12 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t * Allow parents that match the parents of the\n \t\t\t * squashed commit.\n \t\t\t */\n-\t\t\tfor (q = oldest->parents; !seen && q; q = q->next)\n-\t\t\t\tif (p->item == q->item)\n+\t\t\tfor (q = oldest->parents; !seen && q; q = q->next) {\n+\t\t\t\tif (p->item == q->item) {\n \t\t\t\t\tseen = true;\n+\t\t\t\t\tcommit_list_insert(commit, &filter.with_commit);\n+\t\t\t\t}\n+\t\t\t}\n \t\t\tif (!seen) {\n \t\t\t\tret = error(_(\"parent %s of commit %s is \"\n \t\t\t\t\t      \"outside the revision range\"),\n@@ -1119,12 +1128,17 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\to->flags &= ~SQUASH_TIP;\n \t\t\t}\n \t\t}\n-\t\tif (!oldest)\n+\t\tif (!oldest) {\n+\t\t\tcommit_list_insert(commit, &filter.with_commit);\n \t\t\toldest = commit;\n+\t\t}\n \t\ttip = commit;\n \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n \t\ttip_count++;\n \t}\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\treset_revision_walk();\n+\twalk_started = false;\n \n \tif (!tip_count) {\n \t\tret = error(_(\"the revision range is empty\"));\n@@ -1141,6 +1155,49 @@ static int resolve_squash_range(struct repository *repo,\n \t\tBUG(\"an in-range commit must have a parent\");\n \t}\n \n+\tcommit_list_insert(tip, &filter.no_commit);\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tif (update_branches &&\n+\t    filter_refs(&refs, &filter, filter.kind)) {\n+\t\tret = error(_(\"could not filter refs\"));\n+\t\tgoto out;\n+\t}\n+\tif (refs.nr) {\n+\t\tstruct ref_format format = REF_FORMAT_INIT;\n+\t\tstruct ref_sorting *sorting;\n+\t\tstruct string_list sorting_options = STRING_LIST_INIT_DUP;\n+\t\tstruct strbuf branches = STRBUF_INIT;\n+\t\tstruct strbuf err = STRBUF_INIT;\n+\n+\t\tformat.format = \"%(refname:short)\";\n+\t\tif (verify_ref_format(&format))\n+\t\t\tBUG(\"invalid branch format\");\n+\t\tstring_list_append(&sorting_options, \"refname\");\n+\t\tsorting = ref_sorting_options(&sorting_options);\n+\t\tref_array_sort(sorting, &refs);\n+\t\tfor (int i = 0; i < refs.nr; i++) {\n+\t\t\tstrbuf_reset(&err);\n+\t\t\tstrbuf_addstr(&branches, \"\\n  \");\n+\t\t\tif (format_ref_array_item(refs.items[i], &format,\n+\t\t\t\t\t\t  &branches, &err))\n+\t\t\t\tBUG(\"could not format branch name: %s\", err.buf);\n+\t\t}\n+\t\t/*\n+\t\t * TODO: also check HEADS from other worktrees.\n+\t\t */\n+\t\tret = error(_(\"the following branches cannot be rewritten as \"\n+\t\t\t      \"descendants of the squashed commit:%s\"), branches.buf);\n+\t\tadvise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,\n+\t\t\t\t  _(\"Use --update-refs=head to rewrite only \"\n+\t\t\t\t    \"the current branch and leave such branches \"\n+\t\t\t\t    \"untouched.\"));\n+\t\tstrbuf_release(&err);\n+\t\tstrbuf_release(&branches);\n+\t\tref_sorting_release(sorting);\n+\t\tstring_list_clear(&sorting_options, 0);\n+\t\tgoto out;\n+\t}\n+\n \t*oldest_out = oldest;\n \t*tip_out = tip;\n \tret = 0;\n@@ -1150,6 +1207,8 @@ out:\n \tif (walk_started)\n \t\treset_revision_walk();\n \trelease_revisions(&revs);\n+\tref_filter_clear(&filter);\n+\tref_array_clear(&refs);\n \treturn ret;\n }\n \n@@ -1183,8 +1242,11 @@ static int cmd_history_squash(int argc,\n \tif (argc < 2)\n \t\treturn error(_(\"command expects a revision range\"));\n \trepo_config(repo, git_default_config, NULL);\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n \n-\tret = resolve_squash_range(repo, argc, argv, &oldest, &tip);\n+\tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n+\t\t\t\t   argc, argv, &oldest, &tip);\n \tif (ret < 0)\n \t\treturn ret;\n \ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex df92aa4f6c..b1f65de5f5 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -62,4 +62,43 @@ test_expect_success 'rejects a merge parent outside the range' '\n \ttest_grep \"parent .* of commit .* is outside the revision range\" err\n '\n \n+test_expect_success 'prints branches that cannot follow the squash' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f -b feature start &&\n+\ttest_commit C1 &&\n+\ttest_commit C2 &&\n+\tgit checkout -b topic-1 start &&\n+\ttest_commit C3 &&\n+\ttest_commit C4 &&\n+\tgit checkout C3 &&\n+\ttest_commit C5 &&\n+\tgit checkout feature &&\n+\tgit merge C5 &&\n+\ttest_commit C6 &&\n+\tgit checkout -b topic-2 C2 &&\n+\ttest_commit C7 &&\n+\tgit checkout feature &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  topic-1$\" err &&\n+\ttest_grep \"^  topic-2$\" err &&\n+\ttest_grep \"^hint: .* --update-refs=head\" err\n+'\n+\n+test_expect_success 'advice.historyUpdateRefs silences the hint' '\n+\tgit reset --hard three &&\n+\tgit branch -f mid HEAD~1 &&\n+\n+\ttest_must_fail git -c advice.historyUpdateRefs=false \\\n+\t\thistory squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  mid$\" err &&\n+\ttest_grep ! \"hint:\" err &&\n+\n+\tgit branch -D mid\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"552258","messageId":"8da033364685e6b88aa4bc2b44cc2a93b34e97a8.1788900120.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","subject":"[PATCH v15 8/8] history: support editing squashed commit messages","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:59Z","receivedAt":"2026-09-08T20:42:10Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nOpen the editor by default when squashing and provide --no-edit as the\nopt-out. Record the exact commits selected by the revision walk,\nrearrange that todo list with the sequencer's autosquash machinery, and\nbuild the message template from the resulting order.\n\nMatch interactive rebase's treatment of marker messages: comment out\nfixup! messages, retain squash! bodies, and let amend! replace its target\nunless a preceding squash! requires both bodies. This keeps message\nediting aligned with the marker validation used by the no-edit path.\n\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  18 ++-\n builtin/history.c              |  97 +++++++++++++++-\n t/t3455-history-squash.sh      | 197 +++++++++++++++++++++++++++++++++\n 3 files changed, 305 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex fb04a67685..2e2e31f521 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -133,8 +133,9 @@ given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n already on `topic`. Rev-list options may also be given, but any that would\n change how the range is walked are overridden with a warning.\n +\n-With `--no-edit`, the oldest commit's message is preserved, except that an\n-`amend!` commit targeting it replaces its message.\n+An editor opens pre-filled with the messages of all the folded commits so you\n+can combine them. With `--no-edit`, the oldest commit's message is preserved\n+instead, except that an `amend!` commit targeting it replaces its message.\n +\n The selected commits must form a connected graph with a single tip and must\n not include a root commit. Every parent of a commit after the oldest one must\n@@ -148,6 +149,13 @@ of markers for one target is combined into a single commit. With `--no-edit`,\n the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n otherwise refused if folding it would discard its message.\n +\n+The editor template mirrors `git rebase -i --autosquash`: each `fixup!`,\n+`squash!`, or `amend!` is grouped under the commit it targets rather than\n+shown in commit order. A `fixup!` message is dropped (commented out in full),\n+a `squash!` keeps its body with only the marker subject commented, and an\n+`amend!` replaces its target's message, unless a `squash!` folded into that\n+target first, in which case it keeps its body like a `squash!`.\n++\n A local branch descended from a selected commit but not from the range tip\n cannot be rewritten as a descendant of the result, so with the default\n `--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n@@ -163,6 +171,12 @@ OPTIONS\n \tobjects will be written into the repository, so applying these printed\n \tref updates is generally safe.\n \n+`--edit`::\n+`--no-edit`::\n+\tFor `squash`, open an editor to combine the messages of the folded commits.\n+\tThis is the default; use `--no-edit` to keep the selected message without\n+\topening an editor.\n+\n `--reedit-message`::\n \tOpen an editor to modify the target commit's message.\n \ndiff --git a/builtin/history.c b/builtin/history.c\nindex 8176f6794e..2d88bc4352 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1266,6 +1266,10 @@ static int squash_check_subject(struct repository *repo,\n \treturn ret;\n }\n \n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tconst struct strbuf *todo_buf,\n+\t\t\t\tstruct strbuf *out);\n+\n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n \t\t\t\t  struct rev_info *revs)\n@@ -1319,6 +1323,7 @@ static int setup_squash_revisions(struct repository *repo,\n  */\n static int resolve_squash_range(struct repository *repo,\n \t\t\t\tbool update_branches,\n+\t\t\t\tbool edit_message,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n \t\t\t\tstruct commit **tip_out,\n@@ -1327,11 +1332,13 @@ static int resolve_squash_range(struct repository *repo,\n \tstruct rev_info revs;\n \tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n+\tstruct strbuf todo_buf = STRBUF_INIT;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n \tstruct ref_filter filter = REF_FILTER_INIT;\n \tstruct ref_array refs = { 0 };\n \n+\tsubject_data.edit_message = edit_message;\n \tret = setup_squash_revisions(repo, argc, argv, &revs);\n \tif (ret < 0)\n \t\tgoto out;\n@@ -1344,6 +1351,10 @@ static int resolve_squash_range(struct repository *repo,\n \twhile ((commit = get_revision(&revs))) {\n \t\tstruct commit_list *p;\n \n+\t\tif (edit_message)\n+\t\t\tstrbuf_addf(&todo_buf, \"pick %s\\n\",\n+\t\t\t\t    oid_to_hex(&commit->object.oid));\n+\n \t\tif (!commit->parents) {\n \t\t\tret = error(_(\"cannot squash down to root commit\"));\n \t\t\tgoto out;\n@@ -1457,6 +1468,13 @@ static int resolve_squash_range(struct repository *repo,\n \t\tstring_list_clear(&sorting_options, 0);\n \t\tgoto out;\n \t}\n+\tif (edit_message) {\n+\t\tstrbuf_reset(&subject_data.squash_message);\n+\t\tret = build_squash_message(repo, &todo_buf,\n+\t\t\t\t\t   &subject_data.squash_message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t}\n \n \t*oldest_out = oldest;\n \t*tip_out = tip;\n@@ -1464,6 +1482,7 @@ static int resolve_squash_range(struct repository *repo,\n \tret = 0;\n \n out:\n+\tstrbuf_release(&todo_buf);\n \tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP |\n \t\t\t   SQUASH_AMEND_TARGET);\n \tif (walk_started)\n@@ -1475,6 +1494,76 @@ out:\n \treturn ret;\n }\n \n+static bool amend_replaces_target(struct todo_list *todo, int target)\n+{\n+\tfor (int i = target + 1; i < todo->nr &&\n+\t\t\t\t todo->items[i].command != TODO_PICK; i++) {\n+\t\tif (todo->items[i].command == TODO_SQUASH)\n+\t\t\treturn false;\n+\t\tif (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)\n+\t\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static int build_squash_message(struct repository *repo,\n+\t\t\t\tconst struct strbuf *todo_buf,\n+\t\t\t\tstruct strbuf *out)\n+{\n+\tstruct todo_list todo = TODO_LIST_INIT;\n+\tstruct replay_opts opts = REPLAY_OPTS_INIT;\n+\tint nr_commits, ret;\n+\n+\tif (todo_list_parse_insn_buffer(repo, &opts, todo_buf->buf, &todo) < 0 ||\n+\t    todo_list_rearrange_squash(&todo) < 0) {\n+\t\tret = error(_(\"could not prepare the squash message\"));\n+\t\tgoto out;\n+\t}\n+\n+\tnr_commits = todo.nr;\n+\tfor (int i = 0; i < nr_commits; i++) {\n+\t\tstruct todo_item *item = &todo.items[i];\n+\t\tconst char *message, *body;\n+\t\tsize_t commented_len;\n+\t\tbool skip, squashing;\n+\n+\t\tsquashing = item->command == TODO_SQUASH ||\n+\t\t\t    (item->flags & TODO_REPLACE_FIXUP_MSG);\n+\t\tif (item->command == TODO_PICK)\n+\t\t\tskip = amend_replaces_target(&todo, i);\n+\t\telse\n+\t\t\tskip = !squashing;\n+\n+\t\tmessage = repo_logmsg_reencode(repo, item->commit, NULL, NULL);\n+\t\tfind_commit_subject(message, &body);\n+\n+\t\tif (skip)\n+\t\t\tcommented_len = strlen(body);\n+\t\telse if (squashing)\n+\t\t\tcommented_len = squash_subject_comment_len(body, 1);\n+\t\telse\n+\t\t\tcommented_len = 0;\n+\n+\t\tif (!i)\n+\t\t\tadd_squash_combination_header(out, nr_commits);\n+\t\tstrbuf_addch(out, '\\n');\n+\t\tadd_squash_message_header(out, i + 1, skip);\n+\t\tstrbuf_addstr(out, \"\\n\\n\");\n+\t\tstrbuf_add_commented_lines(out, body, commented_len, comment_line_str);\n+\t\tstrbuf_addstr(out, body + commented_len);\n+\t\tstrbuf_complete_line(out);\n+\n+\t\trepo_unuse_commit_buffer(repo, item->commit, message);\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\ttodo_list_release(&todo);\n+\treplay_opts_release(&opts);\n+\treturn ret;\n+}\n+\n static int cmd_history_squash(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -1519,14 +1608,11 @@ static int cmd_history_squash(int argc,\n \tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n \n \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n+\t\t\t\t   edit,\n \t\t\t\t   argc, argv, &oldest, &tip,\n \t\t\t\t   &message_template);\n \tif (ret < 0)\n \t\tgoto out;\n-\tif (edit) {\n-\t\tret = error(_(\"message editing is not supported yet; use '--no-edit'\"));\n-\t\tgoto out;\n-\t}\n \n \tret = setup_revwalk(repo, action, tip, &revs);\n \tif (ret < 0)\n@@ -1538,7 +1624,8 @@ static int cmd_history_squash(int argc,\n \n \tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n \t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n-\t\t\t      &rewritten, 0);\n+\t\t\t      &rewritten,\n+\t\t\t      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing squashed commit\"));\n \t\tgoto out;\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex fb06637aaf..d21e9d9fc4 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -381,6 +381,203 @@ test_expect_success 'squashing fixups into a merge' '\n \tsed 1,2d msg | test_commit_message HEAD\n '\n \n+test_expect_success 'edits every message and aborts on an empty result' '\n+\tgit reset --hard start &&\n+\tstage_file b &&\n+\tgit commit -m \"re-one subject\" -m \"re-one body line\" &&\n+\ttest_commit --no-tag re-two file c &&\n+\ttest_commit re-three file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\twrite_script empty-editor <<-\\EOF &&\n+\t>\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/empty-editor\" &&\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"Aborting commit due to empty commit message\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited &&\n+\techo combined >\"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# This is the 1st commit message:\n+\n+\tre-one subject\n+\n+\tre-one body line\n+\n+\t# This is the commit message #2:\n+\n+\tre-two\n+\n+\t# This is the commit message #3:\n+\n+\tre-three\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_subjects -1 <<-\\EOF\n+\tcombined\n+\tEOF\n+'\n+\n+test_expect_success 'handles fixup!, squash! and amend! like rebase' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"fixup! mark-base\\n\\nfixup body\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\tstage_file e &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\tmark-base\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! mark-base\n+\t#\n+\t# fixup body\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# This is the commit message #4:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tmark-base\n+\n+\tsquash remark\n+\n+\tamended message\n+\n+\tEOF\n+'\n+\n+test_expect_success 'groups fixups under their targets in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag alpha file a1 &&\n+\ttest_commit --no-tag beta file b1 &&\n+\tstage_file a2 &&\n+\tcommit_with_message \"fixup! alpha\\n\" &&\n+\tstage_file b2 &&\n+\tcommit_with_message \"fixup! beta\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 4 commits.\n+\t# This is the 1st commit message:\n+\n+\talpha\n+\n+\t# The commit message #2 will be skipped:\n+\n+\t# fixup! alpha\n+\n+\t# This is the commit message #3:\n+\n+\tbeta\n+\n+\t# The commit message #4 will be skipped:\n+\n+\t# fixup! beta\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited\n+'\n+\n+test_expect_success 'lets amend! replace its target message in the editor' '\n+\tgit reset --hard start &&\n+\ttest_commit --no-tag mark-base file b &&\n+\tstage_file c &&\n+\tcommit_with_message \"amend! mark-base\\n\\namended message\\n\" &&\n+\tstage_file d &&\n+\tcommit_with_message \"squash! mark-base\\n\\nsquash remark\\n\" &&\n+\n+\twrite_script editor <<-\\EOF &&\n+\tcat \"$1\" >edited\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\tgit history squash start.. &&\n+\n+\tcat >expect <<-EOF &&\n+\t# This is a combination of 3 commits.\n+\t# The 1st commit message will be skipped:\n+\n+\t# mark-base\n+\n+\t# This is the commit message #2:\n+\n+\t# amend! mark-base\n+\n+\tamended message\n+\n+\t# This is the commit message #3:\n+\n+\t# squash! mark-base\n+\n+\tsquash remark\n+\n+\t# Please enter the commit message for the squash changes. Lines starting\n+\t# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.\n+\t# Changes to be committed:\n+\t#\tmodified:   file\n+\t#\n+\tEOF\n+\ttest_cmp expect edited &&\n+\tcheck_log_messages -1 <<-\\EOF\n+\tamended message\n+\n+\tsquash remark\n+\n+\tEOF\n+'\n+\n test_expect_success '--update-refs=head only moves HEAD' '\n \tgit reset --hard three &&\n \tgit branch -f other HEAD &&\n-- \ngitgitgadget\n"},{"id":"552259","messageId":"96349a802e2df9d3f9fed233abe08846ca0f5c9e.1788900120.git.gitgitgadget@gmail.com","threadId":"65812","inReplyTo":"pull.2337.v15.git.git.1788900119.gitgitgadget@gmail.com","subject":"[PATCH v15 7/8] history: create squashed commits without editing","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-08T20:41:58Z","receivedAt":"2026-09-08T20:42:10Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCreate one replacement commit from the resolved range when --no-edit is\nselected. Preserve the authorship and all parents of the oldest commit,\nuse the tip tree, and replay descendants through the existing history\nrewrite machinery. Record the complete revision expression in the\nreflog and retain dry-run and update-refs behavior.\n\nResolve fixup!, squash! and amend! subjects while walking the range.\nReject markers whose targets are not selected and refuse any no-edit\nfold that would discard a squash! or amend! message. A range made\nentirely from related markers can still be consolidated, with the last\napplicable amend! body supplying the message.\n\nInspired-by: Sergey Chernov <serega.morph@gmail.com>\nSigned-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nHelped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-history.adoc |  44 ++-\n builtin/history.c              | 324 ++++++++++++++++-\n object.h                       |   2 +-\n t/t3455-history-squash.sh      | 633 +++++++++++++++++++++++++++++++--\n 4 files changed, 968 insertions(+), 35 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex b660baf94d..fb04a67685 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -44,8 +44,11 @@ at once.\n LIMITATIONS\n -----------\n \n-This command does not (yet) work with histories that contain merges. You\n-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.\n+This command does not (yet) replay merge commits onto the rewritten\n+history: if a commit that would be replayed is a merge, the operation is\n+rejected, and you should use linkgit:git-rebase[1] with the\n+`--rebase-merges` flag instead. The `squash` subcommand can still fold merges\n+that lie inside the selected range, subject to the restrictions below.\n \n Furthermore, the command does not support operations that can result in merge\n conflicts. This limitation is by design as history rewrites are not intended to\n@@ -114,6 +117,43 @@ linkgit:gitglossary[7].\n It is invalid to select either all or no hunks, as that would lead to\n one of the commits becoming empty.\n \n+`squash <revision-range>`::\n+\tFold all commits in _<revision-range>_ into the oldest commit of that\n+\trange. The resulting commit keeps the oldest commit's authorship and\n+\ttakes the tree of the range's newest commit, so the whole range\n+\tcollapses into a single commit. Commits above the range are replayed\n+\ton top of the result.\n++\n+The range is given in the usual `<base>..<tip>` form, where _<base>_ is\n+the commit just below the oldest commit to squash. For example, `git\n+history squash HEAD~3..HEAD` folds the three most recent commits into\n+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range\n+while leaving the two newest commits in place. Several revisions may be\n+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is\n+already on `topic`. Rev-list options may also be given, but any that would\n+change how the range is walked are overridden with a warning.\n++\n+With `--no-edit`, the oldest commit's message is preserved, except that an\n+`amend!` commit targeting it replaces its message.\n++\n+The selected commits must form a connected graph with a single tip and must\n+not include a root commit. Every parent of a commit after the oldest one must\n+either be selected or also be a parent of the oldest commit. When the oldest\n+commit is a merge, all of its parents are preserved in the squashed commit.\n++\n+A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it\n+targets is also in the range, so the fold does not silently absorb a\n+marker meant for a commit outside it. As an exception, a range made up entirely\n+of markers for one target is combined into a single commit. With `--no-edit`,\n+the last `amend!` message is used if there is one; a `squash!` or `amend!` is\n+otherwise refused if folding it would discard its message.\n++\n+A local branch descended from a selected commit but not from the range tip\n+cannot be rewritten as a descendant of the result, so with the default\n+`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`\n+to rewrite only the current branch and leave such branches unchanged. Tags\n+and remote-tracking refs are always left unchanged.\n+\n OPTIONS\n -------\n \ndiff --git a/builtin/history.c b/builtin/history.c\nindex e65b76b59f..8176f6794e 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -1011,6 +1011,260 @@ out:\n \n #define SQUASH_SEEN (1u << 11)\n #define SQUASH_TIP (1u << 12)\n+#define SQUASH_AMEND_TARGET (1u << 13)\n+\n+static bool is_autosquash_subject(const char *s)\n+{\n+\treturn starts_with(s, \"amend!\") || starts_with(s, \"fixup!\") ||\n+\t\tstarts_with(s, \"squash!\");\n+}\n+\n+static bool skip_one_autosquash_prefix(const char *s, const char **out)\n+{\n+\tif (skip_prefix(s, \"amend!\", out) || skip_prefix(s, \"fixup!\", out) ||\n+\t    skip_prefix(s, \"squash!\", out)) {\n+\t\twhile (**out == ' ')\n+\t\t\t(*out)++;\n+\t\treturn true;\n+\t}\n+\treturn false;\n+}\n+\n+static void truncate_message_to_subject(struct strbuf *msg)\n+{\n+\tconst char *eos = strstr(msg->buf, \"\\n\\n\");\n+\n+\tif (eos)\n+\t\tstrbuf_setlen(msg, eos - msg->buf + 1);\n+}\n+\n+struct subject_data {\n+\tstruct strintmap subjects;\n+\tstruct strbuf subject;\n+\tstruct strbuf squash_message;\n+\tconst char *message;\n+\tbool edit_message;\n+};\n+\n+#define SUBJECT_DATA_INIT {\t\t\\\n+\t.subjects = STRINTMAP_INIT,\t\\\n+\t.subject = STRBUF_INIT,\t\t\\\n+\t.squash_message = STRBUF_INIT,\t\\\n+}\n+\n+static void subject_data_clear(struct subject_data *data)\n+{\n+\tstrintmap_clear(&data->subjects);\n+\tstrbuf_release(&data->subject);\n+\tstrbuf_release(&data->squash_message);\n+}\n+\n+static int squash_amend_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (!*body) {\n+\t\twarning(_(\"ignoring %s (%s): message body is empty\"),\n+\t\t\trepo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\tDEFAULT_ABBREV),\n+\t\t\tdata->subject.buf);\n+\t\treturn 0;\n+\t}\n+\n+\tif (data->edit_message) {\n+\t\treturn 0;\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\"))\n+\t\t\treturn error(_(\"squashing %s (%s) would overwrite \"\n+\t\t\t\t       \"'squash!' message, please combine them \"\n+\t\t\t\t       \"using '--edit'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\"))\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"amend!\", 5);\n+\t\tif (starts_with(data->squash_message.buf, \"amend!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t} else {\n+\t\t\tstrbuf_reset(&data->squash_message);\n+\t\t}\n+\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--edit'\"),\n+\t\t\t     repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t     data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_squash_message(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data,\n+\t\t\t\tunsigned flags)\n+{\n+\tconst char *body = data->message + data->subject.len;\n+\n+\twhile (isspace(*body))\n+\t\tbody++;\n+\n+\tif (data->edit_message) {\n+\t\treturn 0;\n+\t} else if (flags & SQUASH_AMEND_TARGET) {\n+\t\tif (starts_with(data->squash_message.buf, \"fixup!\")) {\n+\t\t\ttruncate_message_to_subject(&data->squash_message);\n+\t\t\tstrbuf_splice(&data->squash_message, 0, 5, \"squash\", 6);\n+\t\t}\n+\t\tif (starts_with(data->squash_message.buf, \"squash!\")) {\n+\t\t\tstrbuf_addch(&data->squash_message, '\\n');\n+\t\t\tstrbuf_addstr(&data->squash_message, body);\n+\t\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t} else {\n+\t\t\treturn error(_(\"squashing %s (%s) would discard its \"\n+\t\t\t\t       \"message, please combine them using \"\n+\t\t\t\t       \"'--edit'\"),\n+\t\t\t\t     repo_find_unique_abbrev(repo,\n+\t\t\t\t\t\t\t     &commit->object.oid,\n+\t\t\t\t\t\t\t     DEFAULT_ABBREV),\n+\t\t\t\t     data->subject.buf);\n+\t\t}\n+\t} else {\n+\t\treturn error(_(\"cannot squash %s (%s) that does not target \"\n+\t\t\t       \"base commit without '--edit'\"),\n+\t\t\t      repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t\t      DEFAULT_ABBREV),\n+\t\t\t      data->subject.buf);\n+\t}\n+\treturn 0;\n+}\n+\n+static int squash_check_can_autosquash(struct repository *repo,\n+\t\t\t\t       struct commit *commit,\n+\t\t\t\t       struct subject_data *data,\n+\t\t\t\t       unsigned flags)\n+{\n+\tcommit->object.flags |= flags & SQUASH_AMEND_TARGET;\n+\tif (starts_with(data->subject.buf, \"amend!\"))\n+\t\treturn squash_amend_message(repo, commit, data, flags);\n+\telse if (starts_with(data->subject.buf, \"squash!\"))\n+\t\treturn squash_squash_message(repo, commit, data, flags);\n+\n+\treturn 0;\n+}\n+\n+static int squash_check_autosquash_subject(struct repository *repo,\n+\t\t\t\t\t   struct commit *commit,\n+\t\t\t\t\t   struct subject_data *data)\n+{\n+\tconst char* s = data->subject.buf;\n+\tstruct commit *target;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\t/* Try skipping autosquash prefixes one at a time to allow\n+\t * squashing\n+\t *     a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * where we may have started with\n+\t *     a commit\n+\t *     fixup! a commit\n+\t *     fixup! fixup! a commit\n+\t *\n+\t * and squashed the first fixup separately from the second\n+\t */\n+\twhile (skip_one_autosquash_prefix(s, &s)) {\n+\t\tunsigned flags = strintmap_get(&data->subjects, s);\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit, data, flags);\n+\t}\n+\t/*\n+\t * Allow \"fixup! <hex object id>\", but not \"fixup! HEAD^\" or\n+\t * \"fixup! main\". If the target is not being squashed check the subject\n+\t * to allow \"fixup! abc123\" and \"fixup! <subject of abc123>\" to be\n+\t * squashed together.\n+\t */\n+\ttarget = lookup_commit_reference_by_name(s);\n+\tif (target && starts_with(oid_to_hex(&target->object.oid), s)) {\n+\t\tunsigned flags =\n+\t\t\ttarget->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);\n+\t\tif (!flags) {\n+\t\t\tconst char *subject_start;\n+\t\t\tconst char *buffer = repo_logmsg_reencode(repo, target,\n+\t\t\t\t\t\t\t\t  NULL, NULL);\n+\t\t\tsize_t subject_len = find_commit_subject(buffer,\n+\t\t\t\t\t\t\t\t &subject_start);\n+\t\t\tchar *subject = xmemdupz(subject_start, subject_len);\n+\n+\t\t\tflags = strintmap_get(&data->subjects, subject);\n+\t\t\tfree(subject);\n+\t\t\trepo_unuse_commit_buffer(repo, target, buffer);\n+\t\t}\n+\t\tif (flags)\n+\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t   data, flags);\n+\t}\n+\t/* Try subject prefix matches */\n+\tstrintmap_for_each_entry(&data->subjects, &iter, entry) {\n+\t\ts = data->subject.buf;\n+\t\twhile(skip_one_autosquash_prefix(s, &s)) {\n+\t\t\tif (starts_with(entry->key, s)) {\n+\t\t\t\tunsigned value = (intptr_t)entry->value;\n+\n+\t\t\t\treturn squash_check_can_autosquash(repo, commit,\n+\t\t\t\t\t\t\t\t   data, value);\n+\t\t\t}\n+\t\t}\n+\t}\n+\treturn error(_(\"cannot squash %s (%s): its target is not being \"\n+\t\t       \"squashed\"),\n+\t\t       repo_find_unique_abbrev(repo, &commit->object.oid,\n+\t\t\t\t\t       DEFAULT_ABBREV),\n+\t\t       data->subject.buf);\n+}\n+\n+static int squash_check_subject(struct repository *repo,\n+\t\t\t\tstruct commit *commit,\n+\t\t\t\tstruct subject_data *data)\n+{\n+\tint ret = 0;\n+\tconst char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);\n+\tsize_t subject_len = find_commit_subject(buf, &data->message);\n+\n+\tstrbuf_reset(&data->subject);\n+\tstrbuf_add(&data->subject, data->message, subject_len);\n+\n+\tif (!strintmap_get_size(&data->subjects)) {\n+\t\tconst char *s;\n+\n+\t\tstrbuf_addstr(&data->squash_message, data->message);\n+\t\tstrbuf_complete_line(&data->squash_message);\n+\t\t/*\n+\t\t * Strip a single autosquash prefix to allow squashing\n+\t\t *     fixup! base\n+\t\t *     amend! base\n+\t\t */\n+\t\ts = data->subject.buf;\n+\t\tskip_one_autosquash_prefix(s, &s);\n+\t\tstrintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);\n+\t\tcommit->object.flags |= SQUASH_AMEND_TARGET;\n+\t} else if (is_autosquash_subject(data->subject.buf)) {\n+\t\tret = squash_check_autosquash_subject(repo, commit, data);\n+\t} else {\n+\t\tstrintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);\n+\t}\n+\trepo_unuse_commit_buffer(repo, commit, buf);\n+\treturn ret;\n+}\n \n static int setup_squash_revisions(struct repository *repo,\n \t\t\t\t  int argc, const char **argv,\n@@ -1067,9 +1321,11 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\t\tbool update_branches,\n \t\t\t\tint argc, const char **argv,\n \t\t\t\tstruct commit **oldest_out,\n-\t\t\t\tstruct commit **tip_out)\n+\t\t\t\tstruct commit **tip_out,\n+\t\t\t\tchar **message_out)\n {\n \tstruct rev_info revs;\n+\tstruct subject_data subject_data = SUBJECT_DATA_INIT;\n \tstruct commit *commit, *oldest = NULL, *tip = NULL;\n \tint ret, tip_count = 0;\n \tbool walk_started = false;\n@@ -1132,6 +1388,10 @@ static int resolve_squash_range(struct repository *repo,\n \t\t\tcommit_list_insert(commit, &filter.with_commit);\n \t\t\toldest = commit;\n \t\t}\n+\t\tif (squash_check_subject(repo, commit, &subject_data)) {\n+\t\t\tret = -1;\n+\t\t\tgoto out;\n+\t\t}\n \t\ttip = commit;\n \t\ttip->object.flags |= SQUASH_SEEN | SQUASH_TIP;\n \t\ttip_count++;\n@@ -1200,12 +1460,15 @@ static int resolve_squash_range(struct repository *repo,\n \n \t*oldest_out = oldest;\n \t*tip_out = tip;\n+\t*message_out = strbuf_detach(&subject_data.squash_message, NULL);\n \tret = 0;\n \n out:\n-\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);\n+\tclear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP |\n+\t\t\t   SQUASH_AMEND_TARGET);\n \tif (walk_started)\n \t\treset_revision_walk();\n+\tsubject_data_clear(&subject_data);\n \trelease_revisions(&revs);\n \tref_filter_clear(&filter);\n \tref_array_clear(&refs);\n@@ -1234,23 +1497,68 @@ static int cmd_history_squash(int argc,\n \t\t\t N_(\"edit the commit message\")),\n \t\tOPT_END(),\n \t};\n-\tstruct commit *oldest, *tip;\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct commit *oldest, *tip, *rewritten;\n+\tconst struct object_id *base_tree_oid, *tip_tree_oid;\n+\tchar *message_template = NULL;\n+\tstruct rev_info revs = { 0 };\n \tint ret;\n \n \targc = parse_options(argc, argv, prefix, options, usage,\n \t\t\t     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);\n-\tif (argc < 2)\n-\t\treturn error(_(\"command expects a revision range\"));\n+\tif (argc < 2) {\n+\t\tret = error(_(\"command expects a revision range\"));\n+\t\tgoto out;\n+\t}\n \trepo_config(repo, git_default_config, NULL);\n+\n \tif (action == REF_ACTION_DEFAULT)\n \t\taction = REF_ACTION_BRANCHES;\n \n+\tstrbuf_addstr(&reflog_msg, \"squash: updating \");\n+\tstrbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');\n+\n \tret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,\n-\t\t\t\t   argc, argv, &oldest, &tip);\n+\t\t\t\t   argc, argv, &oldest, &tip,\n+\t\t\t\t   &message_template);\n \tif (ret < 0)\n-\t\treturn ret;\n+\t\tgoto out;\n+\tif (edit) {\n+\t\tret = error(_(\"message editing is not supported yet; use '--no-edit'\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = setup_revwalk(repo, action, tip, &revs);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\n+\tbase_tree_oid = &repo_get_commit_tree(repo,\n+\t\t\t\t\toldest->parents->item)->object.oid;\n+\ttip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;\n+\n+\tret = commit_tree_ext(repo, \"squash\", oldest, message_template,\n+\t\t\t      oldest->parents, base_tree_oid, tip_tree_oid,\n+\t\t\t      &rewritten, 0);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed writing squashed commit\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = handle_reference_updates(&revs, action, tip, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run,\n+\t\t\t\t       REPLAY_EMPTY_COMMIT_ABORT);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n \n-\treturn error(_(\"squashing commits is not implemented yet\"));\n+\tret = 0;\n+\n+out:\n+\tstrbuf_release(&reflog_msg);\n+\trelease_revisions(&revs);\n+\tfree(message_template);\n+\treturn ret;\n }\n \n static int update_worktree(struct repository *repo,\ndiff --git a/object.h b/object.h\nindex dcf30156ca..46cade33fb 100644\n--- a/object.h\n+++ b/object.h\n@@ -74,7 +74,7 @@ void object_array_init(struct object_array *array);\n  * bisect.c:                                        16\n  * bundle.c:                                        16\n  * http-push.c:                          11-----14\n- * builtin/history.c:                    1112\n+ * builtin/history.c:                    11---13\n  * commit-graph.c:                                15\n  * commit-reach.c:                                  16-------20\n  * builtin/last-modified.c:                         1617\ndiff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh\nindex b1f65de5f5..fb06637aaf 100755\n--- a/t/t3455-history-squash.sh\n+++ b/t/t3455-history-squash.sh\n@@ -4,11 +4,50 @@ test_description='tests for git-history squash subcommand'\n \n . ./test-lib.sh\n \n-test_expect_success 'setup linear history' '\n+stage_file () {\n+\tprintf \"%s\\n\" \"$1\" >file &&\n+\tgit add file\n+}\n+\n+commit_with_message () {\n+\tprintf \"%b\" \"$1\" >msg &&\n+\tgit commit --allow-empty -qF msg\n+}\n+\n+check_commit_count () {\n+\tgit rev-list --count \"$1\" >actual &&\n+\techo \"$2\" >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_subjects () {\n+\tgit log --format=\"%s\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+check_log_messages () {\n+\tgit log --format=\"%B\" \"$1\" >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+# Checks that the author data of two commits matches\n+# Usage: check_commit_author <rev1> <rev2>\n+check_commit_author () {\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$1\" >expect &&\n+\tgit show -s --format=\"%an <%ae> %ad\" \"$2\" >actual &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'setup linear history touching two files' '\n \ttest_commit base file a start &&\n-\ttest_commit one file b &&\n-\ttest_commit two file c &&\n-\ttest_commit three file d\n+\tGIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \\\n+\t\ttest_commit one other x &&\n+\tGIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \\\n+\t\ttest_commit two file c &&\n+\tGIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \\\n+\t\ttest_commit three file d\n '\n \n test_expect_success 'errors on missing range argument' '\n@@ -29,40 +68,331 @@ test_expect_success 'errors on a single revision that is not a range' '\n '\n \n test_expect_success 'errors on a range holding a single commit' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n \ttest_must_fail git history squash \"HEAD^!\" 2>err &&\n-\ttest_grep \"single commit; nothing to squash\" err\n+\ttest_grep \"single commit; nothing to squash\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n '\n \n-test_expect_success 'rejects a root commit' '\n+test_expect_success 'rejects root commit' '\n+\t# create a disconnected root commit\n \toid=$(git commit-tree -m root three^{tree}) &&\n-\ttest_must_fail git history squash \\\n-\t\t--ancestry-path=start \"$oid..three\" 2>err &&\n-\ttest_grep \"cannot squash down to root commit\" err\n+\t# because we pass --ancestry-path when calling setup_revs() it the\n+\t# revision walk will only include commits decended from $oid so\n+\t# we need to give it another --ancestry-path commit to actually walk\n+\t# any commits.\n+\ttest_must_fail git history squash --ancestry-path=start $oid..three 2>err &&\n+\techo \"error: cannot squash down to root commit\" >expect &&\n+\ttest_cmp expect err\n '\n \n test_expect_success 'rejects multiple tips' '\n \toid=$(git commit-tree -m tip -p start^0 three^{tree}) &&\n-\ttest_must_fail git history squash ^start \"$oid\" three~1 2>err &&\n-\ttest_grep \"revision range contains more than one tip\" err\n+\ttest_must_fail git history squash ^start $oid three~1 2>err &&\n+\techo \"error: the revision range contains more than one tip commit\" >expect &&\n+\ttest_cmp expect err &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit ^start three~1 three &&\n+\ttest_cmp_rev HEAD~1 start^0 &&\n+\ttest_cmp_rev HEAD^{tree} three^{tree}\n '\n \n-test_expect_success 'rejects a merge parent outside the range' '\n-\tgit reset --hard start &&\n+test_expect_success 'accepts multiple revision arguments with an exclusion' '\n+\tgit reset --hard three &&\n+\tgit branch -f keep HEAD~2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..HEAD ^keep &&\n+\n+\tgit reflog -1 --format=%gs >actual &&\n+\techo \"squash: updating start..HEAD ^keep\" >expect &&\n+\ttest_cmp expect actual &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\ttwo\n+\tone\n+\tEOF\n+\ttest_cmp_rev keep HEAD~1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit branch -D keep\n+'\n+\n+test_expect_success 'squashes a branch the current branch is not on' '\n+\tgit reset --hard three &&\n \tmain=$(git symbolic-ref --short HEAD) &&\n-\tgit checkout -b outside-parent &&\n-\ttest_commit --no-tag outside-parent outside x &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\tgit checkout -b off-history start &&\n+\ttest_commit --no-tag off-one off a &&\n+\ttest_commit --no-tag off-two off b &&\n \tgit checkout \"$main\" &&\n-\ttest_commit --no-tag outside-main file b &&\n-\tbase=$(git rev-parse HEAD) &&\n-\ttest_commit --no-tag outside-mid file c &&\n-\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n-\tgit branch -D outside-parent &&\n \n-\ttest_must_fail git history squash \"$base..\" 2>err &&\n-\ttest_grep \"parent .* of commit .* is outside the revision range\" err\n+\tgit history squash --no-edit start..off-history &&\n+\n+\tcheck_commit_count start..off-history 1 &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D off-history\n+'\n+\n+test_expect_success 'squashes a range into a single commit without changing the tree' '\n+\tgit reset --hard three &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --dry-run start.. >out &&\n+\tpredicted=$(awk \"/^update refs\\/heads\\// {print \\$3}\" out) &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest \"$predicted\" = \"$(git rev-parse HEAD)\" &&\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tone\n+\tEOF\n+\tgit reflog >reflog &&\n+\ttest_grep \"squash: updating\" reflog\n+'\n+\n+test_expect_success 'sanitizes rev-list walk options, before and after --' '\n+\tgit reset --hard three &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit --date-order start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\n+\tgit reset --hard three &&\n+\tgit history squash --no-edit -- --reverse start.. 2>err &&\n+\ttest_grep \"ignoring rev-list options\" err &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes an interior range and replays descendants verbatim' '\n+\tgit reset --hard three &&\n+\tfinal_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start..@~1 &&\n+\n+\tcheck_log_subjects start..HEAD <<-\\EOF &&\n+\tthree\n+\tone\n+\tEOF\n+\n+\ttest_cmp_rev start HEAD~2 &&\n+\ttest \"$final_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashes when the base is the root commit' '\n+\tgit reset --hard three &&\n+\troot=$(git rev-list --max-parents=0 HEAD) &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$root..\" &&\n+\n+\tcheck_commit_count \"$root..HEAD\" 1 &&\n+\ttest_cmp_rev \"$root\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'squashing a mix of fixups' '\n+\tgit reset --hard three &&\n+\techo fix >file &&\n+\tgit commit --fixup=two -a &&\n+\techo really fix >file &&\n+\tgit commit --fixup=one -a &&\n+\techo really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! two\n+\techo really really really fix >file &&\n+\tgit commit --fixup=HEAD~1 -a && # fixup! one\n+\n+\t# squashing fixup! with a target that is not being squashed fails\n+\ttest_must_fail git history squash one.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing fixup! into fixup! with a different target fails\n+\ttest_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two\n+\ttest_grep \"^error: cannot squash .* (fixup! one): its target is not being squashed\" err &&\n+\n+\t# squashing a sequence of fixup! commits into their targets\n+\tgit history squash --no-edit start..HEAD~1 &&\n+\ttest_cmp_rev start HEAD~2 &&\n+\tcheck_commit_author one HEAD~1 &&\n+\ttest_commit_message HEAD~1 -m one &&\n+\n+\t# squashing \"fixup! fixup! <target>\" into \"<target>\"\n+\tgit history squash --no-edit start.. &&\n+\ttest_cmp_rev start HEAD~1 &&\n+\tcheck_commit_author one HEAD &&\n+\ttest_commit_message HEAD -m one\n+'\n+\n+test_expect_success 'squashing \"squash!\" messages' '\n+\tgit reset --hard two &&\n+\techo fix >file &&\n+\tgit commit --fixup=HEAD -a &&\n+\toldest=$(git rev-parse HEAD) &&\n+\techo better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! $(git rev-parse two)\n+\n+\tAppend this\n+\tEOF\n+\n+\techo an even better fix >file &&\n+\tgit commit -a -F - <<-EOF &&\n+\tsquash! squash! two\n+\n+\tAppend this as well\n+\tEOF\n+\n+\t# must edit when squashing \"squash!\" into its target\n+\ttest_must_fail git history squash --no-edit two^.. 2>err &&\n+\ttest_grep \"^error: squashing .* (squash! [a-f0-9]*) would discard its message\" err &&\n+\n+\t# squashing \"squash!\" into \"fixup!\" appends messages and changes\n+\t# subject prefix\n+\tgit history squash --no-edit two.. &&\n+\ttest_cmp_rev HEAD^ two &&\n+\ttest_commit_message HEAD <<-\\EOF &&\n+\tsquash! two\n+\n+\tAppend this\n+\n+\tAppend this as well\n+\tEOF\n+\tcheck_commit_author \"$oldest\" HEAD &&\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! two\n+\n+\tA new message\n+\tEOF\n+\n+\t# \"amend!\" does not replace \"squash!\"\n+\ttest_must_fail git history squash --no-edit HEAD~2.. 2>err &&\n+\ttest_grep \"^error: squashing .* (amend! two) would overwrite .squash!. message\" err\n+'\n+\n+test_expect_success '--no-edit uses last \"amend!\" message without an editor' '\n+\tgit reset --hard three &&\n+\twrite_script editor <<-\\EOF &&\n+\texit 1\n+\tEOF\n+\ttest_set_editor \"$(pwd)/editor\" &&\n+\techo fix >file &&\n+\tgit commit --author=\"Fix Me <fix.me@example.com>\" --fixup=HEAD -a &&\n+\tgit commit --allow-empty -F - <<-EOF &&\n+\tamend! $(git rev-parse --short HEAD)\n+\n+\tThe first reword\n+\n+\tMore detail\n+\tEOF\n+\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! three\n+\n+\tThe second reword\n+\n+\tExtra detail\n+\tEOF\n+\n+\ttest_commit WIP &&\n+\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse --short HEAD^)\n+\n+\tThe third reword\n+\n+\tExcruciating detail\n+\tEOF\n+\n+\tgit commit --author=\"Someone Else <s.else@example.com>\" --allow-empty \\\n+\t\t-F msg &&\n+\n+\t# squashing amend! updates the commit message\n+\tgit history squash --no-edit three^.. &&\n+\tsed -e 1,2d msg | test_commit_message HEAD &&\n+\tcheck_commit_author three HEAD &&\n+\ttest_cmp_rev HEAD^ three^ &&\n+\n+\t# squashing amend! into fixup! updates subject prefix\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash --no-edit three.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\tcheck_commit_author HEAD@{1}~4 HEAD &&\n+\ttest_cmp_rev HEAD^ three &&\n+\n+\t# squashing amend! into amend! keeps original subject line\n+\tgit reset --hard HEAD@{1} &&\n+\tgit history squash --no-edit HEAD~3.. &&\n+\tsed \"1s/.*/amend! three/\" msg | test_commit_message HEAD &&\n+\ttest_cmp_rev HEAD~3 three &&\n+\n+\t# all amend! messages must target the first commit\n+\tgit reset --hard HEAD@{1} &&\n+\tgit commit --allow-empty -F - <<-\\EOF &&\n+\tamend! WIP\n+\n+\tThe real message\n+\tEOF\n+\n+\ttest_must_fail git history squash --no-edit HEAD~4.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* that does not target\" err &&\n+\n+\t# amend! message that targets commit that is not in range is rejected\n+\ttest_must_fail git history squash --no-edit HEAD~3.. 2>err &&\n+\ttest_grep \"^error: cannot squash .* target is not being squashed\" err &&\n+\ttest_set_editor :\n '\n \n-test_expect_success 'prints branches that cannot follow the squash' '\n+test_expect_success 'squashing fixups into a merge' '\n+\ttest_when_finished \\\n+\t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n+\t\t git branch -D feature\" &&\n+\tgit checkout -f start &&\n+\ttest_commit F1 &&\n+\tgit checkout -b feature start &&\n+\ttest_commit F2 &&\n+\tgit merge F1 &&\n+\techo fixed >F1.t &&\n+\tcat >msg <<-EOF &&\n+\tamend! $(git rev-parse HEAD)\n+\n+\tmerge F1 and F2\n+\n+\treworded\n+\tEOF\n+\n+\tgit commit -a -F msg &&\n+\tgit history squash --no-edit HEAD^^! HEAD &&\n+\ttest_cmp_rev HEAD^1 F2 &&\n+\ttest_cmp_rev HEAD^2 F1 &&\n+\ttest_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&\n+\tsed 1,2d msg | test_commit_message HEAD\n+'\n+\n+test_expect_success '--update-refs=head only moves HEAD' '\n+\tgit reset --hard three &&\n+\tgit branch -f other HEAD &&\n+\tother_before=$(git rev-parse other) &&\n+\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev \"$other_before\" other\n+'\n+\n+test_expect_success 'refuses to fold a range a branch points into' '\n \ttest_when_finished \\\n \t\t\"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \\\n \t\t git branch -D feature\" &&\n@@ -85,20 +415,275 @@ test_expect_success 'prints branches that cannot follow the squash' '\n \ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n \ttest_grep \"^  topic-1$\" err &&\n \ttest_grep \"^  topic-2$\" err &&\n-\ttest_grep \"^hint: .* --update-refs=head\" err\n+\ttest_grep \"^hint: .* --update-refs=head\" err &&\n+\ttest_cmp_rev C6 HEAD &&\n+\n+\t# squash succeeds with --update-refs=head\n+\tgit history squash --no-edit --update-refs=head start.. &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest_cmp_rev C6^{tree} HEAD^{tree} &&\n+\ttest_cmp_rev C6 HEAD@{1}\n '\n \n test_expect_success 'advice.historyUpdateRefs silences the hint' '\n \tgit reset --hard three &&\n \tgit branch -f mid HEAD~1 &&\n+\thead_before=$(git rev-parse HEAD) &&\n \n \ttest_must_fail git -c advice.historyUpdateRefs=false \\\n \t\thistory squash start.. 2>err &&\n \ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n \ttest_grep \"^  mid$\" err &&\n \ttest_grep ! \"hint:\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n \n \tgit branch -D mid\n '\n \n+test_expect_success 'leaves tags and remote-tracking refs unchanged' '\n+\tgit reset --hard three &&\n+\tgit tag -f mark HEAD~1 &&\n+\tgit update-ref refs/remotes/origin/mark HEAD~1 &&\n+\tmark_before=$(git rev-parse mark) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\ttest_cmp_rev \"$mark_before\" mark &&\n+\ttest_cmp_rev \"$mark_before\" refs/remotes/origin/mark &&\n+\n+\tgit tag -d mark &&\n+\tgit update-ref -d refs/remotes/origin/mark\n+'\n+\n+test_expect_success 'squashes a range whose internal merge has a single base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag before-side file b &&\n+\tgit checkout -b inner-side &&\n+\ttest_commit --no-tag on-inner-side inner x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag after-side file c &&\n+\tgit merge --no-ff -m merge inner-side &&\n+\tgit branch -D inner-side &&\n+\ttest_commit --no-tag after-merge file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\tcheck_log_subjects -1 <<-\\EOF &&\n+\tbefore-side\n+\tEOF\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a merge of a branch that forked at the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b base-fork-side &&\n+\ttest_commit --no-tag base-fork-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag base-fork-main file b &&\n+\tgit merge --no-ff -m \"merge base-fork-side\" base-fork-side &&\n+\tgit branch -D base-fork-side &&\n+\ttest_commit --no-tag base-fork-tail file c &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest_cmp_rev start HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'refuses a merge whose other parent is outside the range' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b outside-parent &&\n+\ttest_commit --no-tag outside-parent outside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag outside-main file b &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag outside-mid file c &&\n+\tgit merge --no-ff -m \"merge outside-parent\" outside-parent &&\n+\tgit branch -D outside-parent &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash \"$base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'folds a range whose tip is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag tipmerge-base file b &&\n+\tgit checkout -b tipmerge-side &&\n+\ttest_commit --no-tag tipmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag tipmerge-main file c &&\n+\tgit merge --no-ff -m \"merge tipmerge-side\" tipmerge-side &&\n+\tgit branch -D tipmerge-side &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file side\n+'\n+\n+test_expect_success 'folds a range whose base is a merge commit' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b basemerge-side &&\n+\ttest_commit --no-tag basemerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag basemerge-main file b &&\n+\tgit merge --no-ff -m \"merge basemerge-side\" basemerge-side &&\n+\tgit branch -D basemerge-side &&\n+\tbase=$(git rev-parse HEAD) &&\n+\ttest_commit --no-tag basemerge-one file c &&\n+\ttest_commit --no-tag basemerge-two file d &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit \"$base..\" &&\n+\n+\tcheck_commit_count \"$base..HEAD\" 1 &&\n+\ttest_cmp_rev \"$base\" HEAD^ &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\"\n+'\n+\n+test_expect_success 'folds a range with two interior merges' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag two-merge-a file a1 &&\n+\tgit checkout -b two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-s1 s1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s1\" two-merge-s1 &&\n+\ttest_commit --no-tag two-merge-b file b1 &&\n+\tgit checkout -b two-merge-s2 &&\n+\ttest_commit --no-tag two-merge-s2 s2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m \"merge s2\" two-merge-s2 &&\n+\tgit branch -D two-merge-s1 two-merge-s2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file s1 &&\n+\ttest_path_is_file s2\n+'\n+\n+test_expect_success 'folds a range with a nested merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b nested-outer &&\n+\ttest_commit --no-tag nested-outer outer x &&\n+\tgit checkout -b nested-inner &&\n+\ttest_commit --no-tag nested-inner inner y &&\n+\tgit checkout nested-outer &&\n+\tgit merge --no-ff -m \"merge inner\" nested-inner &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag nested-main file b1 &&\n+\tgit merge --no-ff -m \"merge outer\" nested-outer &&\n+\tgit branch -D nested-outer nested-inner &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file outer &&\n+\ttest_path_is_file inner\n+'\n+\n+test_expect_success 'folds a range with an octopus merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag octo-base file a1 &&\n+\tgit checkout -b octo-1 &&\n+\ttest_commit --no-tag octo-1 o1 x &&\n+\tgit checkout \"$main\" &&\n+\tgit checkout -b octo-2 &&\n+\ttest_commit --no-tag octo-2 o2 y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-1 octo-2 &&\n+\tgit branch -D octo-1 octo-2 &&\n+\ttip_tree=$(git rev-parse HEAD^{tree}) &&\n+\n+\tgit history squash --no-edit start.. &&\n+\n+\tcheck_commit_count start..HEAD 1 &&\n+\ttest \"$tip_tree\" = \"$(git rev-parse HEAD^{tree})\" &&\n+\ttest_path_is_file o1 &&\n+\ttest_path_is_file o2\n+'\n+\n+test_expect_success 'refuses an octopus merge with an arm forked before the base' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\tgit checkout -b octo-pre &&\n+\ttest_commit octo-pre-side pside x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit octo-pre-main file b1 &&\n+\tocto_base=$(git rev-parse HEAD) &&\n+\tgit checkout -b octo-within &&\n+\ttest_commit --no-tag octo-within wside y &&\n+\tgit checkout \"$main\" &&\n+\tgit merge --no-ff -m octopus octo-pre octo-within &&\n+\tmerged=$(git rev-parse HEAD) &&\n+\tgit branch -D octo-pre octo-within &&\n+\n+\ttest_must_fail git history squash \"$octo_base..\" 2>err &&\n+\ttest_grep \"parent .* of commit .* is outside the revision range\" err &&\n+\ttest_cmp_rev \"$merged\" HEAD\n+'\n+\n+test_expect_success 'refuses when a descendant above the range is a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag desc-one file b &&\n+\ttest_commit --no-tag desc-two file c &&\n+\tgit tag desc-tip &&\n+\tgit checkout -b desc-above &&\n+\ttest_commit --no-tag desc-above above x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag desc-main file d &&\n+\tgit merge --no-ff -m \"merge desc-above\" desc-above &&\n+\tgit branch -D desc-above &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash --no-edit start..desc-tip 2>err &&\n+\ttest_grep \"merge commits is not supported\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD\n+'\n+\n+test_expect_success 'refuses to fold a range a ref points into at a merge' '\n+\tgit reset --hard start &&\n+\tmain=$(git symbolic-ref --short HEAD) &&\n+\ttest_commit --no-tag refmerge-base file b &&\n+\tgit checkout -b refmerge-side &&\n+\ttest_commit --no-tag refmerge-side side x &&\n+\tgit checkout \"$main\" &&\n+\ttest_commit --no-tag refmerge-main file c &&\n+\tgit merge --no-ff -m \"interior merge\" refmerge-side &&\n+\tgit branch -D refmerge-side &&\n+\tgit branch at-merge HEAD &&\n+\ttest_commit --no-tag refmerge-tail file d &&\n+\thead_before=$(git rev-parse HEAD) &&\n+\n+\ttest_must_fail git history squash start.. 2>err &&\n+\ttest_grep \"^error: the following branches cannot be rewritten\" err &&\n+\ttest_grep \"^  at-merge$\" err &&\n+\ttest_cmp_rev \"$head_before\" HEAD &&\n+\n+\tgit branch -D at-merge\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"552262","messageId":"xmqqik4fv4v1.fsf@gitster.g","threadId":"65812","inReplyTo":"8ad1faaa9a4ab71560b4e9321886706164518e9e.1788900120.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v15 4/8] history: add skeleton for squash subcommand","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-08T20:57:06Z","receivedAt":"2026-09-08T20:57:09Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n>\n> Add the entry point and option parsing for \"git history squash\". Pass\n> the remaining arguments through setup_revisions() so the command accepts\n> revision ranges and rev-list options, while restoring the ordering and\n> simplification settings required by the fold if an option changes them.\n>\n> Require at least one BOTTOM revision. The squashed commit needs a commit\n> outside the selected range to serve as its base, so a single positive\n> revision is not a sufficient range.\n>\n> Keep this step limited to defining the revision input contract so graph\n> validation and the rewrite can be added independently.\n>\n> Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n> Helped-by: Phillip Wood <phillip.wood@dunelm.org.uk>\n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>  Documentation/git-history.adoc |  1 +\n>  builtin/history.c              | 94 ++++++++++++++++++++++++++++++++++\n>  t/t9902-completion.sh          |  1 +\n>  3 files changed, 96 insertions(+)\n\nNice.  As the \"complete 'git history'\" topic is now in, with this part\n\n> diff --git a/t/t9902-completion.sh b/t/t9902-completion.sh\n> index 38cf221718..978c42c629 100755\n> --- a/t/t9902-completion.sh\n> +++ b/t/t9902-completion.sh\n> @@ -3227,6 +3227,7 @@ test_expect_success 'git history subcommands' '\n>  \tfixup Z\n>  \treword Z\n>  \tsplit Z\n> +\tsquash Z\n>  \tEOF\n>  \ttest_completion \"git history --\" \"\"\n>  '\n\nbeing a part of this series, I no longer need to worry about making\nan evil merge for this ;-).\n\nThanks.\n"}]}