From: "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Harald Nordgren <haraldnordgren@gmail.com>
Subject: [PATCH v4 0/4] history: add squash subcommand to fold a range
Date: Sun, 21 Jun 2026 05:53:11 +0000 [thread overview]
Message-ID: <pull.2337.v4.git.git.1782021195.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2337.v3.git.git.1781810226.gitgitgadget@gmail.com>
Adds git history squash <revision-range> to fold a range of commits.
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 | 328 +++++++++++++++++++++++++----
t/meson.build | 1 +
t/t3455-history-squash.sh | 340 +++++++++++++++++++++++++++++++
7 files changed, 663 insertions(+), 38 deletions(-)
create mode 100755 t/t3455-history-squash.sh
base-commit: 8d96f09e9245ddf80c1981476fcbac8c4bb4125f
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v4
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v4
Pull-Request: https://github.com/git/git/pull/2337
Range-diff vs v3:
1: 1e31474ef6 = 1: fc2801c0b1 history: extract helper for a commit's parent tree
2: 498da64046 = 2: ee591e83b4 history: give commit_tree_ext a message template
3: 66b2f49fb4 ! 3: 80bfea642e history: add squash subcommand to fold a range
@@ Commit message
other commit, but the range must have a single base, so a range with
more than one entry point is rejected.
+ The folded commits leave the history, so by default the command refuses
+ when another ref points at one of them. Use "--update-refs=head" to
+ rewrite only the current branch and leave those refs untouched.
+
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
+ ## Documentation/config/advice.adoc ##
+@@ Documentation/config/advice.adoc: all advice messages.
+ forceDeleteBranch::
+ Shown when the user tries to delete a not fully merged
+ branch without the force option set.
++ historyUpdateRefs::
++ Shown when `git history squash` refuses because a ref points
++ into the range being folded, to tell the user about
++ `--update-refs=head`.
+ ignoredHook::
+ Shown when a hook is ignored because the hook is not
+ set as executable.
+
## Documentation/git-history.adoc ##
@@ Documentation/git-history.adoc: SYNOPSIS
git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
@@ Documentation/git-history.adoc: linkgit:gitglossary[7].
+folded like any other, but the range must have a single base, so a range
+that reaches more than one entry point (for example a side branch that
+forked before the range and was later merged into it) is rejected.
+++
++The folded commits disappear from the history, so with the default
++`--update-refs=branches` the command refuses when another ref points at
++one of them. Rerun with `--update-refs=head` to rewrite only the current
++branch and leave those refs pointing at the old commits.
+
OPTIONS
-------
+ ## advice.c ##
+@@ advice.c: static struct {
+ [ADVICE_FETCH_SHOW_FORCED_UPDATES] = { "fetchShowForcedUpdates" },
+ [ADVICE_FORCE_DELETE_BRANCH] = { "forceDeleteBranch" },
+ [ADVICE_GRAFT_FILE_DEPRECATED] = { "graftFileDeprecated" },
++ [ADVICE_HISTORY_UPDATE_REFS] = { "historyUpdateRefs" },
+ [ADVICE_IGNORED_HOOK] = { "ignoredHook" },
+ [ADVICE_IMPLICIT_IDENTITY] = { "implicitIdentity" },
+ [ADVICE_MERGE_CONFLICT] = { "mergeConflict" },
+
+ ## advice.h ##
+@@ advice.h: enum advice_type {
+ ADVICE_FETCH_SHOW_FORCED_UPDATES,
+ ADVICE_FORCE_DELETE_BRANCH,
+ ADVICE_GRAFT_FILE_DEPRECATED,
++ ADVICE_HISTORY_UPDATE_REFS,
+ ADVICE_IGNORED_HOOK,
+ ADVICE_IMPLICIT_IDENTITY,
+ ADVICE_MERGE_CONFLICT,
+
## builtin/history.c ##
+@@
+ #define USE_THE_REPOSITORY_VARIABLE
+
+ #include "builtin.h"
++#include "advice.h"
+ #include "cache-tree.h"
+ #include "commit.h"
+ #include "commit-reach.h"
@@
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
@@ builtin/history.c: out:
+ const char *range,
+ struct commit **base_out,
+ struct commit **oldest_out,
-+ struct commit **tip_out)
++ struct commit **tip_out,
++ struct oidset *interior_out)
+{
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
@@ builtin/history.c: out:
+ }
+ if (!oldest)
+ oldest = commit;
++ if (tip)
++ oidset_insert(interior_out, &tip->object.oid);
+ tip = commit;
+ }
+
@@ builtin/history.c: out:
+ return ret;
+}
+
++struct interior_ref_cb {
++ const struct oidset *interior;
++ const char *name;
++};
++
++static int find_interior_ref(const struct reference *ref, void *cb_data)
++{
++ struct interior_ref_cb *data = cb_data;
++
++ if (oidset_contains(data->interior, ref->oid)) {
++ data->name = xstrdup(ref->name);
++ return 1;
++ }
++
++ return 0;
++}
++
+static int cmd_history_squash(int argc,
+ const char **argv,
+ const char *prefix,
@@ builtin/history.c: out:
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
++ struct oidset interior = OIDSET_INIT;
+ struct commit *base, *oldest, *tip, *rewritten;
+ const struct object_id *base_tree_oid, *tip_tree_oid;
+ struct commit_list *parents = NULL;
@@ builtin/history.c: out:
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
-+ ret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip);
++ ret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip,
++ &interior);
+ if (ret < 0)
+ goto out;
+
++ if (action == REF_ACTION_BRANCHES) {
++ struct interior_ref_cb cb = { .interior = &interior };
++
++ refs_for_each_ref(get_main_ref_store(repo),
++ find_interior_ref, &cb);
++ if (cb.name) {
++ ret = error(_("'%s' points into the squashed range"),
++ cb.name);
++ advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
++ _("Use --update-refs=head to rewrite only "
++ "the current branch and leave such refs "
++ "untouched."));
++ free((char *)cb.name);
++ goto out;
++ }
++ }
++
+ ret = setup_revwalk(repo, action, tip, &revs);
+ if (ret < 0)
+ goto out;
@@ builtin/history.c: out:
+
+out:
+ strbuf_release(&reflog_msg);
++ oidset_clear(&interior);
+ commit_list_free(parents);
+ release_revisions(&revs);
+ return ret;
@@ t/meson.build: integration_tests = [
't3451-history-reword.sh',
't3452-history-split.sh',
't3453-history-fixup.sh',
-+ 't3454-history-squash.sh',
++ 't3455-history-squash.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
- ## t/t3454-history-squash.sh (new) ##
+ ## t/t3455-history-squash.sh (new) ##
@@
+#!/bin/sh
+
@@ t/t3454-history-squash.sh (new)
+test_expect_success 'setup linear history touching two files' '
+ test_commit base file a &&
+ git tag start &&
-+ test_commit one other x &&
-+ test_commit two file c &&
++ test_commit --no-tag one other x &&
++ test_commit --no-tag two file c &&
+ test_commit three file d
+'
+
@@ t/t3454-history-squash.sh (new)
+
+test_expect_success 'reuses the message of a fixup! commit in the range' '
+ git reset --hard start &&
-+ test_commit reg1 file b &&
++ test_commit --no-tag reg1 file b &&
+ git commit --allow-empty -m "fixup! reg1" &&
+ test_commit reg2 file c &&
+
@@ t/t3454-history-squash.sh (new)
+test_expect_success 'preserves authorship of the oldest commit' '
+ git reset --hard start &&
+ GIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \
-+ test_commit oldest file b &&
++ test_commit --no-tag oldest file b &&
+ test_commit newest file c &&
+
+ git history squash start.. &&
@@ t/t3454-history-squash.sh (new)
+test_expect_success '--dry-run predicts the rewrite without performing it' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
++ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --dry-run start.. >out &&
-+ grep "^update refs/heads/" out >update &&
-+ predicted=$(awk "{print \$3}" update) &&
++ predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git history squash start.. &&
-+ test "$predicted" = "$(git rev-parse HEAD)"
++ test "$predicted" = "$(git rev-parse HEAD)" &&
++ 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_expect_success '--update-refs=head only moves HEAD' '
@@ t/t3454-history-squash.sh (new)
+ test_cmp_rev "$other_before" other
+'
+
-+test_expect_success '--update-refs=branches moves a branch pointing into the range' '
++test_expect_success 'refuses to fold a range a ref points into' '
++ git reset --hard three &&
++ git branch -f mid HEAD~1 &&
++ head_before=$(git rev-parse HEAD) &&
++
++ test_must_fail git history squash start.. 2>err &&
++ test_grep "error: .* points into the squashed range" err &&
++ test_grep "hint: .*--update-refs=head" err &&
++ test_cmp_rev "$head_before" HEAD &&
++
++ git branch -D mid
++'
++
++test_expect_success 'advice.historyUpdateRefs silences the hint' '
++ git reset --hard three &&
++ git branch -f mid HEAD~1 &&
++
++ test_must_fail git -c advice.historyUpdateRefs=false \
++ history squash start.. 2>err &&
++ test_grep "points into the squashed range" err &&
++ test_grep ! "hint:" err &&
++
++ git branch -D mid
++'
++
++test_expect_success '--update-refs=head folds past a ref pointing into the range' '
+ git reset --hard three &&
-+ git branch -f mid HEAD~2 &&
++ git branch -f mid HEAD~1 &&
+ mid_before=$(git rev-parse mid) &&
+
-+ git history squash start..@~1 &&
++ git history squash --update-refs=head start.. &&
+
++ git rev-list --count start..HEAD >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
+ test_cmp_rev "$mid_before" mid &&
-+ test_commit_message mid -m one
++
++ git branch -D mid
++'
++
++test_expect_success 'refuses to fold a range a tag points into' '
++ git reset --hard three &&
++ git tag -f mark HEAD~1 &&
++ head_before=$(git rev-parse HEAD) &&
++
++ test_must_fail git history squash start.. 2>err &&
++ test_grep "refs/tags/mark" err &&
++ test_grep "points into the squashed range" err &&
++ test_cmp_rev "$head_before" HEAD &&
++
++ git tag -d mark
+'
+
+test_expect_success 'squashes a range whose internal merge has a single base' '
+ git reset --hard start &&
-+ test_commit before-side file b &&
++ test_commit --no-tag before-side file b &&
+ git checkout -b inner-side &&
-+ test_commit on-inner-side inner x &&
++ test_commit --no-tag on-inner-side inner x &&
+ git checkout - &&
-+ test_commit after-side file c &&
++ test_commit --no-tag after-side file c &&
+ git merge --no-ff -m merge inner-side &&
-+ test_commit after-merge file d &&
++ git branch -D inner-side &&
++ test_commit --no-tag after-merge file d &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
@@ t/t3454-history-squash.sh (new)
+ test_path_is_file inner
+'
+
++test_expect_success 'folds a range whose tip is a merge commit' '
++ git reset --hard start &&
++ test_commit --no-tag tipmerge-base file b &&
++ git checkout -b tipmerge-side &&
++ test_commit --no-tag tipmerge-side side x &&
++ git checkout - &&
++ test_commit --no-tag tipmerge-main file c &&
++ git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
++ git branch -D tipmerge-side &&
++ 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 side
++'
++
++test_expect_success 'folds a range whose base is a merge commit' '
++ git reset --hard start &&
++ git checkout -b basemerge-side &&
++ test_commit --no-tag basemerge-side side x &&
++ git checkout - &&
++ test_commit --no-tag basemerge-main file b &&
++ git merge --no-ff -m "merge basemerge-side" basemerge-side &&
++ git branch -D basemerge-side &&
++ base=$(git rev-parse HEAD) &&
++ test_commit --no-tag basemerge-one file c &&
++ test_commit --no-tag basemerge-two file d &&
++ tip_tree=$(git rev-parse HEAD^{tree}) &&
++
++ git history squash "$base.." &&
++
++ git rev-list --count "$base..HEAD" >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
++ test_cmp_rev "$base" HEAD^ &&
++ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
++'
++
+test_expect_success 'refuses to squash a range with more than one base' '
+ git reset --hard start &&
+ head_before=$(git rev-parse HEAD) &&
4: 43e4270614 ! 4: 85c7817d7e history: re-edit a squash with every message
@@ Documentation/git-history.adoc: history squash @~3..` folds the three most recen
forked before the range and was later merged into it) is rejected.
## builtin/history.c ##
-@@ builtin/history.c: out:
- return ret;
+@@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data)
+ return 0;
}
+static int build_squash_message(struct repository *repo,
@@ builtin/history.c: static int cmd_history_squash(int argc,
};
struct strbuf reflog_msg = STRBUF_INIT;
+ struct strbuf message = STRBUF_INIT;
+ struct oidset interior = OIDSET_INIT;
struct commit *base, *oldest, *tip, *rewritten;
const struct object_id *base_tree_oid, *tip_tree_oid;
- struct commit_list *parents = NULL;
@@ builtin/history.c: static int cmd_history_squash(int argc,
- if (ret < 0)
- goto out;
+ }
+ }
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = build_squash_message(repo, base, tip, &message);
@@ builtin/history.c: static int cmd_history_squash(int argc,
out:
strbuf_release(&reflog_msg);
+ strbuf_release(&message);
+ oidset_clear(&interior);
commit_list_free(parents);
release_revisions(&revs);
- return ret;
- ## t/t3454-history-squash.sh ##
-@@ t/t3454-history-squash.sh: test_expect_success 'preserves authorship of the oldest commit' '
+ ## t/t3455-history-squash.sh ##
+@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the oldest commit' '
test_cmp expect actual
'
@@ t/t3454-history-squash.sh: test_expect_success 'preserves authorship of the olde
+ echo b >file &&
+ git add file &&
+ git commit -m "re-one subject" -m "re-one body line" &&
-+ test_commit re-two file c &&
++ test_commit --no-tag re-two file c &&
+ test_commit re-three file d &&
+
+ write_script editor <<-\EOF &&
@@ t/t3454-history-squash.sh: test_expect_success 'preserves authorship of the olde
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
-+ grep "re-one subject" buffer &&
-+ grep "re-one body line" buffer &&
-+ grep re-two buffer &&
-+ grep re-three buffer &&
++ test_grep "re-one subject" buffer &&
++ test_grep "re-one body line" buffer &&
++ test_grep re-two buffer &&
++ test_grep re-three buffer &&
+ git log --format="%s" -1 >actual &&
+ echo combined >expect &&
+ test_cmp expect actual
--
gitgitgadget
next prev parent reply other threads:[~2026-06-21 5:53 UTC|newest]
Thread overview: 32+ 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 ` Harald Nordgren via GitGitGadget [this message]
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
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.v4.git.git.1782021195.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.