[PATCH v10 0/5] history: add squash subcommand to fold a range
From: Harald Nordgren via GitGitGadget <hidden>
Date: 2026-07-20 08:27:07
Adds git history squash <revision-range> to fold a range of commits. Changes in v10: * Record the full revision expression in squash reflog. * Preserve the boundary-walk invariant when sanitizing rev-list options. * Clarify amend! and --reedit-message documentation. Changes in v9: * Use the last amend! targeting the oldest folded commit as the default squashed message. Ignore amend! markers targeting later commits while selecting that replacement message. * Improve tests. Changes in v8: * --reedit-message now builds the same editor template as git rebase -i --autosquash: fixup!, squash! and amend! commits are grouped under the commit they target instead of shown in commit order, and an amend! replaces its target's message. * A fixup!, squash! or amend! is refused only when its target is outside the range, so several fixups for an in-range commit fold together. A range that is entirely markers for one below-range target is combined into a single commit, keeping the last amend! message. * Merges inside the range are folded when the range has a single base, with no dedicated opt-in flag, --ancestry-path ensures only commits descended from the base are folded, and a range reaching more than one base is rejected. * Rev-list options are accepted and sanitized the way git replay does, forcing the walk order back with a warning, which also fixes git history squash -- --reverse slipping past the previous option check. * Kept this as an explicit squash subcommand rather than making --reedit-message the default or renaming the command. Changes in v7: * --reedit-message now builds the same editor template git rebase -i shows for a squash (a combination of N commits banner with each folded message under its own header) and follows autosquash for markers: a fixup! message falls out (commented under a will be skipped header), while a squash! or amend! keeps its body with only the marker subject commented so its remark can be reworded in. Only the message text is affected, every commit's changes are always folded in. * Reuse git rebase -i's squash-message code: a preparatory sequencer: commit extracts the banner, header and marker-comment helpers so both rebase and git history squash build the identical template from one source. * Refuse a range whose oldest commit is a fixup!, squash! or amend!, since the marker's target cannot be inside the range. * Reorder the squash usage so dashed options come before <revision-range>, and spell out HEAD instead of @ in the documentation and examples. * Expand the squash commit message and documentation with this overview, and scope the merge limitation so it no longer contradicts squash folding a single-base interior merge. Changes in v6: * git history squash now accepts multiple revision arguments, read like the arguments to git-rev-list, so a compound range such as @~3.. ^topic works. * The base to reparent onto is now the oldest in-range commit's parent; a boundary other than that base means the range has more than one base and is rejected. This also fixes the earlier overly-restrictive handling of merges and side branches. * A single-commit range (e.g. @^!) is rejected with "nothing to squash" (this also covers the @^!-style example that previously succeeded silently). * Commit messages reworded: the squash commit now gives an overview of fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior. Changes in v5: * The range walk now uses --ancestry-path, so only commits descended from the base are folded; a single revision such as HEAD or HEAD~1 is now rejected as "not a <base>..<tip> range" rather than treated as a squash down to the root. * This adopts the --ancestry-path suggestion; the multi-base rejection is unchanged, so a side branch that forked before the base and merged in is still refused. * Added tests covering more merge topologies: two interior merges, a nested merge, an octopus merge, an octopus arm forked before the base, a merge among the descendants replayed above the range, and a ref pointing at an interior merge commit. Changes in v4: * git history squash now detects when another ref points at a commit inside the range being folded and refuses, with an advice.historyUpdateRefs hint to use --update-refs=head. * A merge inside the range is folded fine as long as the range has a single base; a range with merge commit at the tip or base also folds correctly. Only a range with more than one base is rejected. Changes in v3: * Moved the feature out of git rebase and into a new git history squash <revision-range> subcommand, per the list discussion. git rebase --squash is dropped. * Takes an arbitrary range (git history squash @~3.., git history squash @~5..@~2), folding it into the oldest commit and replaying any descendants on top. * Implemented as a single tree operation rather than picking each commit, so there are no repeated conflict stops (addresses Phillip's efficiency point). * A merge inside the range is folded fine, only a range with more than one base is rejected. * --reedit-message seeds the editor with every folded-in message, not just the oldest. Harald Nordgren (5): history: extract helper for a commit's parent tree history: give commit_tree_ext a message template history: add squash subcommand to fold a range sequencer: share the squash message marker helpers and flags history: re-edit a squash with every message Documentation/config/advice.adoc | 4 + Documentation/git-history.adoc | 55 ++- advice.c | 1 + advice.h | 1 + builtin/history.c | 552 ++++++++++++++++++++-- sequencer.c | 70 +-- sequencer.h | 30 ++ t/meson.build | 1 + t/t3455-history-squash.sh | 770 +++++++++++++++++++++++++++++++ 9 files changed, 1412 insertions(+), 72 deletions(-) create mode 100755 t/t3455-history-squash.sh base-commit: 41365c2a9ba347870b80881c0d67454edd22fd49 Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v10 Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v10 Pull-Request: https://github.com/git/git/pull/2337 Range-diff vs v9: 1: 352c818c29 = 1: f848103497 history: extract helper for a commit's parent tree 2: e06e49095b = 2: 49dadc3410 history: give commit_tree_ext a message template 3: ead974c317 ! 3: 6b5b2c93f2 history: add squash subcommand to fold a range @@ Documentation/config/advice.adoc: all advice messages. set as executable. ## Documentation/git-history.adoc ## -@@ Documentation/git-history.adoc: SYNOPSIS +@@ Documentation/git-history.adoc: git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=( git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] git history reword <commit> [--dry-run] [--update-refs=(branches|head)] git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...] @@ Documentation/git-history.adoc: linkgit:gitglossary[7]. ++ +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it +targets is also in the range, so the fold does not silently absorb a -+marker meant for a commit outside it. The body after an `amend!` subject -+replaces the oldest commit's message when the marker targets that commit. As -+an exception, a range made up entirely of markers for one target is combined -+into a single commit, keeping the last `amend!` message if there is one. ++marker meant for a commit outside it. As an exception, a range made up entirely ++of markers for one target is combined into a single commit, keeping the last ++`amend!` message if there is one. ++ +A branch or tag that points at a commit inside the range would be left +dangling once those commits are folded away, so with the default @@ Documentation/git-history.adoc: OPTIONS `--reedit-message`:: - Open an editor to modify the target commit's message. -+ Open an editor to modify the rewritten commit's message. For `squash` -+ the editor is pre-filled with the messages of all the folded commits. ++ Open an editor to modify the rewritten commit's message. `--empty=(drop|keep|abort)`:: Control what happens when a commit becomes empty as a result of the @@ builtin/history.c: out: + + if (revs.reverse != 1 || revs.topo_order != 1 || + revs.sort_order != REV_SORT_IN_GRAPH_ORDER || -+ revs.simplify_history != 0) { ++ revs.simplify_history != 0 || revs.boundary != 1) { + warning(_("ignoring rev-list options that would change how the " + "range is walked")); + revs.reverse = 1; + revs.topo_order = 1; + revs.sort_order = REV_SORT_IN_GRAPH_ORDER; + revs.simplify_history = 0; ++ revs.boundary = 1; + } + + /* @@ builtin/history.c: out: + goto out; + } + -+ strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]); ++ strbuf_addstr(&reflog_msg, "squash: updating "); ++ strbuf_join_argv(&reflog_msg, argc, argv, ' '); + + ret = handle_reference_updates(&revs, action, tip, rewritten, + reflog_msg.buf, dry_run, @@ builtin/history.c: out: + return ret; +} + - int cmd_history(int argc, - const char **argv, - const char *prefix, + static int update_worktree(struct repository *repo, + const struct commit *old_head, + const struct commit *new_head, @@ builtin/history.c: int cmd_history(int argc, GIT_HISTORY_FIXUP_USAGE, GIT_HISTORY_REWORD_USAGE, @@ builtin/history.c: int cmd_history(int argc, ## t/meson.build ## @@ t/meson.build: integration_tests = [ - 't3451-history-reword.sh', 't3452-history-split.sh', 't3453-history-fixup.sh', + 't3454-history-drop.sh', + 't3455-history-squash.sh', 't3500-cherry.sh', 't3501-revert-cherry-pick.sh', @@ t/t3455-history-squash.sh (new) + + git history squash start..HEAD ^keep && + ++ git reflog -1 --format=%gs >actual && ++ echo "squash: updating start..HEAD ^keep" >expect && ++ test_cmp expect actual && ++ + check_log_subjects start..HEAD <<-\EOF && + two + one 4: 08915cee51 = 4: 41156c9afb sequencer: share the squash message marker helpers and flags 5: fb76afe31c ! 5: cdbc183428 history: re-edit a squash with every message @@ Documentation/git-history.adoc: already on `topic`. Rev-list options may also be + A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it targets is also in the range, so the fold does not silently absorb a -@@ Documentation/git-history.adoc: marker meant for a commit outside it. The body after an `amend!` subject - replaces the oldest commit's message when the marker targets that commit. As - an exception, a range made up entirely of markers for one target is combined - into a single commit, keeping the last `amend!` message if there is one. -+The changes from every commit in the range are always folded in. Only the -+message text differs. +@@ Documentation/git-history.adoc: marker meant for a commit outside it. As an exception, a range made up entirely + of markers for one target is combined into a single commit, keeping the last + `amend!` message if there is one. + + +With `--reedit-message` the template mirrors `git rebase -i --autosquash`: +each `fixup!`, `squash!`, or `amend!` is grouped under the commit it +targets rather than shown in commit order. A `fixup!` message is dropped @@ Documentation/git-history.adoc: marker meant for a commit outside it. The body a +subject commented, and an `amend!` replaces its target's message, unless +a `squash!` folded into that target first, in which case it keeps its +body like a `squash!`. - + +++ A branch or tag that points at a commit inside the range would be left dangling once those commits are folded away, so with the default + `--update-refs=branches` the command refuses. Rerun with +@@ Documentation/git-history.adoc: OPTIONS + ref updates is generally safe. + + `--reedit-message`:: +- Open an editor to modify the rewritten commit's message. ++ Open an editor to modify the rewritten commit's message. For `squash` ++ the editor is pre-filled with the messages of all the folded commits. + + `--empty=(drop|keep|abort)`:: + Control what happens when a commit becomes empty as a result of the ## builtin/history.c ## @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data) -- gitgitgadget