From: Phillip Wood Date: Fri, 26 Jun 2026 08:52:57 GMT Subject: Re: [PATCH v5 0/4] history: add squash subcommand to fold a range Message-ID: In-Reply-To: Hi Harald On 24/06/2026 22:54, Harald Nordgren via GitGitGadget wrote: > Adds git history squash to fold a range of commits. It would be helpful to give a bit more detail here about the command so that the reader has an overview of what is actually being implemented. - what does it do with fixup!, squash! and amend! commits? Can it use the message from amend! commits to reword the commit? - can the user reword the commit message? - what happens if a merge commit inside the range has a parent outside the range? - what happens to branches that point to commits inside the range? I had a quick play and found that it accepts ranges that containing a single commit (e.g. @^!) where there is nothing to squash. It also accepts ranges that are not ancestors of HEAD (e.g. checkout master and run "git history squash --dry-run origin/seen^2^!") without printing an error message. Only accepting a single argument is quite limiting as one cannot say git history squash ^:/base :/tip Thanks Phillip > 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 .. 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 > 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 "..". 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 '..' 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 >