From: "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Harald Nordgren <haraldnordgren@gmail.com>
Subject: [PATCH v5 0/4] history: add squash subcommand to fold a range
Date: Wed, 24 Jun 2026 21:54:58 +0000 [thread overview]
Message-ID: <pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com>
Adds git history squash <revision-range> to fold a range of commits.
Changes in v5:
* The range walk now uses --ancestry-path, so only commits descended from
the base are folded; a single revision such as HEAD or HEAD~1 is now
rejected as "not a <base>..<tip> range" rather than treated as a squash
down to the root.
* This adopts the --ancestry-path suggestion; the multi-base rejection is
unchanged, so a side branch that forked before the base and merged in is
still refused.
* Added tests covering more merge topologies: two interior merges, a nested
merge, an octopus merge, an octopus arm forked before the base, a merge
among the descendants replayed above the range, and a ref pointing at an
interior merge commit.
Changes in v4:
* git history squash now detects when another ref points at a commit inside
the range being folded and refuses, with an advice.historyUpdateRefs hint
to use --update-refs=head.
* A merge inside the range is folded fine as long as the range has a single
base; a range with merge commit at the tip or base also folds correctly.
Only a range with more than one base is rejected.
Changes in v3:
* Moved the feature out of git rebase and into a new git history squash
<revision-range> subcommand, per the list discussion. git rebase --squash
is dropped.
* Takes an arbitrary range (git history squash @~3.., git history squash
@~5..@~2), folding it into the oldest commit and replaying any
descendants on top.
* Implemented as a single tree operation rather than picking each commit,
so there are no repeated conflict stops (addresses Phillip's efficiency
point).
* A merge inside the range is folded fine, only a range with more than one
base is rejected.
* --reedit-message seeds the editor with every folded-in message, not just
the oldest.
Harald Nordgren (4):
history: extract helper for a commit's parent tree
history: give commit_tree_ext a message template
history: add squash subcommand to fold a range
history: re-edit a squash with every message
Documentation/config/advice.adoc | 4 +
Documentation/git-history.adoc | 26 ++
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 341 ++++++++++++++++++---
t/meson.build | 1 +
t/t3455-history-squash.sh | 497 +++++++++++++++++++++++++++++++
7 files changed, 833 insertions(+), 38 deletions(-)
create mode 100755 t/t3455-history-squash.sh
base-commit: 26d8d94e94df5535eecd036f16627493506a0614
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v5
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v5
Pull-Request: https://github.com/git/git/pull/2337
Range-diff vs v4:
1: fc2801c0b1 = 1: 0f1ae9b05a history: extract helper for a commit's parent tree
2: ee591e83b4 = 2: a97ffab1e6 history: give commit_tree_ext a message template
3: 80bfea642e ! 3: 04e18ef979 history: add squash subcommand to fold a range
@@ builtin/history.c: out:
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+ struct strvec args = STRVEC_INIT;
++ size_t i;
+ int ret;
+
+ repo_init_revisions(repo, &revs, NULL);
@@ builtin/history.c: out:
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_push(&args, "--boundary");
++ strvec_push(&args, "--ancestry-path");
+ strvec_push(&args, range);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (args.nr != 1) {
@@ builtin/history.c: out:
+ goto out;
+ }
+
++ /*
++ * A squash needs a base to reparent onto, so the argument has to
++ * exclude something, as in "<base>..<tip>". A single revision has no
++ * such bottom commit and cannot be squashed.
++ */
++ for (i = 0; i < revs.cmdline.nr; i++)
++ if (revs.cmdline.rev[i].flags & UNINTERESTING)
++ break;
++ if (i == revs.cmdline.nr) {
++ ret = error(_("'%s' is not a '<base>..<tip>' range"), range);
++ goto out;
++ }
++
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
@@ builtin/history.c: out:
+ goto out;
+ }
+
-+ if (!base) {
-+ ret = error(_("cannot squash the root commit"));
-+ goto out;
-+ }
++ if (!base)
++ BUG("a non-empty range must have a boundary commit");
+
+ *base_out = base;
+ *oldest_out = oldest;
@@ t/t3455-history-squash.sh (new)
+ test_grep "the range .* is empty" err
+'
+
-+test_expect_success 'errors when the range includes the root commit' '
++test_expect_success 'errors on a single revision that is not a range' '
+ test_must_fail git history squash HEAD 2>err &&
-+ test_grep "cannot squash the root commit" err
++ test_grep "is not a .*range" err &&
++ test_must_fail git history squash HEAD~1 2>err &&
++ test_grep "is not a .*range" err
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
@@ t/t3455-history-squash.sh (new)
+ test_path_is_file inner
+'
+
++test_expect_success 'folds a merge of a branch that forked at the base' '
++ git reset --hard start &&
++ git checkout -b base-fork-side &&
++ test_commit --no-tag base-fork-side side x &&
++ git checkout - &&
++ test_commit --no-tag base-fork-main file b &&
++ git merge --no-ff -m "merge base-fork-side" base-fork-side &&
++ git branch -D base-fork-side &&
++ test_commit --no-tag base-fork-tail file c &&
++ tip_tree=$(git rev-parse HEAD^{tree}) &&
++
++ git history squash start.. &&
++
++ git rev-list --count start..HEAD >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
++ test_cmp_rev start HEAD^ &&
++ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
++ test_path_is_file side
++'
++
+test_expect_success 'folds a range whose tip is a merge commit' '
+ git reset --hard start &&
+ test_commit --no-tag tipmerge-base file b &&
@@ t/t3455-history-squash.sh (new)
+ test_cmp_rev "$merged" HEAD
+'
+
++test_expect_success 'folds a range with two interior merges' '
++ git reset --hard start &&
++ test_commit --no-tag two-merge-a file a1 &&
++ git checkout -b two-merge-s1 &&
++ test_commit --no-tag two-merge-s1 s1 x &&
++ git checkout - &&
++ git merge --no-ff -m "merge s1" two-merge-s1 &&
++ test_commit --no-tag two-merge-b file b1 &&
++ git checkout -b two-merge-s2 &&
++ test_commit --no-tag two-merge-s2 s2 y &&
++ git checkout - &&
++ git merge --no-ff -m "merge s2" two-merge-s2 &&
++ git branch -D two-merge-s1 two-merge-s2 &&
++ tip_tree=$(git rev-parse HEAD^{tree}) &&
++
++ git history squash start.. &&
++
++ git rev-list --count start..HEAD >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
++ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
++ test_path_is_file s1 &&
++ test_path_is_file s2
++'
++
++test_expect_success 'folds a range with a nested merge' '
++ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
++ git checkout -b nested-outer &&
++ test_commit --no-tag nested-outer outer x &&
++ git checkout -b nested-inner &&
++ test_commit --no-tag nested-inner inner y &&
++ git checkout nested-outer &&
++ git merge --no-ff -m "merge inner" nested-inner &&
++ git checkout "$main" &&
++ test_commit --no-tag nested-main file b1 &&
++ git merge --no-ff -m "merge outer" nested-outer &&
++ git branch -D nested-outer nested-inner &&
++ tip_tree=$(git rev-parse HEAD^{tree}) &&
++
++ git history squash start.. &&
++
++ git rev-list --count start..HEAD >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
++ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
++ test_path_is_file outer &&
++ test_path_is_file inner
++'
++
++test_expect_success 'folds a range with an octopus merge' '
++ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
++ test_commit --no-tag octo-base file a1 &&
++ git checkout -b octo-1 &&
++ test_commit --no-tag octo-1 o1 x &&
++ git checkout "$main" &&
++ git checkout -b octo-2 &&
++ test_commit --no-tag octo-2 o2 y &&
++ git checkout "$main" &&
++ git merge --no-ff -m octopus octo-1 octo-2 &&
++ git branch -D octo-1 octo-2 &&
++ tip_tree=$(git rev-parse HEAD^{tree}) &&
++
++ git history squash start.. &&
++
++ git rev-list --count start..HEAD >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
++ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
++ test_path_is_file o1 &&
++ test_path_is_file o2
++'
++
++test_expect_success 'refuses an octopus merge with an arm forked before the base' '
++ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
++ git checkout -b octo-pre &&
++ test_commit octo-pre-side pside x &&
++ git checkout "$main" &&
++ test_commit octo-pre-main file b1 &&
++ octo_base=$(git rev-parse HEAD) &&
++ git checkout -b octo-within &&
++ test_commit --no-tag octo-within wside y &&
++ git checkout "$main" &&
++ git merge --no-ff -m octopus octo-pre octo-within &&
++ merged=$(git rev-parse HEAD) &&
++ git branch -D octo-pre octo-within &&
++
++ test_must_fail git history squash "$octo_base.." 2>err &&
++ test_grep "more than one base" err &&
++ test_cmp_rev "$merged" HEAD
++'
++
++test_expect_success 'refuses when a descendant above the range is a merge' '
++ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
++ test_commit --no-tag desc-base file b &&
++ git tag desc-tip &&
++ git checkout -b desc-above &&
++ test_commit --no-tag desc-above above x &&
++ git checkout "$main" &&
++ test_commit --no-tag desc-main file c &&
++ git merge --no-ff -m "merge desc-above" desc-above &&
++ git branch -D desc-above &&
++ head_before=$(git rev-parse HEAD) &&
++
++ test_must_fail git history squash start..desc-tip 2>err &&
++ test_grep "merge commits is not supported" err &&
++ test_cmp_rev "$head_before" HEAD
++'
++
++test_expect_success 'refuses to fold a range a ref points into at a merge' '
++ git reset --hard start &&
++ main=$(git symbolic-ref --short HEAD) &&
++ test_commit --no-tag refmerge-base file b &&
++ git checkout -b refmerge-side &&
++ test_commit --no-tag refmerge-side side x &&
++ git checkout "$main" &&
++ test_commit --no-tag refmerge-main file c &&
++ git merge --no-ff -m "interior merge" refmerge-side &&
++ git branch -D refmerge-side &&
++ git branch at-merge HEAD &&
++ test_commit --no-tag refmerge-tail file d &&
++ head_before=$(git rev-parse HEAD) &&
++
++ test_must_fail git history squash start.. 2>err &&
++ test_grep "at-merge" err &&
++ test_grep "points into the squashed range" err &&
++ test_cmp_rev "$head_before" HEAD &&
++
++ git branch -D at-merge
++'
++
+test_done
4: 85c7817d7e = 4: a758e1f084 history: re-edit a squash with every message
--
gitgitgadget
next prev parent reply other threads:[~2026-06-24 21:55 UTC|newest]
Thread overview: 39+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-14 19:25 [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
2026-06-14 19:25 ` [PATCH 2/2] rebase: add --fixup-all to fold a range Harald Nordgren via GitGitGadget
2026-06-15 2:01 ` [PATCH 0/2] rebase: add --fixup to fold a range into its oldest commit Junio C Hamano
2026-06-15 8:18 ` Harald Nordgren
2026-06-15 15:17 ` D. Ben Knoble
2026-06-16 8:34 ` Patrick Steinhardt
2026-06-17 9:30 ` Harald Nordgren
2026-06-15 8:37 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 1/2] t3415: remove prepare-commit-msg hook after use Harald Nordgren via GitGitGadget
2026-06-15 8:37 ` [PATCH v2 2/2] rebase: add --squash to fold a range Harald Nordgren via GitGitGadget
2026-06-16 10:10 ` [PATCH v2 0/2] rebase: add --squash to fold a range into its first commit Phillip Wood
2026-06-17 9:11 ` Harald Nordgren
2026-06-17 9:48 ` Phillip Wood
2026-06-18 19:17 ` [PATCH v3 0/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 1/4] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 2/4] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
2026-06-18 19:17 ` [PATCH v3 3/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-06-18 20:30 ` Junio C Hamano
2026-06-18 21:24 ` Junio C Hamano
2026-06-18 21:29 ` D. Ben Knoble
2026-06-19 12:55 ` Patrick Steinhardt
2026-06-18 19:17 ` [PATCH v3 4/4] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
2026-06-18 21:23 ` [PATCH v3 0/4] history: add squash subcommand to fold a range D. Ben Knoble
2026-06-19 0:34 ` Junio C Hamano
2026-06-19 12:37 ` Patrick Steinhardt
2026-06-19 16:11 ` Junio C Hamano
2026-06-21 5:53 ` [PATCH v4 " Harald Nordgren via GitGitGadget
2026-06-21 5:53 ` [PATCH v4 1/4] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-06-21 5:53 ` [PATCH v4 2/4] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
2026-06-21 5:53 ` [PATCH v4 3/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-06-21 5:53 ` [PATCH v4 4/4] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
2026-06-22 11:54 ` [PATCH v4 0/4] history: add squash subcommand to fold a range Patrick Steinhardt
2026-06-23 10:41 ` Harald Nordgren
2026-06-24 21:54 ` Harald Nordgren via GitGitGadget [this message]
2026-06-24 21:54 ` [PATCH v5 1/4] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-06-24 21:55 ` [PATCH v5 2/4] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
2026-06-24 21:55 ` [PATCH v5 3/4] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-06-24 21:55 ` [PATCH v5 4/4] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=pull.2337.v5.git.git.1782338102.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=git@vger.kernel.org \
--cc=haraldnordgren@gmail.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.