* [PATCH v7 1/5] history: extract helper for a commit's parent tree
2026-07-06 8:50 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-07-06 8:50 ` Harald Nordgren via GitGitGadget
2026-07-06 8:50 ` [PATCH v7 2/5] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
` (6 subsequent siblings)
7 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-06 8:50 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Three places resolve the tree of a commit's first parent, falling back
to the empty tree for a root commit, each repeating the same parse and
oidcpy dance. Extract a first_parent_tree_oid() helper and route the
existing callers through it.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 58 +++++++++++++++++++++--------------------------
1 file changed, 26 insertions(+), 32 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 091465a59e..f95f26e684 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -157,6 +157,25 @@ out:
return ret;
}
+static int first_parent_tree_oid(struct repository *repo,
+ struct commit *commit,
+ struct object_id *out)
+{
+ struct commit *parent = commit->parents ? commit->parents->item : NULL;
+
+ if (!parent) {
+ oidcpy(out, repo->hash_algo->empty_tree);
+ return 0;
+ }
+
+ if (repo_parse_commit(repo, parent))
+ return error(_("unable to parse parent commit %s"),
+ oid_to_hex(&parent->object.oid));
+
+ oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
+ return 0;
+}
+
static int commit_tree_with_edited_message(struct repository *repo,
const char *action,
struct commit *original,
@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,
{
struct object_id parent_tree_oid;
const struct object_id *tree_oid;
- struct commit *parent;
tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- parent = original->parents ? original->parents->item : NULL;
- if (parent) {
- if (repo_parse_commit(repo, parent)) {
- return error(_("unable to parse parent commit %s"),
- oid_to_hex(&parent->object.oid));
- }
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return commit_tree_ext(repo, action, original, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,
struct commit *original,
struct tree *result)
{
- struct commit *parent = original->parents ? original->parents->item : NULL;
struct object_id parent_tree_oid;
- if (parent) {
- if (repo_parse_commit(repo, parent))
- return error(_("unable to parse parent of %s"),
- oid_to_hex(&original->object.oid));
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return oideq(&result->object.oid, &parent_tree_oid);
}
@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,
struct tree *split_tree;
int ret;
- if (original->parents) {
- if (repo_parse_commit(repo, original->parents->item)) {
- ret = error(_("unable to parse parent commit %s"),
- oid_to_hex(&original->parents->item->object.oid));
- goto out;
- }
-
- parent_tree_oid = *get_commit_tree_oid(original->parents->item);
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
+ ret = -1;
+ goto out;
}
original_commit_tree_oid = get_commit_tree_oid(original);
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v7 2/5] history: give commit_tree_ext a message template
2026-07-06 8:50 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-07-06 8:50 ` [PATCH v7 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
@ 2026-07-06 8:50 ` Harald Nordgren via GitGitGadget
2026-07-06 8:50 ` [PATCH v7 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
7 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-06 8:50 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
commit_tree_ext() reuses the message of the commit it is handed. A
caller that folds several commits together wants to seed the message
from more than that single commit, so add an optional message_template
parameter. When NULL, the behavior is unchanged.
Pass NULL from the existing fixup and split callers.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index f95f26e684..305bde3102 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -101,6 +101,7 @@ enum commit_tree_flags {
static int commit_tree_ext(struct repository *repo,
const char *action,
struct commit *commit_with_message,
+ const char *message_template,
const struct commit_list *parents,
const struct object_id *old_tree,
const struct object_id *new_tree,
@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
+ if (!message_template)
+ message_template = original_body;
+
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
+ message_template, action, &commit_message);
if (ret < 0)
goto out;
} else {
- strbuf_addstr(&commit_message, original_body);
+ strbuf_addstr(&commit_message, message_template);
}
original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
return -1;
- return commit_tree_ext(repo, action, original, original->parents,
+ return commit_tree_ext(repo, action, original, NULL, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,
goto out;
if (!skip_commit) {
- ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
&original_tree->object.oid, &merge_result.tree->object.oid,
&rewritten, flags);
if (ret < 0) {
@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v7 3/5] history: add squash subcommand to fold a range
2026-07-06 8:50 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-07-06 8:50 ` [PATCH v7 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-07-06 8:50 ` [PATCH v7 2/5] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
@ 2026-07-06 8:50 ` Harald Nordgren via GitGitGadget
2026-07-06 8:50 ` [PATCH v7 4/5] sequencer: extract helpers for the squash message markers Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
7 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-06 8:50 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
Add "git history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
message and authorship and taking the tree of the newest commit, then
replays the commits above the range on top. The squashed message comes
from the oldest commit, or from an editor with --reedit-message. As that
message is reused, a range whose oldest commit is a fixup!, squash! or
amend! is refused, since the marker's target cannot be in the range.
The range is read like the arguments to "git rev-list", so several
arguments such as "HEAD~3..HEAD ^topic" are allowed. A merge inside the
range is folded when its other parent is reachable from the base,
otherwise the range has more than one base and is rejected. By default
the command also refuses when a ref points at a commit that the fold
would discard. Use --update-refs=head to rewrite only the current branch
instead.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/advice.adoc | 4 +
Documentation/git-history.adoc | 43 ++-
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 245 +++++++++++++++
t/meson.build | 1 +
t/t3455-history-squash.sh | 517 +++++++++++++++++++++++++++++++
7 files changed, 809 insertions(+), 3 deletions(-)
create mode 100755 t/t3455-history-squash.sh
diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 257db58918..f4d692d136 100644
--- a/Documentation/config/advice.adoc
+++ b/Documentation/config/advice.adoc
@@ -55,6 +55,10 @@ 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.
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 2ba8121795..783ddf4a51 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
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>...]
+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
DESCRIPTION
-----------
@@ -42,8 +43,11 @@ at once.
LIMITATIONS
-----------
-This command does not (yet) work with histories that contain merges. You
-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
+This command does not (yet) replay merge commits onto the rewritten
+history: if a commit that would be replayed is a merge, the operation is
+rejected, and you should use linkgit:git-rebase[1] with the
+`--rebase-merges` flag instead. The `squash` subcommand can still fold a
+merge that lies inside the range, as long as the range has a single base.
Furthermore, the command does not support operations that can result in merge
conflicts. This limitation is by design as history rewrites are not intended to
@@ -97,6 +101,38 @@ linkgit:gitglossary[7].
It is invalid to select either all or no hunks, as that would lead to
one of the commits becoming empty.
+`squash <revision-range>`::
+ Fold all commits in _<revision-range>_ into the oldest commit of that
+ range. The resulting commit keeps the oldest commit's message and
+ authorship and takes the tree of the range's newest commit, so the
+ whole range collapses into a single commit. Commits above the range
+ are replayed on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash HEAD~3..HEAD` folds the three most recent commits into
+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
+while leaving the two newest commits in place. _<revision-range>_ is read
+like the arguments to linkgit:git-rev-list[1], so several arguments may be
+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
+already on `topic`.
++
+The oldest commit's message and authorship are preserved by default,
+unless you specify `--reedit-message`. A merge commit inside the range is
+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.
++
+Because the oldest commit's message is reused, the range may not begin
+with a `fixup!`, `squash!`, or `amend!` commit, whose target is
+necessarily outside the range.
++
+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
+`--update-refs=head` to rewrite only the current branch and leave such
+refs pointing at the old commits.
+
OPTIONS
-------
@@ -107,7 +143,8 @@ OPTIONS
ref updates is generally safe.
`--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.
`--empty=(drop|keep|abort)`::
Control what happens when a commit becomes empty as a result of the
diff --git a/advice.c b/advice.c
index 0018501b7b..5c6ff95e31 100644
--- a/advice.c
+++ b/advice.c
@@ -58,6 +58,7 @@ 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" },
diff --git a/advice.h b/advice.h
index 8def280688..911b4e4643 100644
--- a/advice.h
+++ b/advice.h
@@ -25,6 +25,7 @@ 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,
diff --git a/builtin/history.c b/builtin/history.c
index 305bde3102..63911a493d 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
#define USE_THE_REPOSITORY_VARIABLE
#include "builtin.h"
+#include "advice.h"
#include "cache-tree.h"
#include "commit.h"
#include "commit-reach.h"
@@ -30,6 +31,8 @@
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
+#define GIT_HISTORY_SQUASH_USAGE \
+ N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>")
static void change_data_free(void *util, const char *str UNUSED)
{
@@ -973,6 +976,246 @@ out:
return ret;
}
+/*
+ * Resolve a "<base>..<tip>" revision range into the base commit just outside
+ * the range (which becomes the parent of the squashed commit), the oldest
+ * commit contained in the range (whose message the squash reuses), and the
+ * range tip (whose tree becomes the result). A merge inside the range is fine,
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+ const char **argv,
+ struct commit **base_out,
+ struct commit **oldest_out,
+ struct commit **tip_out,
+ struct oidset *interior_out)
+{
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+ struct commit_list *boundaries = NULL, *b;
+ struct strvec args = STRVEC_INIT;
+ size_t i;
+ int ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_push(&args, "--boundary");
+ strvec_push(&args, "--ancestry-path");
+ strvec_pushv(&args, argv);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (args.nr != 1) {
+ ret = error(_("unrecognized argument: %s"), args.v[1]);
+ goto out;
+ }
+
+ /*
+ * A squash needs a base to reparent onto, so the range has to exclude
+ * something, as in "<base>..<tip>". A revision range with no such
+ * bottom commit 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(_("not a '<base>..<tip>' revision range"));
+ goto out;
+ }
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
+ commit_list_insert(commit, &boundaries);
+ continue;
+ }
+ if (!oldest)
+ oldest = commit;
+ if (tip)
+ oidset_insert(interior_out, &tip->object.oid);
+ tip = commit;
+ }
+
+ if (!oldest) {
+ ret = error(_("the revision range is empty"));
+ goto out;
+ }
+
+ if (oldest == tip) {
+ ret = error(_("the revision range holds a single commit; "
+ "nothing to squash"));
+ goto out;
+ }
+
+ if (!oldest->parents)
+ BUG("an in-range commit must have a parent");
+ base = oldest->parents->item;
+
+ /*
+ * A boundary other than the base is an in-range commit reaching a
+ * commit outside the range, so the range has more than one base.
+ */
+ for (b = boundaries; b; b = b->next) {
+ if (b->item != base) {
+ ret = error(_("the revision range has more than one base; "
+ "cannot squash"));
+ goto out;
+ }
+ }
+
+ *base_out = base;
+ *oldest_out = oldest;
+ *tip_out = tip;
+ ret = 0;
+
+out:
+ commit_list_free(boundaries);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
+static int reject_fixupish_oldest(struct repository *repo,
+ struct commit *oldest)
+{
+ const char *message, *subject;
+ int ret = 0;
+
+ message = repo_logmsg_reencode(repo, oldest, NULL, NULL);
+ find_commit_subject(message, &subject);
+ if (starts_with(subject, "fixup! ") ||
+ starts_with(subject, "squash! ") ||
+ starts_with(subject, "amend! "))
+ ret = error(_("the range begins with a fixup!, squash! or amend! "
+ "commit whose target is not in the range"));
+ repo_unuse_commit_buffer(repo, oldest, message);
+ 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,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_SQUASH_USAGE,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ 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;
+ struct rev_info revs = { 0 };
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
+ if (!argc) {
+ ret = error(_("command expects a revision range"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
+ &interior);
+ if (ret < 0)
+ goto out;
+
+ ret = reject_fixupish_oldest(repo, oldest);
+ 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;
+
+ base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+ tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+ commit_list_append(base, &parents);
+
+ ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+ base_tree_oid, tip_tree_oid, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing squashed commit"));
+ goto out;
+ }
+
+ strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, tip, rewritten,
+ reflog_msg.buf, dry_run,
+ REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ strbuf_release(&reflog_msg);
+ oidset_clear(&interior);
+ commit_list_free(parents);
+ release_revisions(&revs);
+ return ret;
+}
+
int cmd_history(int argc,
const char **argv,
const char *prefix,
@@ -982,6 +1225,7 @@ int cmd_history(int argc,
GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
+ GIT_HISTORY_SQUASH_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
@@ -989,6 +1233,7 @@ int cmd_history(int argc,
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
+ OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
OPT_END(),
};
diff --git a/t/meson.build b/t/meson.build
index 3219264fe7..63ea26b8ed 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -399,6 +399,7 @@ integration_tests = [
't3451-history-reword.sh',
't3452-history-split.sh',
't3453-history-fixup.sh',
+ 't3455-history-squash.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
new file mode 100755
index 0000000000..6598649971
--- /dev/null
+++ b/t/t3455-history-squash.sh
@@ -0,0 +1,517 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'setup linear history touching two files' '
+ test_commit base file a &&
+ git tag start &&
+ test_commit --no-tag one other x &&
+ test_commit --no-tag two file c &&
+ test_commit three file d
+'
+
+test_expect_success 'errors on missing range argument' '
+ test_must_fail git history squash 2>err &&
+ test_grep "expects a revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+ test_must_fail git history squash HEAD..HEAD 2>err &&
+ test_grep "the revision range is empty" err
+'
+
+test_expect_success 'errors on a single revision that is not a range' '
+ test_must_fail git history squash HEAD 2>err &&
+ test_grep "not a .*range" err &&
+ test_must_fail git history squash HEAD~1 2>err &&
+ test_grep "not a .*range" err
+'
+
+test_expect_success 'errors on a range holding a single commit' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "HEAD^!" 2>err &&
+ test_grep "single commit; nothing to squash" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'accepts multiple revision arguments with an exclusion' '
+ git reset --hard three &&
+ git branch -f keep HEAD~2 &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..HEAD ^keep &&
+
+ git log --format="%s" start..HEAD >actual &&
+ cat >expect <<-\EOF &&
+ two
+ one
+ EOF
+ test_cmp expect actual &&
+ test_cmp_rev keep HEAD~1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+ git branch -D keep
+'
+
+test_expect_success 'squashes a branch the current branch is not on' '
+ git reset --hard three &&
+ main=$(git symbolic-ref --short HEAD) &&
+ head_before=$(git rev-parse HEAD) &&
+ git checkout -b off-history start &&
+ test_commit --no-tag off-one off a &&
+ test_commit --no-tag off-two off b &&
+ git checkout "$main" &&
+
+ git history squash start..off-history &&
+
+ git rev-list --count start..off-history >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D off-history
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --dry-run start.. >out &&
+ predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git history squash start.. &&
+
+ 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})" &&
+ git log --format="%s" -1 >subject &&
+ echo one >expect &&
+ test_cmp expect subject &&
+ git reflog >reflog &&
+ test_grep "squash: updating" reflog
+'
+
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+ git reset --hard three &&
+ final_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..@~1 &&
+
+ git log --format="%s" start..HEAD >actual &&
+ cat >expect <<-\EOF &&
+ three
+ one
+ EOF
+ test_cmp expect actual &&
+
+ test_cmp_rev start HEAD~2 &&
+ test "$final_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes when the base is the root commit' '
+ git reset --hard three &&
+ root=$(git rev-list --max-parents=0 HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash "$root.." &&
+
+ git rev-list --count "$root..HEAD" >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$root" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+
+test_expect_success 'reuses the message of a fixup! commit in the range' '
+ git reset --hard start &&
+ test_commit --no-tag reg1 file b &&
+ git commit --allow-empty -m "fixup! reg1" &&
+ test_commit reg2 file c &&
+
+ git history squash start.. &&
+
+ git log --format="%s" -1 >actual &&
+ echo reg1 >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success 'refuses a range whose oldest commit is a fixup!' '
+ git reset --hard start &&
+ test_commit --no-tag "fixup! something" file b &&
+ test_commit --no-tag tail file c &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "target is not in the range" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'does not interpret squash! or amend! markers' '
+ git reset --hard start &&
+ test_commit --no-tag marker-oldest file b &&
+ git commit --allow-empty -m "squash! marker-oldest" &&
+ git commit --allow-empty -m "amend! marker-oldest" &&
+ test_commit --no-tag marker-newest file c &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ git log --format="%s" -1 >actual &&
+ echo marker-oldest >expect &&
+ test_cmp expect actual
+'
+
+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 --no-tag oldest file b &&
+ test_commit newest file c &&
+
+ git history squash start.. &&
+
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Squasher <squash@example.com>" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--update-refs=head only moves HEAD' '
+ git reset --hard three &&
+ git branch -f other HEAD &&
+ other_before=$(git rev-parse other) &&
+
+ git history squash --update-refs=head start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$other_before" other
+'
+
+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~1 &&
+ mid_before=$(git rev-parse mid) &&
+
+ 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 &&
+
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag before-side file b &&
+ git checkout -b inner-side &&
+ test_commit --no-tag on-inner-side inner x &&
+ git checkout "$main" &&
+ test_commit --no-tag after-side file c &&
+ git merge --no-ff -m merge inner-side &&
+ git branch -D inner-side &&
+ test_commit --no-tag after-merge file d &&
+ 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 &&
+ git log --format="%s" -1 >subject &&
+ echo before-side >expect &&
+ test_cmp expect subject &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file inner
+'
+
+test_expect_success 'folds a merge of a branch that forked at the base' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b base-fork-side &&
+ test_commit --no-tag base-fork-side side x &&
+ git checkout "$main" &&
+ 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 'refuses a merge whose other parent is outside the range' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b outside-parent &&
+ test_commit --no-tag outside-parent outside x &&
+ git checkout "$main" &&
+ test_commit --no-tag outside-main file b &&
+ base=$(git rev-parse HEAD) &&
+ test_commit --no-tag outside-mid file c &&
+ git merge --no-ff -m "merge outside-parent" outside-parent &&
+ git branch -D outside-parent &&
+ merged=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range whose tip is a merge commit' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag tipmerge-base file b &&
+ git checkout -b tipmerge-side &&
+ test_commit --no-tag tipmerge-side side x &&
+ git checkout "$main" &&
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b basemerge-side &&
+ test_commit --no-tag basemerge-side side x &&
+ git checkout "$main" &&
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b forked-before &&
+ test_commit forked-side fside x &&
+ git checkout "$main" &&
+ test_commit forked-base file b &&
+ base=$(git rev-parse HEAD) &&
+ test_commit forked-main file c &&
+ git merge --no-ff -m merge forked-before &&
+ merged=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range with two interior merges' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ 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 "$main" &&
+ 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 "$main" &&
+ 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-one file b &&
+ test_commit --no-tag desc-two file c &&
+ 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 d &&
+ 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v7 4/5] sequencer: extract helpers for the squash message markers
2026-07-06 8:50 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-07-06 8:50 ` [PATCH v7 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-07-06 8:50 ` Harald Nordgren via GitGitGadget
2026-07-06 8:50 ` [PATCH v7 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
7 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-06 8:50 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
When "git rebase -i" squashes commits it builds an editor template with a
"This is a combination of N commits." banner, a "This is the 1st/Nth
commit message:" header above each kept message (or a "will be skipped"
header for a dropped one), and a commented-out subject for any fixup!,
squash! or amend! commit. The banner, the headers and the
subject-commenting all live in static helpers in sequencer.c wired to the
rebase state, so no other command can present a squash the same way.
Pull the three pieces out into add_squash_combination_header(),
add_squash_message_header() (which takes a flag for the "will be skipped"
variant) and squash_subject_comment_len(), and use them from
update_squash_messages() and append_squash_message(). A later change
reuses them to give "git history squash --reedit-message" the same
template.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
sequencer.c | 64 ++++++++++++++++++++++++++++++++---------------------
sequencer.h | 23 +++++++++++++++++++
2 files changed, 62 insertions(+), 25 deletions(-)
diff --git a/sequencer.c b/sequencer.c
index 57855b0066..f4893e8f40 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1892,6 +1892,32 @@ static const char skip_first_commit_msg_str[] = N_("The 1st commit message will
static const char skip_nth_commit_msg_fmt[] = N_("The commit message #%d will be skipped:");
static const char combined_commit_msg_fmt[] = N_("This is a combination of %d commits.");
+void add_squash_combination_header(struct strbuf *buf, int n)
+{
+ strbuf_addf(buf, "%s ", comment_line_str);
+ strbuf_addf(buf, _(combined_commit_msg_fmt), n);
+}
+
+void add_squash_message_header(struct strbuf *buf, int n, int skip)
+{
+ strbuf_addf(buf, "%s ", comment_line_str);
+ if (n == 1)
+ strbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :
+ _(first_commit_msg_str));
+ else
+ strbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :
+ _(nth_commit_msg_fmt), n);
+}
+
+size_t squash_subject_comment_len(const char *body, int squashing)
+{
+ if (starts_with(body, "amend!") ||
+ (squashing && (starts_with(body, "squash!") ||
+ starts_with(body, "fixup!"))))
+ return commit_subject_length(body);
+ return 0;
+}
+
static int is_fixup_flag(enum todo_command command, unsigned flag)
{
return command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||
@@ -2005,20 +2031,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,
{
struct replay_ctx *ctx = opts->ctx;
const char *fixup_msg;
- size_t commented_len = 0, fixup_off;
- /*
- * amend is non-interactive and not normally used with fixup!
- * or squash! commits, so only comment out those subjects when
- * squashing commit messages.
- */
- if (starts_with(body, "amend!") ||
- ((command == TODO_SQUASH || seen_squash(ctx)) &&
- (starts_with(body, "squash!") || starts_with(body, "fixup!"))))
- commented_len = commit_subject_length(body);
+ size_t commented_len, fixup_off;
+
+ commented_len = squash_subject_comment_len(body,
+ command == TODO_SQUASH || seen_squash(ctx));
- strbuf_addf(buf, "\n%s ", comment_line_str);
- strbuf_addf(buf, _(nth_commit_msg_fmt),
- ++ctx->current_fixup_count + 1);
+ strbuf_addch(buf, '\n');
+ add_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);
strbuf_addstr(buf, "\n\n");
strbuf_add_commented_lines(buf, body, commented_len, comment_line_str);
/* buf->buf may be reallocated so store an offset into the buffer */
@@ -2083,9 +2102,8 @@ static int update_squash_messages(struct repository *r,
eol = !starts_with(buf.buf, comment_line_str) ?
buf.buf : strchrnul(buf.buf, '\n');
- strbuf_addf(&header, "%s ", comment_line_str);
- strbuf_addf(&header, _(combined_commit_msg_fmt),
- ctx->current_fixup_count + 2);
+ add_squash_combination_header(&header,
+ ctx->current_fixup_count + 2);
strbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);
strbuf_release(&header);
if (is_fixup_flag(command, flag) && !seen_squash(ctx))
@@ -2109,12 +2127,9 @@ static int update_squash_messages(struct repository *r,
repo_unuse_commit_buffer(r, head_commit, head_message);
return error(_("cannot write '%s'"), rebase_path_fixup_msg());
}
- strbuf_addf(&buf, "%s ", comment_line_str);
- strbuf_addf(&buf, _(combined_commit_msg_fmt), 2);
- strbuf_addf(&buf, "\n%s ", comment_line_str);
- strbuf_addstr(&buf, is_fixup_flag(command, flag) ?
- _(skip_first_commit_msg_str) :
- _(first_commit_msg_str));
+ add_squash_combination_header(&buf, 2);
+ strbuf_addch(&buf, '\n');
+ add_squash_message_header(&buf, 1, is_fixup_flag(command, flag));
strbuf_addstr(&buf, "\n\n");
if (is_fixup_flag(command, flag))
strbuf_add_commented_lines(&buf, body, strlen(body),
@@ -2133,9 +2148,8 @@ static int update_squash_messages(struct repository *r,
if (command == TODO_SQUASH || is_fixup_flag(command, flag)) {
res = append_squash_message(&buf, body, command, opts, flag);
} else if (command == TODO_FIXUP) {
- strbuf_addf(&buf, "\n%s ", comment_line_str);
- strbuf_addf(&buf, _(skip_nth_commit_msg_fmt),
- ++ctx->current_fixup_count + 1);
+ strbuf_addch(&buf, '\n');
+ add_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);
strbuf_addstr(&buf, "\n\n");
strbuf_add_commented_lines(&buf, body, strlen(body),
comment_line_str);
diff --git a/sequencer.h b/sequencer.h
index 3164bd437d..feed0e9de3 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -208,6 +208,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);
*/
void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);
+/*
+ * Append the "This is a combination of N commits." banner that "git rebase
+ * -i" writes at the top of a squashed commit's message, commented out with
+ * the comment character.
+ */
+void add_squash_combination_header(struct strbuf *buf, int n);
+
+/*
+ * Append the header (1-based N) that "git rebase -i" writes above each message
+ * when squashing, commented out with the comment character. With SKIP it reads
+ * "The ... commit message will be skipped" for a message that is dropped (a
+ * fixup), otherwise "This is the ... commit message".
+ */
+void add_squash_message_header(struct strbuf *buf, int n, int skip);
+
+/*
+ * Return the length of the leading subject of BODY when it should be commented
+ * out in a squash message, or 0 otherwise. An "amend!" subject always
+ * qualifies; "squash!" and "fixup!" subjects only when SQUASHING, since a
+ * plain fixup chain keeps them.
+ */
+size_t squash_subject_comment_len(const char *body, int squashing);
+
void append_conflicts_hint(struct index_state *istate,
struct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);
enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v7 5/5] history: re-edit a squash with every message
2026-07-06 8:50 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-07-06 8:50 ` [PATCH v7 4/5] sequencer: extract helpers for the squash message markers Harald Nordgren via GitGitGadget
@ 2026-07-06 8:50 ` Harald Nordgren via GitGitGadget
2026-07-06 14:06 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Phillip Wood
` (2 subsequent siblings)
7 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-06 8:50 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
By default "git history squash" reuses the oldest commit's message.
When --reedit-message is given it only reopened that one message, so the
messages of the folded-in commits were lost.
Gather the messages of every commit in the range, oldest first, and build
the same editor template that "git rebase -i" shows for a squash, using
add_squash_combination_header(), add_squash_message_header() and
squash_subject_comment_len(). Only the message text differs, the changes
are always folded in. Following autosquash, a fixup!'s message is
commented out in full under a "will be skipped" header, while a squash! or
amend! keeps its body with only the marker subject commented.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-history.adoc | 12 +++-
builtin/history.c | 73 ++++++++++++++++++++-
t/t3455-history-squash.sh | 115 +++++++++++++++++++++++++++++++++
3 files changed, 196 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 783ddf4a51..f51332e731 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -117,15 +117,21 @@ like the arguments to linkgit:git-rev-list[1], so several arguments may be
given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
already on `topic`.
+
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
+The oldest commit's message and authorship are preserved by default. With
+`--reedit-message`, an editor opens pre-filled with the messages of all the
+folded commits so you can combine them. A merge commit inside the range is
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.
+
Because the oldest commit's message is reused, the range may not begin
with a `fixup!`, `squash!`, or `amend!` commit, whose target is
-necessarily outside the range.
+necessarily outside the range. The changes from every commit in the range
+are always folded in. Only the message text differs. With
+`--reedit-message` the template mirrors `git rebase -i`: the message of a
+`fixup!` elsewhere in the range is commented out in full, while a
+`squash!` or `amend!` keeps its message body with only the marker subject
+commented, so you can fold the remark into the result.
+
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
diff --git a/builtin/history.c b/builtin/history.c
index 63911a493d..8999ba9533 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1114,6 +1114,68 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
return 0;
}
+static int build_squash_message(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct strbuf *out)
+{
+ struct commit_list *commits = NULL, **tail = &commits, *c;
+ struct rev_info revs;
+ struct commit *commit;
+ struct strvec args = STRVEC_INIT;
+ int n = 0, total, ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs)))
+ tail = &commit_list_insert(commit, tail)->next;
+ total = commit_list_count(commits);
+
+ for (c = commits; c; c = c->next) {
+ const char *message, *body;
+ size_t commented_len;
+ int skip;
+
+ message = repo_logmsg_reencode(repo, c->item, NULL, NULL);
+ find_commit_subject(message, &body);
+
+ skip = starts_with(body, "fixup! ");
+ commented_len = skip ? strlen(body) :
+ squash_subject_comment_len(body, 1);
+
+ if (!n)
+ add_squash_combination_header(out, total);
+ strbuf_addch(out, '\n');
+ add_squash_message_header(out, ++n, skip);
+ strbuf_addstr(out, "\n\n");
+ strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
+ strbuf_addstr(out, body + commented_len);
+ strbuf_complete_line(out);
+
+ repo_unuse_commit_buffer(repo, c->item, message);
+ }
+
+ ret = 0;
+
+out:
+ commit_list_free(commits);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
@@ -1138,6 +1200,7 @@ static int cmd_history_squash(int argc,
OPT_END(),
};
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;
@@ -1181,6 +1244,12 @@ static int cmd_history_squash(int argc,
}
}
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
+ }
+
ret = setup_revwalk(repo, action, tip, &revs);
if (ret < 0)
goto out;
@@ -1189,7 +1258,8 @@ static int cmd_history_squash(int argc,
tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
commit_list_append(base, &parents);
- ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+ ret = commit_tree_ext(repo, "squash", oldest,
+ message.len ? message.buf : NULL, parents,
base_tree_oid, tip_tree_oid, &rewritten, flags);
if (ret < 0) {
ret = error(_("failed writing squashed commit"));
@@ -1210,6 +1280,7 @@ 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);
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 6598649971..1985c83fbb 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -186,6 +186,121 @@ test_expect_success 'preserves authorship of the oldest commit' '
test_cmp expect actual
'
+test_expect_success '--reedit-message offers every folded-in message' '
+ git reset --hard start &&
+ echo b >file &&
+ git add file &&
+ git commit -m "re-one subject" -m "re-one body line" &&
+ test_commit --no-tag re-two file c &&
+ test_commit re-three file d &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited &&
+ echo combined >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 3 commits.
+ # This is the 1st commit message:
+
+ re-one subject
+
+ re-one body line
+
+ # This is the commit message #2:
+
+ re-two
+
+ # This is the commit message #3:
+
+ re-three
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ echo combined >expect &&
+ git log --format="%s" -1 >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
+ printf "fixup! mark-base\n\nfixup body\n" >msg &&
+ echo c >file &&
+ git add file &&
+ git commit -qF msg &&
+ printf "squash! mark-base\n\nsquash remark\n" >msg &&
+ echo d >file &&
+ git add file &&
+ git commit -qF msg &&
+ printf "amend! mark-base\n\namended message\n" >msg &&
+ echo e >file &&
+ git add file &&
+ git commit -qF msg &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 4 commits.
+ # This is the 1st commit message:
+
+ mark-base
+
+ # The commit message #2 will be skipped:
+
+ # fixup! mark-base
+ #
+ # fixup body
+
+ # This is the commit message #3:
+
+ # squash! mark-base
+
+ squash remark
+
+ # This is the commit message #4:
+
+ # amend! mark-base
+
+ amended message
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ git log -1 --format="%B" >final &&
+ test_grep ! "fixup body" final &&
+ test_grep "squash remark" final &&
+ test_grep "amended message" final
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ write_script editor <<-\EOF &&
+ >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ test_must_fail git history squash --reedit-message start.. &&
+
+ test_cmp_rev "$head_before" HEAD
+'
+
test_expect_success '--update-refs=head only moves HEAD' '
git reset --hard three &&
git branch -f other HEAD &&
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* Re: [PATCH v7 0/5] history: add squash subcommand to fold a range
2026-07-06 8:50 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-07-06 8:50 ` [PATCH v7 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
@ 2026-07-06 14:06 ` Phillip Wood
2026-07-07 7:51 ` Harald Nordgren
2026-07-06 20:42 ` Junio C Hamano
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
7 siblings, 1 reply; 115+ messages in thread
From: Phillip Wood @ 2026-07-06 14:06 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Harald Nordgren, Patrick Steinhardt, Junio C Hamano, Matt Hunter
Hi Harald
On 06/07/2026 09:50, Harald Nordgren via GitGitGadget wrote:
> Adds git history squash <revision-range> to fold a range of commits.
>
> Changes in v7:
>
> * --reedit-message
There was some discussion [1] about making that the default and renaming
it - was that overlooked? If not it would be helpful to comment on those
discussions to explain why you don't think it is a good idea.
> 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.
Rebase re-orders commits so that fixups immediately follow their target
- do you do that here? I think that is very relevant because here we may
be dealing with several different commits each being targeted by a set
of fixups and presenting them mixed together will be confusing. When
rebase sees an "amend!" commit it comments out the message that is being
replaced - it is not clear from this description whether that happens here.
As I've said before I think we would be better off with a summary of the
commits that are being squashed and a more compact template message that
only contains the messages we want to keep [2]. What are the advantages
of having lots of commented lines (or redundant messages if you don't
comment out the original when there is an amend! commit) in the middle
of the template message?
> * 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.
I think it should allow squashing a bunch of fixups together though. I
thought there was a plan [3] to refuse to squash a fixup unless the
range included its target.
The range-diff does not show any input sanitization - what happens when
the user passes "--reverse" for example? As I said in [4] we should copy
what "git replay" does to sanity check the rev-list options, otherwise
we've got no idea whether the parent of the first commit returned by
get_revision() is the commit we want to use as the parent of the
squashed commit.
Thanks
Phillip
[1]
https://lore.kernel.org/git/3c35bd17-e884-432d-a400-36a89964ed89@gmail.com/
[2]
https://lore.kernel.org/git/4b505228-4846-4a48-9255-e249f4e70a1f@gmail.com
[3]
https://lore.kernel.org/git/CAHwyqnWQmObWr3N81_EU6F13iyKp3FfY8KSNFfoAjS4r_0qJrQ@mail.gmail.com/
[4]
https://lore.kernel.org/git/f3fe7ff2-3ce9-4e90-95e7-8c620de5628a@gmail.com/
> * 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: extract helpers for the squash message markers
> history: re-edit a squash with every message
>
> Documentation/config/advice.adoc | 4 +
> Documentation/git-history.adoc | 49 ++-
> advice.c | 1 +
> advice.h | 1 +
> builtin/history.c | 390 +++++++++++++++++--
> sequencer.c | 64 ++--
> sequencer.h | 23 ++
> t/meson.build | 1 +
> t/t3455-history-squash.sh | 632 +++++++++++++++++++++++++++++++
> 9 files changed, 1099 insertions(+), 66 deletions(-)
> create mode 100755 t/t3455-history-squash.sh
>
>
> base-commit: e9019fcafe0040228b8631c30f97ae1adb61bcdc
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v7
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v7
> Pull-Request: https://github.com/git/git/pull/2337
>
> Range-diff vs v6:
>
> 1: fea6b79e60 = 1: 56ed8fadbb history: extract helper for a commit's parent tree
> 2: e2674e0bc4 = 2: 212e9c228f history: give commit_tree_ext a message template
> 3: 811e393ab4 ! 3: cf3346a1cd history: add squash subcommand to fold a range
> @@ Commit message
> Add "git history squash <revision-range>" to do this directly. It folds
> every commit in the range into the oldest one, keeping that commit's
> message and authorship and taking the tree of the newest commit, then
> - replays the commits above the range on top. fixup!, squash! and amend!
> - commits are folded like any other and are not interpreted, so the
> - squashed message comes from the oldest commit, or from an editor with
> - --reedit-message.
> + replays the commits above the range on top. The squashed message comes
> + from the oldest commit, or from an editor with --reedit-message. As that
> + message is reused, a range whose oldest commit is a fixup!, squash! or
> + amend! is refused, since the marker's target cannot be in the range.
>
> The range is read like the arguments to "git rev-list", so several
> - arguments such as "@~3.. ^topic" are allowed. A merge inside the range
> - is folded when its other parent is reachable from the base, otherwise
> - the range has more than one base and is rejected. By default the command
> - also refuses when a ref points at a commit that the fold would discard.
> - Use --update-refs=head to rewrite only the current branch instead.
> + arguments such as "HEAD~3..HEAD ^topic" are allowed. A merge inside the
> + range is folded when its other parent is reachable from the base,
> + otherwise the range has more than one base and is rejected. By default
> + the command also refuses when a ref points at a commit that the fold
> + would discard. Use --update-refs=head to rewrite only the current branch
> + instead.
>
> Inspired-by: Sergey Chernov <serega.morph@gmail.com>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> @@ Documentation/git-history.adoc: SYNOPSIS
> 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>...]
> -+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
> ++git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
>
> DESCRIPTION
> -----------
> +@@ Documentation/git-history.adoc: at once.
> + LIMITATIONS
> + -----------
> +
> +-This command does not (yet) work with histories that contain merges. You
> +-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
> ++This command does not (yet) replay merge commits onto the rewritten
> ++history: if a commit that would be replayed is a merge, the operation is
> ++rejected, and you should use linkgit:git-rebase[1] with the
> ++`--rebase-merges` flag instead. The `squash` subcommand can still fold a
> ++merge that lies inside the range, as long as the range has a single base.
> +
> + Furthermore, the command does not support operations that can result in merge
> + conflicts. This limitation is by design as history rewrites are not intended to
> @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
> It is invalid to select either all or no hunks, as that would lead to
> one of the commits becoming empty.
> @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
> ++
> +The range is given in the usual `<base>..<tip>` form, where _<base>_ is
> +the commit just below the oldest commit to squash. For example, `git
> -+history squash @~3..` folds the three most recent commits into one, and
> -+`git history squash @~5..@~2` squashes an interior range while leaving
> -+the two newest commits in place. _<revision-range>_ is read like the
> -+arguments to linkgit:git-rev-list[1], so several arguments may be given,
> -+for example `@~3.. ^topic` to additionally exclude what is already on
> -+`topic`.
> ++history squash HEAD~3..HEAD` folds the three most recent commits into
> ++one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
> ++while leaving the two newest commits in place. _<revision-range>_ is read
> ++like the arguments to linkgit:git-rev-list[1], so several arguments may be
> ++given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
> ++already on `topic`.
> ++
> +The oldest commit's message and authorship are preserved by default,
> +unless you specify `--reedit-message`. A merge commit inside the range is
> @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
> +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.
> ++Because the oldest commit's message is reused, the range may not begin
> ++with a `fixup!`, `squash!`, or `amend!` commit, whose target is
> ++necessarily outside the range.
> +++
> ++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
> ++`--update-refs=head` to rewrite only the current branch and leave such
> ++refs pointing at the old commits.
> +
> OPTIONS
> -------
>
> +@@ Documentation/git-history.adoc: OPTIONS
> + ref updates is generally safe.
> +
> + `--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.
> +
> + `--empty=(drop|keep|abort)`::
> + Control what happens when a commit becomes empty as a result of the
>
> ## advice.c ##
> @@ advice.c: static struct {
> @@ builtin/history.c
> #define GIT_HISTORY_SPLIT_USAGE \
> N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
> +#define GIT_HISTORY_SQUASH_USAGE \
> -+ 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>")
>
> static void change_data_free(void *util, const char *str UNUSED)
> {
> @@ builtin/history.c: out:
> + return ret;
> +}
> +
> ++static int reject_fixupish_oldest(struct repository *repo,
> ++ struct commit *oldest)
> ++{
> ++ const char *message, *subject;
> ++ int ret = 0;
> ++
> ++ message = repo_logmsg_reencode(repo, oldest, NULL, NULL);
> ++ find_commit_subject(message, &subject);
> ++ if (starts_with(subject, "fixup! ") ||
> ++ starts_with(subject, "squash! ") ||
> ++ starts_with(subject, "amend! "))
> ++ ret = error(_("the range begins with a fixup!, squash! or amend! "
> ++ "commit whose target is not in the range"));
> ++ repo_unuse_commit_buffer(repo, oldest, message);
> ++ return ret;
> ++}
> ++
> +struct interior_ref_cb {
> + const struct oidset *interior;
> + const char *name;
> @@ builtin/history.c: out:
> + if (ret < 0)
> + goto out;
> +
> ++ ret = reject_fixupish_oldest(repo, oldest);
> ++ if (ret < 0)
> ++ goto out;
> ++
> + if (action == REF_ACTION_BRANCHES) {
> + struct interior_ref_cb cb = { .interior = &interior };
> +
> @@ t/t3455-history-squash.sh (new)
> +
> +test_expect_success 'squashes a range into a single commit without changing the tree' '
> + git reset --hard three &&
> ++ head_before=$(git rev-parse HEAD) &&
> + tip_tree=$(git rev-parse HEAD^{tree}) &&
> +
> ++ git history squash --dry-run start.. >out &&
> ++ predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
> ++ test_cmp_rev "$head_before" HEAD &&
> ++
> + git history squash start.. &&
> +
> ++ test "$predicted" = "$(git rev-parse HEAD)" &&
> + git rev-list --count start..HEAD >count &&
> + echo 1 >expect &&
> + test_cmp expect count &&
> @@ t/t3455-history-squash.sh (new)
> + test_cmp expect actual
> +'
> +
> -+test_expect_success 'keeps the oldest message even if it is a fixup!' '
> ++test_expect_success 'refuses a range whose oldest commit is a fixup!' '
> + git reset --hard start &&
> + test_commit --no-tag "fixup! something" file b &&
> -+ test_commit tail file c &&
> ++ test_commit --no-tag tail file c &&
> ++ head_before=$(git rev-parse HEAD) &&
> ++
> ++ test_must_fail git history squash start.. 2>err &&
> ++ test_grep "target is not in the range" err &&
> ++ test_cmp_rev "$head_before" HEAD
> ++'
> ++
> ++test_expect_success 'does not interpret squash! or amend! markers' '
> ++ git reset --hard start &&
> ++ test_commit --no-tag marker-oldest file b &&
> ++ git commit --allow-empty -m "squash! marker-oldest" &&
> ++ git commit --allow-empty -m "amend! marker-oldest" &&
> ++ test_commit --no-tag marker-newest file c &&
> +
> + git history squash start.. &&
> +
> ++ git rev-list --count start..HEAD >count &&
> ++ echo 1 >expect &&
> ++ test_cmp expect count &&
> + git log --format="%s" -1 >actual &&
> -+ echo "fixup! something" >expect &&
> ++ echo marker-oldest >expect &&
> + test_cmp expect actual
> +'
> +
> @@ t/t3455-history-squash.sh (new)
> + test_cmp expect actual
> +'
> +
> -+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 &&
> -+ predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
> -+ test_cmp_rev "$head_before" HEAD &&
> -+
> -+ git history squash start.. &&
> -+ 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' '
> + git reset --hard three &&
> + git branch -f other HEAD &&
> -: ---------- > 4: 001356db93 sequencer: extract helpers for the squash message markers
> 4: 4edf012b77 ! 5: 615fe4dd3f history: re-edit a squash with every message
> @@ Commit message
> When --reedit-message is given it only reopened that one message, so the
> messages of the folded-in commits were lost.
>
> - Gather the messages of every commit in the range, oldest first, and use
> - them as the editor template when re-editing, mirroring how "git rebase
> - -i" presents a squash.
> + Gather the messages of every commit in the range, oldest first, and build
> + the same editor template that "git rebase -i" shows for a squash, using
> + add_squash_combination_header(), add_squash_message_header() and
> + squash_subject_comment_len(). Only the message text differs, the changes
> + are always folded in. Following autosquash, a fixup!'s message is
> + commented out in full under a "will be skipped" header, while a squash! or
> + amend! keeps its body with only the marker subject commented.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>
> ## Documentation/git-history.adoc ##
> -@@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several arguments may be given,
> - for example `@~3.. ^topic` to additionally exclude what is already on
> - `topic`.
> +@@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], so several arguments may be
> + given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
> + already on `topic`.
> +
> -The oldest commit's message and authorship are preserved by default,
> -unless you specify `--reedit-message`. A merge commit inside the range is
> @@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several
> 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.
> + +
> + Because the oldest commit's message is reused, the range may not begin
> + with a `fixup!`, `squash!`, or `amend!` commit, whose target is
> +-necessarily outside the range.
> ++necessarily outside the range. The changes from every commit in the range
> ++are always folded in. Only the message text differs. With
> ++`--reedit-message` the template mirrors `git rebase -i`: the message of a
> ++`fixup!` elsewhere in the range is commented out in full, while a
> ++`squash!` or `amend!` keeps its message body with only the marker subject
> ++commented, so you can fold the remark into the result.
> + +
> + 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
>
> ## builtin/history.c ##
> @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data)
> @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
> + struct commit *tip,
> + struct strbuf *out)
> +{
> ++ struct commit_list *commits = NULL, **tail = &commits, *c;
> + struct rev_info revs;
> + struct commit *commit;
> + struct strvec args = STRVEC_INIT;
> -+ int n = 0, ret;
> ++ int n = 0, total, ret;
> +
> + repo_init_revisions(repo, &revs, NULL);
> + strvec_push(&args, "ignored");
> @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
> + goto out;
> + }
> +
> -+ while ((commit = get_revision(&revs))) {
> ++ while ((commit = get_revision(&revs)))
> ++ tail = &commit_list_insert(commit, tail)->next;
> ++ total = commit_list_count(commits);
> ++
> ++ for (c = commits; c; c = c->next) {
> + const char *message, *body;
> -+ struct strbuf one = STRBUF_INIT;
> ++ size_t commented_len;
> ++ int skip;
> +
> -+ message = repo_logmsg_reencode(repo, commit, NULL, NULL);
> ++ message = repo_logmsg_reencode(repo, c->item, NULL, NULL);
> + find_commit_subject(message, &body);
> -+ strbuf_addstr(&one, body);
> -+ strbuf_trim_trailing_newline(&one);
> +
> -+ if (n++)
> -+ strbuf_addch(out, '\n');
> -+ strbuf_addbuf(out, &one);
> ++ skip = starts_with(body, "fixup! ");
> ++ commented_len = skip ? strlen(body) :
> ++ squash_subject_comment_len(body, 1);
> ++
> ++ if (!n)
> ++ add_squash_combination_header(out, total);
> + strbuf_addch(out, '\n');
> ++ add_squash_message_header(out, ++n, skip);
> ++ strbuf_addstr(out, "\n\n");
> ++ strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
> ++ strbuf_addstr(out, body + commented_len);
> ++ strbuf_complete_line(out);
> +
> -+ strbuf_release(&one);
> -+ repo_unuse_commit_buffer(repo, commit, message);
> ++ repo_unuse_commit_buffer(repo, c->item, message);
> + }
> +
> + ret = 0;
> +
> +out:
> ++ commit_list_free(commits);
> + reset_revision_walk();
> + release_revisions(&revs);
> + strvec_clear(&args);
> @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
> + test_commit re-three file d &&
> +
> + write_script editor <<-\EOF &&
> -+ cp "$1" buffer &&
> ++ cat "$1" >edited &&
> + echo combined >"$1"
> + EOF
> + test_set_editor "$(pwd)/editor" &&
> + git history squash --reedit-message start.. &&
> +
> -+ 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 &&
> ++ cat >expect <<-EOF &&
> ++ # This is a combination of 3 commits.
> ++ # This is the 1st commit message:
> ++
> ++ re-one subject
> ++
> ++ re-one body line
> ++
> ++ # This is the commit message #2:
> ++
> ++ re-two
> ++
> ++ # This is the commit message #3:
> ++
> ++ re-three
> ++
> ++ # Please enter the commit message for the squash changes. Lines starting
> ++ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
> ++ # Changes to be committed:
> ++ # modified: file
> ++ #
> ++ EOF
> ++ test_cmp expect edited &&
> + echo combined >expect &&
> ++ git log --format="%s" -1 >actual &&
> + test_cmp expect actual
> +'
> +
> ++test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
> ++ git reset --hard start &&
> ++ test_commit --no-tag mark-base file b &&
> ++ printf "fixup! mark-base\n\nfixup body\n" >msg &&
> ++ echo c >file &&
> ++ git add file &&
> ++ git commit -qF msg &&
> ++ printf "squash! mark-base\n\nsquash remark\n" >msg &&
> ++ echo d >file &&
> ++ git add file &&
> ++ git commit -qF msg &&
> ++ printf "amend! mark-base\n\namended message\n" >msg &&
> ++ echo e >file &&
> ++ git add file &&
> ++ git commit -qF msg &&
> ++
> ++ write_script editor <<-\EOF &&
> ++ cat "$1" >edited
> ++ EOF
> ++ test_set_editor "$(pwd)/editor" &&
> ++ git history squash --reedit-message start.. &&
> ++
> ++ cat >expect <<-EOF &&
> ++ # This is a combination of 4 commits.
> ++ # This is the 1st commit message:
> ++
> ++ mark-base
> ++
> ++ # The commit message #2 will be skipped:
> ++
> ++ # fixup! mark-base
> ++ #
> ++ # fixup body
> ++
> ++ # This is the commit message #3:
> ++
> ++ # squash! mark-base
> ++
> ++ squash remark
> ++
> ++ # This is the commit message #4:
> ++
> ++ # amend! mark-base
> ++
> ++ amended message
> ++
> ++ # Please enter the commit message for the squash changes. Lines starting
> ++ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
> ++ # Changes to be committed:
> ++ # modified: file
> ++ #
> ++ EOF
> ++ test_cmp expect edited &&
> ++ git log -1 --format="%B" >final &&
> ++ test_grep ! "fixup body" final &&
> ++ test_grep "squash remark" final &&
> ++ test_grep "amended message" final
> ++'
> ++
> +test_expect_success '--reedit-message aborts on an empty message' '
> + git reset --hard three &&
> + head_before=$(git rev-parse HEAD) &&
> @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
> + test_cmp_rev "$head_before" HEAD
> +'
> +
> - test_expect_success '--dry-run predicts the rewrite without performing it' '
> + test_expect_success '--update-refs=head only moves HEAD' '
> git reset --hard three &&
> - head_before=$(git rev-parse HEAD) &&
> + git branch -f other HEAD &&
>
^ permalink raw reply [flat|nested] 115+ messages in thread* Re: [PATCH v7 0/5] history: add squash subcommand to fold a range
2026-07-06 14:06 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Phillip Wood
@ 2026-07-07 7:51 ` Harald Nordgren
2026-07-07 8:55 ` Harald Nordgren
2026-07-07 9:48 ` Phillip Wood
0 siblings, 2 replies; 115+ messages in thread
From: Harald Nordgren @ 2026-07-07 7:51 UTC (permalink / raw)
To: phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Patrick Steinhardt,
Junio C Hamano, Matt Hunter
> There was some discussion [1] about making that the default and renaming
> it - was that overlooked? If not it would be helpful to comment on those
> discussions to explain why you don't think it is a good idea.
Not overlooked, but I side-stepped it because the discussion died
down, and yes I don't agree that it needs to be the default. I could
have mentioned my thinking in the cover letter.
> > 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.
>
> Rebase re-orders commits so that fixups immediately follow their target
> - do you do that here? I think that is very relevant because here we may
> be dealing with several different commits each being targeted by a set
> of fixups and presenting them mixed together will be confusing.
No, I'm not doing that now, but I can take a look at that.
> I think it should allow squashing a bunch of fixups together though. I
> thought there was a plan [3] to refuse to squash a fixup unless the
> range included its target.
I attempted this with reject_fixupish_oldest(), assuming only the
first commit needs to be checked as not being a fixup/squash/amend.
But now I realize that maybe we need to check all of the commits, and
also check if the target is in the range or not. It just makes the
logic a lot bigger.
> The range-diff does not show any input sanitization - what happens when
> the user passes "--reverse" for example? As I said in [4] we should copy
> what "git replay" does to sanity check the rev-list options, otherwise
> we've got no idea whether the parent of the first commit returned by
> get_revision() is the commit we want to use as the parent of the
> squashed commit.
Yeah, good point.
Harald
^ permalink raw reply [flat|nested] 115+ messages in thread
* Re: [PATCH v7 0/5] history: add squash subcommand to fold a range
2026-07-07 7:51 ` Harald Nordgren
@ 2026-07-07 8:55 ` Harald Nordgren
2026-07-07 9:30 ` Phillip Wood
2026-07-07 9:48 ` Phillip Wood
1 sibling, 1 reply; 115+ messages in thread
From: Harald Nordgren @ 2026-07-07 8:55 UTC (permalink / raw)
To: phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Patrick Steinhardt,
Junio C Hamano, Matt Hunter
> > The range-diff does not show any input sanitization - what happens when
> > the user passes "--reverse" for example? As I said in [4] we should copy
> > what "git replay" does to sanity check the rev-list options, otherwise
> > we've got no idea whether the parent of the first commit returned by
> > get_revision() is the commit we want to use as the parent of the
> > squashed commit.
>
> Yeah, good point.
Well, the code already blocks "--reverse" and other unknown options,
but I can clarify that better in the commit message.
Harald
^ permalink raw reply [flat|nested] 115+ messages in thread
* Re: [PATCH v7 0/5] history: add squash subcommand to fold a range
2026-07-07 8:55 ` Harald Nordgren
@ 2026-07-07 9:30 ` Phillip Wood
0 siblings, 0 replies; 115+ messages in thread
From: Phillip Wood @ 2026-07-07 9:30 UTC (permalink / raw)
To: Harald Nordgren, phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Patrick Steinhardt,
Junio C Hamano, Matt Hunter
Hi Harald
On 07/07/2026 09:55, Harald Nordgren wrote:
>>> The range-diff does not show any input sanitization - what happens when
>>> the user passes "--reverse" for example? As I said in [4] we should copy
>>> what "git replay" does to sanity check the rev-list options, otherwise
>>> we've got no idea whether the parent of the first commit returned by
>>> get_revision() is the commit we want to use as the parent of the
>>> squashed commit.
>>
>> Yeah, good point.
>
> Well, the code already blocks "--reverse" and other unknown options,
> but I can clarify that better in the commit message.
Well it accepts
git history squash -- --reverse ...
because after calling parse_options() everything after the "--" is
passed to setup_revisions(). There was some discussion about accepting
rev-list options [2] so it would have been helpful to reference that in
the cover letter. The cover letter should explain both the changes you
have made and the suggestions that were discussed that have not been
implemented so readers can get an overview of how this version relates
to the previous discussion. Without that it is impossible to know if you
disagree with a suggestion or have just forgotten it.
"git replay" supports arbitrary rev-list options by passing
PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT
to parse_options(), then passing the remaining options to
setup_revisions(). After that it checks the various members of `struct
rev_info` that it cares about are still set appropriately.
Thanks
Phillip
[1] https://lore.kernel.org/git/xmqqzf0dwalx.fsf@gitster.g
^ permalink raw reply [flat|nested] 115+ messages in thread
* Re: [PATCH v7 0/5] history: add squash subcommand to fold a range
2026-07-07 7:51 ` Harald Nordgren
2026-07-07 8:55 ` Harald Nordgren
@ 2026-07-07 9:48 ` Phillip Wood
1 sibling, 0 replies; 115+ messages in thread
From: Phillip Wood @ 2026-07-07 9:48 UTC (permalink / raw)
To: Harald Nordgren, phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Patrick Steinhardt,
Junio C Hamano, Matt Hunter
Hi Harald
On 07/07/2026 08:51, Harald Nordgren wrote:
>> There was some discussion [1] about making that the default and renaming
>> it - was that overlooked? If not it would be helpful to comment on those
>> discussions to explain why you don't think it is a good idea.
>
> Not overlooked, but I side-stepped it because the discussion died
> down, and yes I don't agree that it needs to be the default. I could
> have mentioned my thinking in the cover letter.
>
>>> 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.
>>
>> Rebase re-orders commits so that fixups immediately follow their target
>> - do you do that here? I think that is very relevant because here we may
>> be dealing with several different commits each being targeted by a set
>> of fixups and presenting them mixed together will be confusing.
>
> No, I'm not doing that now, but I can take a look at that.
That's great, it is fine to punt things like this which require quite a
bit of work to implement to a later re-roll but please be clear in the
cover letter so reviewers know what to expect.
>> I think it should allow squashing a bunch of fixups together though. I
>> thought there was a plan [3] to refuse to squash a fixup unless the
>> range included its target.
>
> I attempted this with reject_fixupish_oldest(), assuming only the
> first commit needs to be checked as not being a fixup/squash/amend.
>
> But now I realize that maybe we need to check all of the commits, and
> also check if the target is in the range or not. It just makes the
> logic a lot bigger.
Yes it is a bit more involved. If the first commit is a fixup! then we
should allow the user to squash other fixups with the same target and
take the message from the last "amend!" commit if we see one. If there
are other commits it the range then we should refuse to squash as you do
here.
If the first target is not a fixup then we should refuse fixup commits
whose target we have not seen. As well as exact subject matches "git
rebase" accepts prefix matches and "fixup! $objectid". I think it is
fine to skip the prefix matches to start with here. The $objectid
matches shouldn't be too much extra work and I think they are worth
supporting because if I remember correctly git-gui creates them. Another
gotcha is that fixuping up a fixup prepends a "fixup!" to the subject
line so you need to be able to handle things like
fixup! fixup! the real target
fixup! amend! the real target
squash! fixup! the real target
etc. Hopefully looking at the code that handles fixups in the sequencer
will help
Thanks
Phillip
^ permalink raw reply [flat|nested] 115+ messages in thread
* Re: [PATCH v7 0/5] history: add squash subcommand to fold a range
2026-07-06 8:50 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-07-06 14:06 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Phillip Wood
@ 2026-07-06 20:42 ` Junio C Hamano
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
7 siblings, 0 replies; 115+ messages in thread
From: Junio C Hamano @ 2026-07-06 20:42 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget; +Cc: git, Harald Nordgren
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> Adds git history squash <revision-range> to fold a range of commits.
What I saw in the range-diff looked all reasonable.
> 3: 811e393ab4 ! 3: cf3346a1cd history: add squash subcommand to fold a range
> @@ Commit message
> Add "git history squash <revision-range>" to do this directly. It folds
> every commit in the range into the oldest one, keeping that commit's
> message and authorship and taking the tree of the newest commit, then
> - replays the commits above the range on top. fixup!, squash! and amend!
> - commits are folded like any other and are not interpreted, so the
> - squashed message comes from the oldest commit, or from an editor with
> - --reedit-message.
> + replays the commits above the range on top. The squashed message comes
> + from the oldest commit, or from an editor with --reedit-message. As that
> + message is reused, a range whose oldest commit is a fixup!, squash! or
> + amend! is refused, since the marker's target cannot be in the range.
> ...
> -+git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
> ++git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
> ++static int reject_fixupish_oldest(struct repository *repo,
> ++ struct commit *oldest)
> ++{
> ++ const char *message, *subject;
> ++ int ret = 0;
> ++
> ++ message = repo_logmsg_reencode(repo, oldest, NULL, NULL);
> ++ find_commit_subject(message, &subject);
> ++ if (starts_with(subject, "fixup! ") ||
> ++ starts_with(subject, "squash! ") ||
> ++ starts_with(subject, "amend! "))
> ++ ret = error(_("the range begins with a fixup!, squash! or amend! "
> ++ "commit whose target is not in the range"));
> ++ repo_unuse_commit_buffer(repo, oldest, message);
> ++ return ret;
> ++}
Nice. I often see myself getting rescued by the corresponding sanity
checks in the sequencer.
Will replace. Thanks.
^ permalink raw reply [flat|nested] 115+ messages in thread* [PATCH v8 0/5] history: add squash subcommand to fold a range
2026-07-06 8:50 ` [PATCH v7 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (6 preceding siblings ...)
2026-07-06 20:42 ` Junio C Hamano
@ 2026-07-10 9:06 ` Harald Nordgren via GitGitGadget
2026-07-10 9:06 ` [PATCH v8 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
` (6 more replies)
7 siblings, 7 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-10 9:06 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren
Adds git history squash <revision-range> to fold a range of commits.
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 | 54 ++-
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 522 +++++++++++++++++++--
sequencer.c | 70 +--
sequencer.h | 30 ++
t/meson.build | 1 +
t/t3455-history-squash.sh | 776 +++++++++++++++++++++++++++++++
9 files changed, 1387 insertions(+), 72 deletions(-)
create mode 100755 t/t3455-history-squash.sh
base-commit: f85a7e662054a7b0d9070e432508831afa214b47
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v8
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v8
Pull-Request: https://github.com/git/git/pull/2337
Range-diff vs v7:
1: 56ed8fadbb = 1: ba77752282 history: extract helper for a commit's parent tree
2: 212e9c228f = 2: 50f3572887 history: give commit_tree_ext a message template
3: cf3346a1cd ! 3: 2d81a40a05 history: add squash subcommand to fold a range
@@ Commit message
every commit in the range into the oldest one, keeping that commit's
message and authorship and taking the tree of the newest commit, then
replays the commits above the range on top. The squashed message comes
- from the oldest commit, or from an editor with --reedit-message. As that
- message is reused, a range whose oldest commit is a fixup!, squash! or
- amend! is refused, since the marker's target cannot be in the range.
+ from the oldest commit, or from an editor with --reedit-message. 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 check runs the range through
+ todo_list_rearrange_squash(), which leaves such a marker as a plain pick.
+ Markers whose target is in the range fold in as usual. As an exception, a
+ range made up entirely of markers for one target is combined anyway,
+ taking its message from the last amend! if there is one, so a batch of
+ fixups for the same commit can be collapsed.
The range is read like the arguments to "git rev-list", so several
- arguments such as "HEAD~3..HEAD ^topic" are allowed. A merge inside the
- range is folded when its other parent is reachable from the base,
- otherwise the range has more than one base and is rejected. By default
- the command also refuses when a ref points at a commit that the fold
- would discard. Use --update-refs=head to rewrite only the current branch
- instead.
+ revisions such as "HEAD~3..HEAD ^topic" may be given, and rev-list
+ options are accepted too. As "git replay" does, the walk options the fold
+ relies on are forced after setup_revisions() and a warning is printed if
+ an option changed them, so the first commit returned is the range's
+ oldest and its parent is the base regardless of what the user passed
+ (including after a "--"). A merge inside the range is folded when its
+ other parent is reachable from the base, otherwise the range has more
+ than one base and is rejected. By default the command also refuses when a
+ ref points at a commit that the fold would discard. Use --update-refs=head
+ to rewrite only the current branch instead.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-history.adoc: linkgit:gitglossary[7].
+the commit just below the oldest commit to squash. For example, `git
+history squash HEAD~3..HEAD` folds the three most recent commits into
+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
-+while leaving the two newest commits in place. _<revision-range>_ is read
-+like the arguments to linkgit:git-rev-list[1], so several arguments may be
++while leaving the two newest commits in place. Several revisions may be
+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
-+already on `topic`.
++already on `topic`. Rev-list options may also be given, but any that would
++change how the range is walked are overridden with a warning.
++
+The oldest commit's message and authorship are preserved by default,
+unless you specify `--reedit-message`. A merge commit inside the range is
@@ Documentation/git-history.adoc: linkgit:gitglossary[7].
+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.
++
-+Because the oldest commit's message is reused, the range may not begin
-+with a `fixup!`, `squash!`, or `amend!` commit, whose target is
-+necessarily outside the range.
++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. 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
@@ builtin/history.c: out:
+ int ret;
+
+ repo_init_revisions(repo, &revs, NULL);
++ revs.reverse = 1;
++ revs.topo_order = 1;
++ revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
++ revs.simplify_history = 0;
++ revs.boundary = 1;
++
+ strvec_push(&args, "ignored");
-+ strvec_push(&args, "--reverse");
-+ strvec_push(&args, "--topo-order");
-+ strvec_push(&args, "--boundary");
+ strvec_push(&args, "--ancestry-path");
+ strvec_pushv(&args, argv);
+ setup_revisions_from_strvec(&args, &revs, NULL);
@@ builtin/history.c: out:
+ goto out;
+ }
+
++ if (revs.reverse != 1 || revs.topo_order != 1 ||
++ revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||
++ revs.simplify_history != 0) {
++ 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;
++ }
++
+ /*
+ * A squash needs a base to reparent onto, so the range has to exclude
+ * something, as in "<base>..<tip>". A revision range with no such
@@ builtin/history.c: out:
+ goto out;
+ }
+
++ /*
++ * Set boundary commits aside for the base check below, and put every
++ * in-range commit but the tip into the interior set. A ref pointing
++ * at an interior commit would dangle once the range is folded away.
++ */
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
+ commit_list_insert(commit, &boundaries);
@@ builtin/history.c: out:
+ if (!oldest) {
+ ret = error(_("the revision range is empty"));
+ goto out;
-+ }
-+
-+ if (oldest == tip) {
++ } else if (oldest == tip) {
+ ret = error(_("the revision range holds a single commit; "
+ "nothing to squash"));
+ goto out;
-+ }
-+
-+ if (!oldest->parents)
++ } else if (!oldest->parents) {
+ BUG("an in-range commit must have a parent");
++ }
+ base = oldest->parents->item;
+
+ /*
@@ builtin/history.c: out:
+ return ret;
+}
+
-+static int reject_fixupish_oldest(struct repository *repo,
-+ struct commit *oldest)
++static const char *autosquash_target(const char *subject)
++{
++ const char *rest;
++
++ while (skip_prefix(subject, "fixup! ", &rest) ||
++ skip_prefix(subject, "squash! ", &rest) ||
++ skip_prefix(subject, "amend! ", &rest))
++ subject = rest;
++ return subject;
++}
++
++static int reject_dangling_fixups(struct repository *repo,
++ struct commit *base,
++ struct commit *tip,
++ struct commit *oldest,
++ struct commit **msg_source)
+{
-+ const char *message, *subject;
-+ int ret = 0;
-+
-+ message = repo_logmsg_reencode(repo, oldest, NULL, NULL);
-+ find_commit_subject(message, &subject);
-+ if (starts_with(subject, "fixup! ") ||
-+ starts_with(subject, "squash! ") ||
-+ starts_with(subject, "amend! "))
-+ ret = error(_("the range begins with a fixup!, squash! or amend! "
-+ "commit whose target is not in the range"));
-+ repo_unuse_commit_buffer(repo, oldest, message);
++ struct todo_list todo = TODO_LIST_INIT;
++ struct replay_opts opts = REPLAY_OPTS_INIT;
++ struct rev_info revs;
++ struct commit *commit, *last_amend = NULL;
++ struct strvec args = STRVEC_INIT;
++ char *dangling_subject = NULL, *dangling_target = NULL;
++ bool mixed_target = false, all_fixups_one_target;
++ int i, ret, nr_dangling = 0;
++
++ *msg_source = oldest;
++
++ repo_init_revisions(repo, &revs, NULL);
++ strvec_push(&args, "ignored");
++ strvec_push(&args, "--reverse");
++ strvec_push(&args, "--topo-order");
++ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
++ oid_to_hex(&tip->object.oid));
++ setup_revisions_from_strvec(&args, &revs, NULL);
++
++ if (prepare_revision_walk(&revs) < 0) {
++ ret = error(_("error preparing revisions"));
++ goto out;
++ }
++ while ((commit = get_revision(&revs)))
++ strbuf_addf(&todo.buf, "pick %s\n",
++ oid_to_hex(&commit->object.oid));
++
++ if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
++ todo_list_rearrange_squash(&todo) < 0) {
++ ret = error(_("could not check the range for fixups"));
++ goto out;
++ }
++
++ for (i = 0; i < todo.nr; i++) {
++ const char *message, *subject_start, *target;
++ char *subject;
++ size_t sublen;
++
++ if (todo.items[i].command != TODO_PICK)
++ continue;
++ message = repo_logmsg_reencode(repo, todo.items[i].commit,
++ NULL, NULL);
++ sublen = find_commit_subject(message, &subject_start);
++ subject = xmemdupz(subject_start, sublen);
++ target = autosquash_target(subject);
++ if (target != subject) {
++ nr_dangling++;
++ if (!dangling_target) {
++ dangling_target = xstrdup(target);
++ dangling_subject = xstrdup(subject);
++ } else if (strcmp(dangling_target, target)) {
++ mixed_target = true;
++ }
++ if (starts_with(subject, "amend! "))
++ last_amend = todo.items[i].commit;
++ }
++ free(subject);
++ repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
++ }
++
++ all_fixups_one_target = nr_dangling == todo.nr && !mixed_target;
++ if (nr_dangling && !all_fixups_one_target) {
++ ret = error(_("cannot squash '%s': its target is not in the "
++ "range"), dangling_subject);
++ } else {
++ if (last_amend)
++ *msg_source = last_amend;
++ ret = 0;
++ }
++
++out:
++ free(dangling_subject);
++ free(dangling_target);
++ todo_list_release(&todo);
++ replay_opts_release(&opts);
++ reset_revision_walk();
++ release_revisions(&revs);
++ strvec_clear(&args);
+ return ret;
+}
+
@@ builtin/history.c: out:
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct oidset interior = OIDSET_INIT;
-+ struct commit *base, *oldest, *tip, *rewritten;
++ struct commit *base, *oldest, *tip, *rewritten, *msg_source;
+ const struct object_id *base_tree_oid, *tip_tree_oid;
+ struct commit_list *parents = NULL;
+ struct rev_info revs = { 0 };
+ int ret;
+
-+ argc = parse_options(argc, argv, prefix, options, usage, 0);
++ argc = parse_options(argc, argv, prefix, options, usage,
++ PARSE_OPT_KEEP_UNKNOWN_OPT);
+ if (!argc) {
+ ret = error(_("command expects a revision range"));
+ goto out;
@@ builtin/history.c: out:
+ if (ret < 0)
+ goto out;
+
-+ ret = reject_fixupish_oldest(repo, oldest);
++ ret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source);
+ if (ret < 0)
+ goto out;
+
@@ builtin/history.c: out:
+ tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+ commit_list_append(base, &parents);
+
-+ ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
++ ret = commit_tree_ext(repo, "squash", msg_source, NULL, parents,
+ base_tree_oid, tip_tree_oid, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing squashed commit"));
@@ t/t3455-history-squash.sh (new)
+ test_grep "squash: updating" reflog
+'
+
++test_expect_success 'sanitizes rev-list walk options, before and after --' '
++ git reset --hard three &&
++ tip_tree=$(git rev-parse HEAD^{tree}) &&
++
++ git history squash --date-order start.. 2>err &&
++ test_grep "ignoring rev-list options" err &&
++ test_cmp_rev start HEAD^ &&
++ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
++
++ git reset --hard three &&
++ git history squash -- --reverse start.. 2>err &&
++ test_grep "ignoring rev-list options" err &&
++ test_cmp_rev start HEAD^ &&
++ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
++'
++
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+ git reset --hard three &&
+ final_tree=$(git rev-parse HEAD^{tree}) &&
@@ t/t3455-history-squash.sh (new)
+'
+
+
-+test_expect_success 'reuses the message of a fixup! commit in the range' '
++test_expect_success 'folds fixups whose target is in the range' '
+ git reset --hard start &&
-+ test_commit --no-tag reg1 file b &&
-+ git commit --allow-empty -m "fixup! reg1" &&
-+ test_commit reg2 file c &&
++ test_commit --no-tag target file b &&
++ git commit --allow-empty -m "fixup! target" &&
++ git commit --allow-empty -m "fixup! target" &&
++ test_commit --no-tag later file c &&
+
+ git history squash start.. &&
+
++ git rev-list --count start..HEAD >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
+ git log --format="%s" -1 >actual &&
-+ echo reg1 >expect &&
++ echo target >expect &&
+ test_cmp expect actual
+'
+
-+test_expect_success 'refuses a range whose oldest commit is a fixup!' '
++test_expect_success 'refuses a below-range fixup! after an in-range commit' '
+ git reset --hard start &&
-+ test_commit --no-tag "fixup! something" file b &&
-+ test_commit --no-tag tail file c &&
++ test_commit --no-tag inside file b &&
++ test_commit --no-tag "fixup! outside" file c &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
@@ t/t3455-history-squash.sh (new)
+ test_cmp_rev "$head_before" HEAD
+'
+
-+test_expect_success 'does not interpret squash! or amend! markers' '
++test_expect_success 'combines a run of fixups for one commit below the range' '
++ git reset --hard start &&
++ echo b >file && git add file && git commit -m "fixup! base" &&
++ echo c >file && git add file && git commit -m "fixup! base" &&
++
++ git history squash start.. &&
++
++ git rev-list --count start..HEAD >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
++ git log --format="%s" -1 >actual &&
++ echo "fixup! base" >expect &&
++ test_cmp expect actual
++'
++
++test_expect_success 'combining below-range fixups keeps the last amend! message' '
++ git reset --hard start &&
++ echo b >file && git add file && git commit -m "fixup! base" &&
++ printf "amend! base\n\namended body\n" >msg &&
++ echo c >file && git add file && git commit -qF msg &&
++
++ git history squash start.. &&
++
++ git rev-list --count start..HEAD >count &&
++ echo 1 >expect &&
++ test_cmp expect count &&
++ git log --format="%s" -1 >actual &&
++ echo "amend! base" >expect &&
++ test_cmp expect actual &&
++ git log --format="%b" -1 >body &&
++ test_grep "amended body" body
++'
++
++test_expect_success 'refuses fixups for two different commits below the range' '
++ git reset --hard start &&
++ echo b >file && git add file && git commit -m "fixup! aaa" &&
++ echo c >file && git add file && git commit -m "fixup! bbb" &&
++ head_before=$(git rev-parse HEAD) &&
++
++ test_must_fail git history squash start.. 2>err &&
++ test_grep "target is not in the range" err &&
++ test_cmp_rev "$head_before" HEAD
++'
++
++test_expect_success 'keeps the oldest message for in-range squash! and amend!' '
+ git reset --hard start &&
+ test_commit --no-tag marker-oldest file b &&
+ git commit --allow-empty -m "squash! marker-oldest" &&
@@ t/t3455-history-squash.sh (new)
+test_expect_success 'advice.historyUpdateRefs silences the hint' '
+ git reset --hard three &&
+ git branch -f mid HEAD~1 &&
++ head_before=$(git rev-parse HEAD) &&
+
+ 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 &&
++ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D mid
+'
@@ t/t3455-history-squash.sh (new)
+ 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 &&
-+ main=$(git symbolic-ref --short HEAD) &&
-+ git checkout -b forked-before &&
-+ test_commit forked-side fside x &&
-+ git checkout "$main" &&
-+ test_commit forked-base file b &&
-+ base=$(git rev-parse HEAD) &&
-+ test_commit forked-main file c &&
-+ git merge --no-ff -m merge forked-before &&
-+ merged=$(git rev-parse HEAD) &&
-+
-+ test_must_fail git history squash "$base.." 2>err &&
-+ test_grep "more than one base" err &&
-+ test_cmp_rev "$merged" HEAD
-+'
-+
+test_expect_success 'folds a range with two interior merges' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
4: 001356db93 ! 4: 0a735117ad sequencer: extract helpers for the squash message markers
@@ Metadata
Author: Harald Nordgren <haraldnordgren@gmail.com>
## Commit message ##
- sequencer: extract helpers for the squash message markers
+ sequencer: share the squash message marker helpers and flags
When "git rebase -i" squashes commits it builds an editor template with a
"This is a combination of N commits." banner, a "This is the 1st/Nth
@@ Commit message
Pull the three pieces out into add_squash_combination_header(),
add_squash_message_header() (which takes a flag for the "will be skipped"
variant) and squash_subject_comment_len(), and use them from
- update_squash_messages() and append_squash_message(). A later change
- reuses them to give "git history squash --reedit-message" the same
- template.
+ update_squash_messages() and append_squash_message(). Also move the
+ todo_item_flags enum to the header, so a caller reading the output of
+ todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)
+ from a plain fixup!. A later change reuses all of this to give "git
+ history squash --reedit-message" the same template.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## sequencer.c ##
-@@ sequencer.c: static const char skip_first_commit_msg_str[] = N_("The 1st commit message will
+@@ sequencer.c: static int is_pick_or_similar(enum todo_command command)
+ }
+ }
+
+-enum todo_item_flags {
+- TODO_EDIT_MERGE_MSG = (1 << 0),
+- TODO_REPLACE_FIXUP_MSG = (1 << 1),
+- TODO_EDIT_FIXUP_MSG = (1 << 2),
+-};
+-
+ static const char first_commit_msg_str[] = N_("This is the 1st commit message:");
+ static const char nth_commit_msg_fmt[] = N_("This is the commit message #%d:");
+ static const char skip_first_commit_msg_str[] = N_("The 1st commit message will be skipped:");
static const char skip_nth_commit_msg_fmt[] = N_("The commit message #%d will be skipped:");
static const char combined_commit_msg_fmt[] = N_("This is a combination of %d commits.");
@@ sequencer.c: static int update_squash_messages(struct repository *r,
comment_line_str);
## sequencer.h ##
+@@ sequencer.h: enum todo_command {
+ TODO_COMMENT
+ };
+
++/* Bits for the "flags" member of struct todo_item */
++enum todo_item_flags {
++ TODO_EDIT_MERGE_MSG = (1 << 0),
++ TODO_REPLACE_FIXUP_MSG = (1 << 1),
++ TODO_EDIT_FIXUP_MSG = (1 << 2),
++};
++
+ struct todo_item {
+ enum todo_command command;
+ struct commit *commit;
@@ sequencer.h: int todo_list_rearrange_squash(struct todo_list *todo_list);
*/
void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);
5: 615fe4dd3f ! 5: baf7e6f0a6 history: re-edit a squash with every message
@@ Commit message
By default "git history squash" reuses the oldest commit's message.
When --reedit-message is given it only reopened that one message, so the
- messages of the folded-in commits were lost.
+ messages of the other commits in the range were lost.
- Gather the messages of every commit in the range, oldest first, and build
- the same editor template that "git rebase -i" shows for a squash, using
+ Gather the message of every commit in the range and build the same editor
+ template that "git rebase -i --autosquash" shows for a squash, reusing
add_squash_combination_header(), add_squash_message_header() and
- squash_subject_comment_len(). Only the message text differs, the changes
- are always folded in. Following autosquash, a fixup!'s message is
- commented out in full under a "will be skipped" header, while a squash! or
- amend! keeps its body with only the marker subject commented.
+ squash_subject_comment_len(). Feed the range through
+ todo_list_rearrange_squash() so that each fixup!, squash! or amend! is
+ grouped under the commit it targets rather than shown in commit order,
+ exactly as autosquash would arrange them.
+
+ Only the message text differs, the changes are always folded in. A fixup!
+ message is commented out in full under a "will be skipped" header, a
+ squash! keeps its body with only the marker subject commented, and an
+ amend! replaces its target's message unless a squash! already folded into
+ that target, in which case it behaves like a squash!.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-history.adoc ##
-@@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], so several arguments may be
- given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
- already on `topic`.
+@@ Documentation/git-history.adoc: given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
+ already on `topic`. Rev-list options may also be given, but any that would
+ change how the range is walked are overridden with a warning.
+
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
@@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], s
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.
- +
- Because the oldest commit's message is reused, the range may not begin
- with a `fixup!`, `squash!`, or `amend!` commit, whose target is
--necessarily outside the range.
-+necessarily outside the range. The changes from every commit in the range
-+are always folded in. Only the message text differs. With
-+`--reedit-message` the template mirrors `git rebase -i`: the message of a
-+`fixup!` elsewhere in the range is commented out in full, while a
-+`squash!` or `amend!` keeps its message body with only the marker subject
-+commented, so you can fold the remark into the result.
+@@ Documentation/git-history.adoc: 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. 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.
++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.
++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
++(commented out in full), a `squash!` keeps its body with only the marker
++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
@@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
return 0;
}
++static bool amend_replaces_target(struct todo_list *todo, int target)
++{
++ int i;
++
++ for (i = target + 1; i < todo->nr &&
++ todo->items[i].command != TODO_PICK; i++) {
++ if (todo->items[i].command == TODO_SQUASH)
++ return false;
++ if (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)
++ return true;
++ }
++ return false;
++}
++
+static int build_squash_message(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct strbuf *out)
+{
-+ struct commit_list *commits = NULL, **tail = &commits, *c;
+ struct rev_info revs;
+ struct commit *commit;
+ struct strvec args = STRVEC_INIT;
-+ int n = 0, total, ret;
++ struct todo_list todo = TODO_LIST_INIT;
++ struct replay_opts opts = REPLAY_OPTS_INIT;
++ int i, nr_commits, ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
@@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
+ }
+
+ while ((commit = get_revision(&revs)))
-+ tail = &commit_list_insert(commit, tail)->next;
-+ total = commit_list_count(commits);
++ strbuf_addf(&todo.buf, "pick %s\n",
++ oid_to_hex(&commit->object.oid));
+
-+ for (c = commits; c; c = c->next) {
++ if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
++ todo_list_rearrange_squash(&todo) < 0) {
++ ret = error(_("could not prepare the squash message"));
++ goto out;
++ }
++
++ nr_commits = todo.nr;
++ for (i = 0; i < nr_commits; i++) {
++ struct todo_item *item = &todo.items[i];
+ const char *message, *body;
+ size_t commented_len;
-+ int skip;
++ bool skip, squashing;
+
-+ message = repo_logmsg_reencode(repo, c->item, NULL, NULL);
++ squashing = item->command == TODO_SQUASH ||
++ (item->flags & TODO_REPLACE_FIXUP_MSG);
++ if (item->command == TODO_PICK)
++ skip = amend_replaces_target(&todo, i);
++ else
++ skip = !squashing;
++
++ message = repo_logmsg_reencode(repo, item->commit, NULL, NULL);
+ find_commit_subject(message, &body);
+
-+ skip = starts_with(body, "fixup! ");
-+ commented_len = skip ? strlen(body) :
-+ squash_subject_comment_len(body, 1);
++ if (skip)
++ commented_len = strlen(body);
++ else if (squashing)
++ commented_len = squash_subject_comment_len(body, 1);
++ else
++ commented_len = 0;
+
-+ if (!n)
-+ add_squash_combination_header(out, total);
++ if (!i)
++ add_squash_combination_header(out, nr_commits);
+ strbuf_addch(out, '\n');
-+ add_squash_message_header(out, ++n, skip);
++ add_squash_message_header(out, i + 1, skip);
+ strbuf_addstr(out, "\n\n");
+ strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
+ strbuf_addstr(out, body + commented_len);
+ strbuf_complete_line(out);
+
-+ repo_unuse_commit_buffer(repo, c->item, message);
++ repo_unuse_commit_buffer(repo, item->commit, message);
+ }
+
+ ret = 0;
+
+out:
-+ commit_list_free(commits);
++ todo_list_release(&todo);
++ replay_opts_release(&opts);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
@@ 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;
+ struct commit *base, *oldest, *tip, *rewritten, *msg_source;
const struct object_id *base_tree_oid, *tip_tree_oid;
@@ builtin/history.c: static int cmd_history_squash(int argc,
}
@@ builtin/history.c: static int cmd_history_squash(int argc,
tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
commit_list_append(base, &parents);
-- ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
-+ ret = commit_tree_ext(repo, "squash", oldest,
+- ret = commit_tree_ext(repo, "squash", msg_source, NULL, parents,
++ ret = commit_tree_ext(repo, "squash", msg_source,
+ message.len ? message.buf : NULL, parents,
base_tree_oid, tip_tree_oid, &rewritten, flags);
if (ret < 0) {
@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
+ test_grep "amended message" final
+'
+
++test_expect_success '--reedit-message groups fixups under their targets' '
++ git reset --hard start &&
++ test_commit --no-tag alpha file a1 &&
++ test_commit --no-tag beta file b1 &&
++ printf "fixup! alpha\n" >msg &&
++ echo a2 >file &&
++ git add file &&
++ git commit -qF msg &&
++ printf "fixup! beta\n" >msg &&
++ echo b2 >file &&
++ git add file &&
++ git commit -qF msg &&
++
++ write_script editor <<-\EOF &&
++ cat "$1" >edited
++ EOF
++ test_set_editor "$(pwd)/editor" &&
++ git history squash --reedit-message start.. &&
++
++ cat >expect <<-EOF &&
++ # This is a combination of 4 commits.
++ # This is the 1st commit message:
++
++ alpha
++
++ # The commit message #2 will be skipped:
++
++ # fixup! alpha
++
++ # This is the commit message #3:
++
++ beta
++
++ # The commit message #4 will be skipped:
++
++ # fixup! beta
++
++ # Please enter the commit message for the squash changes. Lines starting
++ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
++ # Changes to be committed:
++ # modified: file
++ #
++ EOF
++ test_cmp expect edited
++'
++
++test_expect_success '--reedit-message lets amend! replace its target message' '
++ git reset --hard start &&
++ test_commit --no-tag mark-base file b &&
++ printf "amend! mark-base\n\namended message\n" >msg &&
++ echo c >file &&
++ git add file &&
++ git commit -qF msg &&
++ printf "squash! mark-base\n\nsquash remark\n" >msg &&
++ echo d >file &&
++ git add file &&
++ git commit -qF msg &&
++
++ write_script editor <<-\EOF &&
++ cat "$1" >edited
++ EOF
++ test_set_editor "$(pwd)/editor" &&
++ git history squash --reedit-message start.. &&
++
++ cat >expect <<-EOF &&
++ # This is a combination of 3 commits.
++ # The 1st commit message will be skipped:
++
++ # mark-base
++
++ # This is the commit message #2:
++
++ # amend! mark-base
++
++ amended message
++
++ # This is the commit message #3:
++
++ # squash! mark-base
++
++ squash remark
++
++ # Please enter the commit message for the squash changes. Lines starting
++ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
++ # Changes to be committed:
++ # modified: file
++ #
++ EOF
++ test_cmp expect edited &&
++ git log -1 --format="%B" >final &&
++ test_grep ! "mark-base" final &&
++ test_grep "amended message" final &&
++ test_grep "squash remark" final
++'
++
+test_expect_success '--reedit-message aborts on an empty message' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
--
gitgitgadget
^ permalink raw reply [flat|nested] 115+ messages in thread* [PATCH v8 1/5] history: extract helper for a commit's parent tree
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
@ 2026-07-10 9:06 ` Harald Nordgren via GitGitGadget
2026-07-10 9:06 ` [PATCH v8 2/5] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
6 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-10 9:06 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Three places resolve the tree of a commit's first parent, falling back
to the empty tree for a root commit, each repeating the same parse and
oidcpy dance. Extract a first_parent_tree_oid() helper and route the
existing callers through it.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 58 +++++++++++++++++++++--------------------------
1 file changed, 26 insertions(+), 32 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index fd83de8265..9f516687fe 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -157,6 +157,25 @@ out:
return ret;
}
+static int first_parent_tree_oid(struct repository *repo,
+ struct commit *commit,
+ struct object_id *out)
+{
+ struct commit *parent = commit->parents ? commit->parents->item : NULL;
+
+ if (!parent) {
+ oidcpy(out, repo->hash_algo->empty_tree);
+ return 0;
+ }
+
+ if (repo_parse_commit(repo, parent))
+ return error(_("unable to parse parent commit %s"),
+ oid_to_hex(&parent->object.oid));
+
+ oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
+ return 0;
+}
+
static int commit_tree_with_edited_message(struct repository *repo,
const char *action,
struct commit *original,
@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,
{
struct object_id parent_tree_oid;
const struct object_id *tree_oid;
- struct commit *parent;
tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- parent = original->parents ? original->parents->item : NULL;
- if (parent) {
- if (repo_parse_commit(repo, parent)) {
- return error(_("unable to parse parent commit %s"),
- oid_to_hex(&parent->object.oid));
- }
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return commit_tree_ext(repo, action, original, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,
struct commit *original,
struct tree *result)
{
- struct commit *parent = original->parents ? original->parents->item : NULL;
struct object_id parent_tree_oid;
- if (parent) {
- if (repo_parse_commit(repo, parent))
- return error(_("unable to parse parent of %s"),
- oid_to_hex(&original->object.oid));
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return oideq(&result->object.oid, &parent_tree_oid);
}
@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,
struct tree *split_tree;
int ret;
- if (original->parents) {
- if (repo_parse_commit(repo, original->parents->item)) {
- ret = error(_("unable to parse parent commit %s"),
- oid_to_hex(&original->parents->item->object.oid));
- goto out;
- }
-
- parent_tree_oid = *get_commit_tree_oid(original->parents->item);
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
+ ret = -1;
+ goto out;
}
original_commit_tree_oid = get_commit_tree_oid(original);
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v8 2/5] history: give commit_tree_ext a message template
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
2026-07-10 9:06 ` [PATCH v8 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
@ 2026-07-10 9:06 ` Harald Nordgren via GitGitGadget
2026-07-10 9:06 ` [PATCH v8 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
6 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-10 9:06 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
commit_tree_ext() reuses the message of the commit it is handed. A
caller that folds several commits together wants to seed the message
from more than that single commit, so add an optional message_template
parameter. When NULL, the behavior is unchanged.
Pass NULL from the existing fixup and split callers.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 9f516687fe..cbba25096f 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -101,6 +101,7 @@ enum commit_tree_flags {
static int commit_tree_ext(struct repository *repo,
const char *action,
struct commit *commit_with_message,
+ const char *message_template,
const struct commit_list *parents,
const struct object_id *old_tree,
const struct object_id *new_tree,
@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
+ if (!message_template)
+ message_template = original_body;
+
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
+ message_template, action, &commit_message);
if (ret < 0)
goto out;
} else {
- strbuf_addstr(&commit_message, original_body);
+ strbuf_addstr(&commit_message, message_template);
}
original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
return -1;
- return commit_tree_ext(repo, action, original, original->parents,
+ return commit_tree_ext(repo, action, original, NULL, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,
goto out;
if (!skip_commit) {
- ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
&original_tree->object.oid, &merge_result.tree->object.oid,
&rewritten, flags);
if (ret < 0) {
@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v8 3/5] history: add squash subcommand to fold a range
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
2026-07-10 9:06 ` [PATCH v8 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-07-10 9:06 ` [PATCH v8 2/5] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
@ 2026-07-10 9:06 ` Harald Nordgren via GitGitGadget
2026-07-10 9:06 ` [PATCH v8 4/5] sequencer: share the squash message marker helpers and flags Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
6 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-10 9:06 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
Add "git history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
message and authorship and taking the tree of the newest commit, then
replays the commits above the range on top. The squashed message comes
from the oldest commit, or from an editor with --reedit-message. 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 check runs the range through
todo_list_rearrange_squash(), which leaves such a marker as a plain pick.
Markers whose target is in the range fold in as usual. As an exception, a
range made up entirely of markers for one target is combined anyway,
taking its message from the last amend! if there is one, so a batch of
fixups for the same commit can be collapsed.
The range is read like the arguments to "git rev-list", so several
revisions such as "HEAD~3..HEAD ^topic" may be given, and rev-list
options are accepted too. As "git replay" does, the walk options the fold
relies on are forced after setup_revisions() and a warning is printed if
an option changed them, so the first commit returned is the range's
oldest and its parent is the base regardless of what the user passed
(including after a "--"). A merge inside the range is folded when its
other parent is reachable from the base, otherwise the range has more
than one base and is rejected. By default the command also refuses when a
ref points at a commit that the fold would discard. Use --update-refs=head
to rewrite only the current branch instead.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/advice.adoc | 4 +
Documentation/git-history.adoc | 45 ++-
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 343 +++++++++++++++++++
t/meson.build | 1 +
t/t3455-history-squash.sh | 566 +++++++++++++++++++++++++++++++
7 files changed, 958 insertions(+), 3 deletions(-)
create mode 100755 t/t3455-history-squash.sh
diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index c3c190ba6a..cd27f7e795 100644
--- a/Documentation/config/advice.adoc
+++ b/Documentation/config/advice.adoc
@@ -59,6 +59,10 @@ 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.
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 2ba8121795..ad921ac2be 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
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>...]
+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
DESCRIPTION
-----------
@@ -42,8 +43,11 @@ at once.
LIMITATIONS
-----------
-This command does not (yet) work with histories that contain merges. You
-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
+This command does not (yet) replay merge commits onto the rewritten
+history: if a commit that would be replayed is a merge, the operation is
+rejected, and you should use linkgit:git-rebase[1] with the
+`--rebase-merges` flag instead. The `squash` subcommand can still fold a
+merge that lies inside the range, as long as the range has a single base.
Furthermore, the command does not support operations that can result in merge
conflicts. This limitation is by design as history rewrites are not intended to
@@ -97,6 +101,40 @@ linkgit:gitglossary[7].
It is invalid to select either all or no hunks, as that would lead to
one of the commits becoming empty.
+`squash <revision-range>`::
+ Fold all commits in _<revision-range>_ into the oldest commit of that
+ range. The resulting commit keeps the oldest commit's message and
+ authorship and takes the tree of the range's newest commit, so the
+ whole range collapses into a single commit. Commits above the range
+ are replayed on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash HEAD~3..HEAD` folds the three most recent commits into
+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
+while leaving the two newest commits in place. Several revisions may be
+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
+already on `topic`. Rev-list options may also be given, but any that would
+change how the range is walked are overridden with a warning.
++
+The oldest commit's message and authorship are preserved by default,
+unless you specify `--reedit-message`. A merge commit inside the range is
+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.
++
+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. 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
+`--update-refs=branches` the command refuses. Rerun with
+`--update-refs=head` to rewrite only the current branch and leave such
+refs pointing at the old commits.
+
OPTIONS
-------
@@ -107,7 +145,8 @@ OPTIONS
ref updates is generally safe.
`--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.
`--empty=(drop|keep|abort)`::
Control what happens when a commit becomes empty as a result of the
diff --git a/advice.c b/advice.c
index 0018501b7b..5c6ff95e31 100644
--- a/advice.c
+++ b/advice.c
@@ -58,6 +58,7 @@ 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" },
diff --git a/advice.h b/advice.h
index 8def280688..911b4e4643 100644
--- a/advice.h
+++ b/advice.h
@@ -25,6 +25,7 @@ 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,
diff --git a/builtin/history.c b/builtin/history.c
index cbba25096f..d3f535ded9 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
#define USE_THE_REPOSITORY_VARIABLE
#include "builtin.h"
+#include "advice.h"
#include "cache-tree.h"
#include "commit.h"
#include "commit-reach.h"
@@ -30,6 +31,8 @@
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
+#define GIT_HISTORY_SQUASH_USAGE \
+ N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>")
static void change_data_free(void *util, const char *str UNUSED)
{
@@ -973,6 +976,344 @@ out:
return ret;
}
+/*
+ * Resolve a "<base>..<tip>" revision range into the base commit just outside
+ * the range (which becomes the parent of the squashed commit), the oldest
+ * commit contained in the range (whose message the squash reuses), and the
+ * range tip (whose tree becomes the result). A merge inside the range is fine,
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+ const char **argv,
+ struct commit **base_out,
+ struct commit **oldest_out,
+ struct commit **tip_out,
+ struct oidset *interior_out)
+{
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+ struct commit_list *boundaries = NULL, *b;
+ struct strvec args = STRVEC_INIT;
+ size_t i;
+ int ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ revs.reverse = 1;
+ revs.topo_order = 1;
+ revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
+ revs.simplify_history = 0;
+ revs.boundary = 1;
+
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--ancestry-path");
+ strvec_pushv(&args, argv);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (args.nr != 1) {
+ ret = error(_("unrecognized argument: %s"), args.v[1]);
+ goto out;
+ }
+
+ if (revs.reverse != 1 || revs.topo_order != 1 ||
+ revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||
+ revs.simplify_history != 0) {
+ 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;
+ }
+
+ /*
+ * A squash needs a base to reparent onto, so the range has to exclude
+ * something, as in "<base>..<tip>". A revision range with no such
+ * bottom commit 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(_("not a '<base>..<tip>' revision range"));
+ goto out;
+ }
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ /*
+ * Set boundary commits aside for the base check below, and put every
+ * in-range commit but the tip into the interior set. A ref pointing
+ * at an interior commit would dangle once the range is folded away.
+ */
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
+ commit_list_insert(commit, &boundaries);
+ continue;
+ }
+ if (!oldest)
+ oldest = commit;
+ if (tip)
+ oidset_insert(interior_out, &tip->object.oid);
+ tip = commit;
+ }
+
+ if (!oldest) {
+ ret = error(_("the revision range is empty"));
+ goto out;
+ } else if (oldest == tip) {
+ ret = error(_("the revision range holds a single commit; "
+ "nothing to squash"));
+ goto out;
+ } else if (!oldest->parents) {
+ BUG("an in-range commit must have a parent");
+ }
+ base = oldest->parents->item;
+
+ /*
+ * A boundary other than the base is an in-range commit reaching a
+ * commit outside the range, so the range has more than one base.
+ */
+ for (b = boundaries; b; b = b->next) {
+ if (b->item != base) {
+ ret = error(_("the revision range has more than one base; "
+ "cannot squash"));
+ goto out;
+ }
+ }
+
+ *base_out = base;
+ *oldest_out = oldest;
+ *tip_out = tip;
+ ret = 0;
+
+out:
+ commit_list_free(boundaries);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
+static const char *autosquash_target(const char *subject)
+{
+ const char *rest;
+
+ while (skip_prefix(subject, "fixup! ", &rest) ||
+ skip_prefix(subject, "squash! ", &rest) ||
+ skip_prefix(subject, "amend! ", &rest))
+ subject = rest;
+ return subject;
+}
+
+static int reject_dangling_fixups(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct commit *oldest,
+ struct commit **msg_source)
+{
+ struct todo_list todo = TODO_LIST_INIT;
+ struct replay_opts opts = REPLAY_OPTS_INIT;
+ struct rev_info revs;
+ struct commit *commit, *last_amend = NULL;
+ struct strvec args = STRVEC_INIT;
+ char *dangling_subject = NULL, *dangling_target = NULL;
+ bool mixed_target = false, all_fixups_one_target;
+ int i, ret, nr_dangling = 0;
+
+ *msg_source = oldest;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+ while ((commit = get_revision(&revs)))
+ strbuf_addf(&todo.buf, "pick %s\n",
+ oid_to_hex(&commit->object.oid));
+
+ if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
+ todo_list_rearrange_squash(&todo) < 0) {
+ ret = error(_("could not check the range for fixups"));
+ goto out;
+ }
+
+ for (i = 0; i < todo.nr; i++) {
+ const char *message, *subject_start, *target;
+ char *subject;
+ size_t sublen;
+
+ if (todo.items[i].command != TODO_PICK)
+ continue;
+ message = repo_logmsg_reencode(repo, todo.items[i].commit,
+ NULL, NULL);
+ sublen = find_commit_subject(message, &subject_start);
+ subject = xmemdupz(subject_start, sublen);
+ target = autosquash_target(subject);
+ if (target != subject) {
+ nr_dangling++;
+ if (!dangling_target) {
+ dangling_target = xstrdup(target);
+ dangling_subject = xstrdup(subject);
+ } else if (strcmp(dangling_target, target)) {
+ mixed_target = true;
+ }
+ if (starts_with(subject, "amend! "))
+ last_amend = todo.items[i].commit;
+ }
+ free(subject);
+ repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
+ }
+
+ all_fixups_one_target = nr_dangling == todo.nr && !mixed_target;
+ if (nr_dangling && !all_fixups_one_target) {
+ ret = error(_("cannot squash '%s': its target is not in the "
+ "range"), dangling_subject);
+ } else {
+ if (last_amend)
+ *msg_source = last_amend;
+ ret = 0;
+ }
+
+out:
+ free(dangling_subject);
+ free(dangling_target);
+ todo_list_release(&todo);
+ replay_opts_release(&opts);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ 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,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_SQUASH_USAGE,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct oidset interior = OIDSET_INIT;
+ struct commit *base, *oldest, *tip, *rewritten, *msg_source;
+ const struct object_id *base_tree_oid, *tip_tree_oid;
+ struct commit_list *parents = NULL;
+ struct rev_info revs = { 0 };
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage,
+ PARSE_OPT_KEEP_UNKNOWN_OPT);
+ if (!argc) {
+ ret = error(_("command expects a revision range"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
+ &interior);
+ if (ret < 0)
+ goto out;
+
+ ret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source);
+ 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;
+
+ base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+ tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+ commit_list_append(base, &parents);
+
+ ret = commit_tree_ext(repo, "squash", msg_source, NULL, parents,
+ base_tree_oid, tip_tree_oid, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing squashed commit"));
+ goto out;
+ }
+
+ strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, tip, rewritten,
+ reflog_msg.buf, dry_run,
+ REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ strbuf_release(&reflog_msg);
+ oidset_clear(&interior);
+ commit_list_free(parents);
+ release_revisions(&revs);
+ return ret;
+}
+
int cmd_history(int argc,
const char **argv,
const char *prefix,
@@ -982,6 +1323,7 @@ int cmd_history(int argc,
GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
+ GIT_HISTORY_SQUASH_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
@@ -989,6 +1331,7 @@ int cmd_history(int argc,
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
+ OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
OPT_END(),
};
diff --git a/t/meson.build b/t/meson.build
index 7c3c070426..459e251623 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -400,6 +400,7 @@ integration_tests = [
't3451-history-reword.sh',
't3452-history-split.sh',
't3453-history-fixup.sh',
+ 't3455-history-squash.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
new file mode 100755
index 0000000000..6bc0a89ebc
--- /dev/null
+++ b/t/t3455-history-squash.sh
@@ -0,0 +1,566 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'setup linear history touching two files' '
+ test_commit base file a &&
+ git tag start &&
+ test_commit --no-tag one other x &&
+ test_commit --no-tag two file c &&
+ test_commit three file d
+'
+
+test_expect_success 'errors on missing range argument' '
+ test_must_fail git history squash 2>err &&
+ test_grep "expects a revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+ test_must_fail git history squash HEAD..HEAD 2>err &&
+ test_grep "the revision range is empty" err
+'
+
+test_expect_success 'errors on a single revision that is not a range' '
+ test_must_fail git history squash HEAD 2>err &&
+ test_grep "not a .*range" err &&
+ test_must_fail git history squash HEAD~1 2>err &&
+ test_grep "not a .*range" err
+'
+
+test_expect_success 'errors on a range holding a single commit' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "HEAD^!" 2>err &&
+ test_grep "single commit; nothing to squash" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'accepts multiple revision arguments with an exclusion' '
+ git reset --hard three &&
+ git branch -f keep HEAD~2 &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..HEAD ^keep &&
+
+ git log --format="%s" start..HEAD >actual &&
+ cat >expect <<-\EOF &&
+ two
+ one
+ EOF
+ test_cmp expect actual &&
+ test_cmp_rev keep HEAD~1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+ git branch -D keep
+'
+
+test_expect_success 'squashes a branch the current branch is not on' '
+ git reset --hard three &&
+ main=$(git symbolic-ref --short HEAD) &&
+ head_before=$(git rev-parse HEAD) &&
+ git checkout -b off-history start &&
+ test_commit --no-tag off-one off a &&
+ test_commit --no-tag off-two off b &&
+ git checkout "$main" &&
+
+ git history squash start..off-history &&
+
+ git rev-list --count start..off-history >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D off-history
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --dry-run start.. >out &&
+ predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git history squash start.. &&
+
+ 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})" &&
+ git log --format="%s" -1 >subject &&
+ echo one >expect &&
+ test_cmp expect subject &&
+ git reflog >reflog &&
+ test_grep "squash: updating" reflog
+'
+
+test_expect_success 'sanitizes rev-list walk options, before and after --' '
+ git reset --hard three &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --date-order start.. 2>err &&
+ test_grep "ignoring rev-list options" err &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+ git reset --hard three &&
+ git history squash -- --reverse start.. 2>err &&
+ test_grep "ignoring rev-list options" err &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+ git reset --hard three &&
+ final_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..@~1 &&
+
+ git log --format="%s" start..HEAD >actual &&
+ cat >expect <<-\EOF &&
+ three
+ one
+ EOF
+ test_cmp expect actual &&
+
+ test_cmp_rev start HEAD~2 &&
+ test "$final_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes when the base is the root commit' '
+ git reset --hard three &&
+ root=$(git rev-list --max-parents=0 HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash "$root.." &&
+
+ git rev-list --count "$root..HEAD" >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$root" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+
+test_expect_success 'folds fixups whose target is in the range' '
+ git reset --hard start &&
+ test_commit --no-tag target file b &&
+ git commit --allow-empty -m "fixup! target" &&
+ git commit --allow-empty -m "fixup! target" &&
+ test_commit --no-tag later file c &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ git log --format="%s" -1 >actual &&
+ echo target >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success 'refuses a below-range fixup! after an in-range commit' '
+ git reset --hard start &&
+ test_commit --no-tag inside file b &&
+ test_commit --no-tag "fixup! outside" file c &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "target is not in the range" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'combines a run of fixups for one commit below the range' '
+ git reset --hard start &&
+ echo b >file && git add file && git commit -m "fixup! base" &&
+ echo c >file && git add file && git commit -m "fixup! base" &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ git log --format="%s" -1 >actual &&
+ echo "fixup! base" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success 'combining below-range fixups keeps the last amend! message' '
+ git reset --hard start &&
+ echo b >file && git add file && git commit -m "fixup! base" &&
+ printf "amend! base\n\namended body\n" >msg &&
+ echo c >file && git add file && git commit -qF msg &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ git log --format="%s" -1 >actual &&
+ echo "amend! base" >expect &&
+ test_cmp expect actual &&
+ git log --format="%b" -1 >body &&
+ test_grep "amended body" body
+'
+
+test_expect_success 'refuses fixups for two different commits below the range' '
+ git reset --hard start &&
+ echo b >file && git add file && git commit -m "fixup! aaa" &&
+ echo c >file && git add file && git commit -m "fixup! bbb" &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "target is not in the range" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'keeps the oldest message for in-range squash! and amend!' '
+ git reset --hard start &&
+ test_commit --no-tag marker-oldest file b &&
+ git commit --allow-empty -m "squash! marker-oldest" &&
+ git commit --allow-empty -m "amend! marker-oldest" &&
+ test_commit --no-tag marker-newest file c &&
+
+ git history squash start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ git log --format="%s" -1 >actual &&
+ echo marker-oldest >expect &&
+ test_cmp expect actual
+'
+
+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 --no-tag oldest file b &&
+ test_commit newest file c &&
+
+ git history squash start.. &&
+
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Squasher <squash@example.com>" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--update-refs=head only moves HEAD' '
+ git reset --hard three &&
+ git branch -f other HEAD &&
+ other_before=$(git rev-parse other) &&
+
+ git history squash --update-refs=head start.. &&
+
+ git rev-list --count start..HEAD >count &&
+ echo 1 >expect &&
+ test_cmp expect count &&
+ test_cmp_rev "$other_before" other
+'
+
+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 &&
+ head_before=$(git rev-parse HEAD) &&
+
+ 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 &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ 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~1 &&
+ mid_before=$(git rev-parse mid) &&
+
+ 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 &&
+
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag before-side file b &&
+ git checkout -b inner-side &&
+ test_commit --no-tag on-inner-side inner x &&
+ git checkout "$main" &&
+ test_commit --no-tag after-side file c &&
+ git merge --no-ff -m merge inner-side &&
+ git branch -D inner-side &&
+ test_commit --no-tag after-merge file d &&
+ 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 &&
+ git log --format="%s" -1 >subject &&
+ echo before-side >expect &&
+ test_cmp expect subject &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file inner
+'
+
+test_expect_success 'folds a merge of a branch that forked at the base' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b base-fork-side &&
+ test_commit --no-tag base-fork-side side x &&
+ git checkout "$main" &&
+ 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 'refuses a merge whose other parent is outside the range' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b outside-parent &&
+ test_commit --no-tag outside-parent outside x &&
+ git checkout "$main" &&
+ test_commit --no-tag outside-main file b &&
+ base=$(git rev-parse HEAD) &&
+ test_commit --no-tag outside-mid file c &&
+ git merge --no-ff -m "merge outside-parent" outside-parent &&
+ git branch -D outside-parent &&
+ merged=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range whose tip is a merge commit' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag tipmerge-base file b &&
+ git checkout -b tipmerge-side &&
+ test_commit --no-tag tipmerge-side side x &&
+ git checkout "$main" &&
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b basemerge-side &&
+ test_commit --no-tag basemerge-side side x &&
+ git checkout "$main" &&
+ 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 'folds a range with two interior merges' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ 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 "$main" &&
+ 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 "$main" &&
+ 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-one file b &&
+ test_commit --no-tag desc-two file c &&
+ 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 d &&
+ 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v8 4/5] sequencer: share the squash message marker helpers and flags
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-07-10 9:06 ` [PATCH v8 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-07-10 9:06 ` Harald Nordgren via GitGitGadget
2026-07-10 9:06 ` [PATCH v8 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
6 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-10 9:06 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
When "git rebase -i" squashes commits it builds an editor template with a
"This is a combination of N commits." banner, a "This is the 1st/Nth
commit message:" header above each kept message (or a "will be skipped"
header for a dropped one), and a commented-out subject for any fixup!,
squash! or amend! commit. The banner, the headers and the
subject-commenting all live in static helpers in sequencer.c wired to the
rebase state, so no other command can present a squash the same way.
Pull the three pieces out into add_squash_combination_header(),
add_squash_message_header() (which takes a flag for the "will be skipped"
variant) and squash_subject_comment_len(), and use them from
update_squash_messages() and append_squash_message(). Also move the
todo_item_flags enum to the header, so a caller reading the output of
todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)
from a plain fixup!. A later change reuses all of this to give "git
history squash --reedit-message" the same template.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
sequencer.c | 70 +++++++++++++++++++++++++++++------------------------
sequencer.h | 30 +++++++++++++++++++++++
2 files changed, 69 insertions(+), 31 deletions(-)
diff --git a/sequencer.c b/sequencer.c
index 0fe8fed6c3..2387afd9b5 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)
}
}
-enum todo_item_flags {
- TODO_EDIT_MERGE_MSG = (1 << 0),
- TODO_REPLACE_FIXUP_MSG = (1 << 1),
- TODO_EDIT_FIXUP_MSG = (1 << 2),
-};
-
static const char first_commit_msg_str[] = N_("This is the 1st commit message:");
static const char nth_commit_msg_fmt[] = N_("This is the commit message #%d:");
static const char skip_first_commit_msg_str[] = N_("The 1st commit message will be skipped:");
static const char skip_nth_commit_msg_fmt[] = N_("The commit message #%d will be skipped:");
static const char combined_commit_msg_fmt[] = N_("This is a combination of %d commits.");
+void add_squash_combination_header(struct strbuf *buf, int n)
+{
+ strbuf_addf(buf, "%s ", comment_line_str);
+ strbuf_addf(buf, _(combined_commit_msg_fmt), n);
+}
+
+void add_squash_message_header(struct strbuf *buf, int n, int skip)
+{
+ strbuf_addf(buf, "%s ", comment_line_str);
+ if (n == 1)
+ strbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :
+ _(first_commit_msg_str));
+ else
+ strbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :
+ _(nth_commit_msg_fmt), n);
+}
+
+size_t squash_subject_comment_len(const char *body, int squashing)
+{
+ if (starts_with(body, "amend!") ||
+ (squashing && (starts_with(body, "squash!") ||
+ starts_with(body, "fixup!"))))
+ return commit_subject_length(body);
+ return 0;
+}
+
static int is_fixup_flag(enum todo_command command, unsigned flag)
{
return command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||
@@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,
{
struct replay_ctx *ctx = opts->ctx;
const char *fixup_msg;
- size_t commented_len = 0, fixup_off;
- /*
- * amend is non-interactive and not normally used with fixup!
- * or squash! commits, so only comment out those subjects when
- * squashing commit messages.
- */
- if (starts_with(body, "amend!") ||
- ((command == TODO_SQUASH || seen_squash(ctx)) &&
- (starts_with(body, "squash!") || starts_with(body, "fixup!"))))
- commented_len = commit_subject_length(body);
+ size_t commented_len, fixup_off;
+
+ commented_len = squash_subject_comment_len(body,
+ command == TODO_SQUASH || seen_squash(ctx));
- strbuf_addf(buf, "\n%s ", comment_line_str);
- strbuf_addf(buf, _(nth_commit_msg_fmt),
- ++ctx->current_fixup_count + 1);
+ strbuf_addch(buf, '\n');
+ add_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);
strbuf_addstr(buf, "\n\n");
strbuf_add_commented_lines(buf, body, commented_len, comment_line_str);
/* buf->buf may be reallocated so store an offset into the buffer */
@@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r,
eol = !starts_with(buf.buf, comment_line_str) ?
buf.buf : strchrnul(buf.buf, '\n');
- strbuf_addf(&header, "%s ", comment_line_str);
- strbuf_addf(&header, _(combined_commit_msg_fmt),
- ctx->current_fixup_count + 2);
+ add_squash_combination_header(&header,
+ ctx->current_fixup_count + 2);
strbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);
strbuf_release(&header);
if (is_fixup_flag(command, flag) && !seen_squash(ctx))
@@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r,
repo_unuse_commit_buffer(r, head_commit, head_message);
return error(_("cannot write '%s'"), rebase_path_fixup_msg());
}
- strbuf_addf(&buf, "%s ", comment_line_str);
- strbuf_addf(&buf, _(combined_commit_msg_fmt), 2);
- strbuf_addf(&buf, "\n%s ", comment_line_str);
- strbuf_addstr(&buf, is_fixup_flag(command, flag) ?
- _(skip_first_commit_msg_str) :
- _(first_commit_msg_str));
+ add_squash_combination_header(&buf, 2);
+ strbuf_addch(&buf, '\n');
+ add_squash_message_header(&buf, 1, is_fixup_flag(command, flag));
strbuf_addstr(&buf, "\n\n");
if (is_fixup_flag(command, flag))
strbuf_add_commented_lines(&buf, body, strlen(body),
@@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r,
if (command == TODO_SQUASH || is_fixup_flag(command, flag)) {
res = append_squash_message(&buf, body, command, opts, flag);
} else if (command == TODO_FIXUP) {
- strbuf_addf(&buf, "\n%s ", comment_line_str);
- strbuf_addf(&buf, _(skip_nth_commit_msg_fmt),
- ++ctx->current_fixup_count + 1);
+ strbuf_addch(&buf, '\n');
+ add_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);
strbuf_addstr(&buf, "\n\n");
strbuf_add_commented_lines(&buf, body, strlen(body),
comment_line_str);
diff --git a/sequencer.h b/sequencer.h
index 64a9c7fb1b..b01f897020 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -119,6 +119,13 @@ enum todo_command {
TODO_COMMENT
};
+/* Bits for the "flags" member of struct todo_item */
+enum todo_item_flags {
+ TODO_EDIT_MERGE_MSG = (1 << 0),
+ TODO_REPLACE_FIXUP_MSG = (1 << 1),
+ TODO_EDIT_FIXUP_MSG = (1 << 2),
+};
+
struct todo_item {
enum todo_command command;
struct commit *commit;
@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);
*/
void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);
+/*
+ * Append the "This is a combination of N commits." banner that "git rebase
+ * -i" writes at the top of a squashed commit's message, commented out with
+ * the comment character.
+ */
+void add_squash_combination_header(struct strbuf *buf, int n);
+
+/*
+ * Append the header (1-based N) that "git rebase -i" writes above each message
+ * when squashing, commented out with the comment character. With SKIP it reads
+ * "The ... commit message will be skipped" for a message that is dropped (a
+ * fixup), otherwise "This is the ... commit message".
+ */
+void add_squash_message_header(struct strbuf *buf, int n, int skip);
+
+/*
+ * Return the length of the leading subject of BODY when it should be commented
+ * out in a squash message, or 0 otherwise. An "amend!" subject always
+ * qualifies; "squash!" and "fixup!" subjects only when SQUASHING, since a
+ * plain fixup chain keeps them.
+ */
+size_t squash_subject_comment_len(const char *body, int squashing);
+
void append_conflicts_hint(struct index_state *istate,
struct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);
enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v8 5/5] history: re-edit a squash with every message
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-07-10 9:06 ` [PATCH v8 4/5] sequencer: share the squash message marker helpers and flags Harald Nordgren via GitGitGadget
@ 2026-07-10 9:06 ` Harald Nordgren via GitGitGadget
2026-07-14 4:44 ` [PATCH v8 0/5] history: add squash subcommand to fold a range Matt Hunter
2026-07-15 15:16 ` [PATCH v9 " Harald Nordgren via GitGitGadget
6 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-10 9:06 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
By default "git history squash" reuses the oldest commit's message.
When --reedit-message is given it only reopened that one message, so the
messages of the other commits in the range were lost.
Gather the message of every commit in the range and build the same editor
template that "git rebase -i --autosquash" shows for a squash, reusing
add_squash_combination_header(), add_squash_message_header() and
squash_subject_comment_len(). Feed the range through
todo_list_rearrange_squash() so that each fixup!, squash! or amend! is
grouped under the commit it targets rather than shown in commit order,
exactly as autosquash would arrange them.
Only the message text differs, the changes are always folded in. A fixup!
message is commented out in full under a "will be skipped" header, a
squash! keeps its body with only the marker subject commented, and an
amend! replaces its target's message unless a squash! already folded into
that target, in which case it behaves like a squash!.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-history.adoc | 15 ++-
builtin/history.c | 107 ++++++++++++++++-
t/t3455-history-squash.sh | 210 +++++++++++++++++++++++++++++++++
3 files changed, 328 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index ad921ac2be..697155f5d8 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -117,8 +117,9 @@ given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
already on `topic`. Rev-list options may also be given, but any that would
change how the range is walked are overridden with a warning.
+
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
+The oldest commit's message and authorship are preserved by default. With
+`--reedit-message`, an editor opens pre-filled with the messages of all the
+folded commits so you can combine them. A merge commit inside the range is
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.
@@ -127,7 +128,15 @@ 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. 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.
+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.
+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
+(commented out in full), a `squash!` keeps its body with only the marker
+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
diff --git a/builtin/history.c b/builtin/history.c
index d3f535ded9..f82001508d 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1211,6 +1211,102 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
return 0;
}
+static bool amend_replaces_target(struct todo_list *todo, int target)
+{
+ int i;
+
+ for (i = target + 1; i < todo->nr &&
+ todo->items[i].command != TODO_PICK; i++) {
+ if (todo->items[i].command == TODO_SQUASH)
+ return false;
+ if (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)
+ return true;
+ }
+ return false;
+}
+
+static int build_squash_message(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct strbuf *out)
+{
+ struct rev_info revs;
+ struct commit *commit;
+ struct strvec args = STRVEC_INIT;
+ struct todo_list todo = TODO_LIST_INIT;
+ struct replay_opts opts = REPLAY_OPTS_INIT;
+ int i, nr_commits, ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs)))
+ strbuf_addf(&todo.buf, "pick %s\n",
+ oid_to_hex(&commit->object.oid));
+
+ if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
+ todo_list_rearrange_squash(&todo) < 0) {
+ ret = error(_("could not prepare the squash message"));
+ goto out;
+ }
+
+ nr_commits = todo.nr;
+ for (i = 0; i < nr_commits; i++) {
+ struct todo_item *item = &todo.items[i];
+ const char *message, *body;
+ size_t commented_len;
+ bool skip, squashing;
+
+ squashing = item->command == TODO_SQUASH ||
+ (item->flags & TODO_REPLACE_FIXUP_MSG);
+ if (item->command == TODO_PICK)
+ skip = amend_replaces_target(&todo, i);
+ else
+ skip = !squashing;
+
+ message = repo_logmsg_reencode(repo, item->commit, NULL, NULL);
+ find_commit_subject(message, &body);
+
+ if (skip)
+ commented_len = strlen(body);
+ else if (squashing)
+ commented_len = squash_subject_comment_len(body, 1);
+ else
+ commented_len = 0;
+
+ if (!i)
+ add_squash_combination_header(out, nr_commits);
+ strbuf_addch(out, '\n');
+ add_squash_message_header(out, i + 1, skip);
+ strbuf_addstr(out, "\n\n");
+ strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
+ strbuf_addstr(out, body + commented_len);
+ strbuf_complete_line(out);
+
+ repo_unuse_commit_buffer(repo, item->commit, message);
+ }
+
+ ret = 0;
+
+out:
+ todo_list_release(&todo);
+ replay_opts_release(&opts);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
@@ -1235,6 +1331,7 @@ static int cmd_history_squash(int argc,
OPT_END(),
};
struct strbuf reflog_msg = STRBUF_INIT;
+ struct strbuf message = STRBUF_INIT;
struct oidset interior = OIDSET_INIT;
struct commit *base, *oldest, *tip, *rewritten, *msg_source;
const struct object_id *base_tree_oid, *tip_tree_oid;
@@ -1279,6 +1376,12 @@ static int cmd_history_squash(int argc,
}
}
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
+ }
+
ret = setup_revwalk(repo, action, tip, &revs);
if (ret < 0)
goto out;
@@ -1287,7 +1390,8 @@ static int cmd_history_squash(int argc,
tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
commit_list_append(base, &parents);
- ret = commit_tree_ext(repo, "squash", msg_source, NULL, parents,
+ ret = commit_tree_ext(repo, "squash", msg_source,
+ message.len ? message.buf : NULL, parents,
base_tree_oid, tip_tree_oid, &rewritten, flags);
if (ret < 0) {
ret = error(_("failed writing squashed commit"));
@@ -1308,6 +1412,7 @@ 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);
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 6bc0a89ebc..9b24f546e0 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -250,6 +250,216 @@ test_expect_success 'preserves authorship of the oldest commit' '
test_cmp expect actual
'
+test_expect_success '--reedit-message offers every folded-in message' '
+ git reset --hard start &&
+ echo b >file &&
+ git add file &&
+ git commit -m "re-one subject" -m "re-one body line" &&
+ test_commit --no-tag re-two file c &&
+ test_commit re-three file d &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited &&
+ echo combined >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 3 commits.
+ # This is the 1st commit message:
+
+ re-one subject
+
+ re-one body line
+
+ # This is the commit message #2:
+
+ re-two
+
+ # This is the commit message #3:
+
+ re-three
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ echo combined >expect &&
+ git log --format="%s" -1 >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
+ printf "fixup! mark-base\n\nfixup body\n" >msg &&
+ echo c >file &&
+ git add file &&
+ git commit -qF msg &&
+ printf "squash! mark-base\n\nsquash remark\n" >msg &&
+ echo d >file &&
+ git add file &&
+ git commit -qF msg &&
+ printf "amend! mark-base\n\namended message\n" >msg &&
+ echo e >file &&
+ git add file &&
+ git commit -qF msg &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 4 commits.
+ # This is the 1st commit message:
+
+ mark-base
+
+ # The commit message #2 will be skipped:
+
+ # fixup! mark-base
+ #
+ # fixup body
+
+ # This is the commit message #3:
+
+ # squash! mark-base
+
+ squash remark
+
+ # This is the commit message #4:
+
+ # amend! mark-base
+
+ amended message
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ git log -1 --format="%B" >final &&
+ test_grep ! "fixup body" final &&
+ test_grep "squash remark" final &&
+ test_grep "amended message" final
+'
+
+test_expect_success '--reedit-message groups fixups under their targets' '
+ git reset --hard start &&
+ test_commit --no-tag alpha file a1 &&
+ test_commit --no-tag beta file b1 &&
+ printf "fixup! alpha\n" >msg &&
+ echo a2 >file &&
+ git add file &&
+ git commit -qF msg &&
+ printf "fixup! beta\n" >msg &&
+ echo b2 >file &&
+ git add file &&
+ git commit -qF msg &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 4 commits.
+ # This is the 1st commit message:
+
+ alpha
+
+ # The commit message #2 will be skipped:
+
+ # fixup! alpha
+
+ # This is the commit message #3:
+
+ beta
+
+ # The commit message #4 will be skipped:
+
+ # fixup! beta
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited
+'
+
+test_expect_success '--reedit-message lets amend! replace its target message' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
+ printf "amend! mark-base\n\namended message\n" >msg &&
+ echo c >file &&
+ git add file &&
+ git commit -qF msg &&
+ printf "squash! mark-base\n\nsquash remark\n" >msg &&
+ echo d >file &&
+ git add file &&
+ git commit -qF msg &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 3 commits.
+ # The 1st commit message will be skipped:
+
+ # mark-base
+
+ # This is the commit message #2:
+
+ # amend! mark-base
+
+ amended message
+
+ # This is the commit message #3:
+
+ # squash! mark-base
+
+ squash remark
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ git log -1 --format="%B" >final &&
+ test_grep ! "mark-base" final &&
+ test_grep "amended message" final &&
+ test_grep "squash remark" final
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ write_script editor <<-\EOF &&
+ >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ test_must_fail git history squash --reedit-message start.. &&
+
+ test_cmp_rev "$head_before" HEAD
+'
+
test_expect_success '--update-refs=head only moves HEAD' '
git reset --hard three &&
git branch -f other HEAD &&
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* Re: [PATCH v8 0/5] history: add squash subcommand to fold a range
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-07-10 9:06 ` [PATCH v8 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
@ 2026-07-14 4:44 ` Matt Hunter
2026-07-14 8:38 ` Harald Nordgren
2026-07-14 12:36 ` Ben Knoble
2026-07-15 15:16 ` [PATCH v9 " Harald Nordgren via GitGitGadget
6 siblings, 2 replies; 115+ messages in thread
From: Matt Hunter @ 2026-07-14 4:44 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Harald Nordgren
On Fri Jul 10, 2026 at 5:06 AM EDT, Harald Nordgren via GitGitGadget wrote:
> Adds git history squash <revision-range> to fold a range of commits.
>
> 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.
This feature looks like it's coming together pretty well imo. I just have
one observation I want to comment on:
I noticed that 'git history squash <range>', when --reedit-message is
omitted, will ignore any amend! message in the range that targets the
first folded commit.
On the surface, this makes sense. The feature is pretty explicit that
it will faithfully stick with the first commit's message, unless
modified by use of --reedit-message.
However, this edge case is a little surprising, given that
'git history squash' seems to be aware of the semantics of fixup!, amend!,
and squash! messages whether --reedit-message was given or not. For instance,
the default command notices when the range contains a squash! commit whose
target is elsewhere (a useful feature). It seems consistent then, that the
default command would incorporate an amend! it is aware of when placing the
"first commit's" message in the resulting squash. This seems useful to me
as well.
At the same time, I can understand why the current implementation does
what it does. So I'm not entirely sure what the correct answer is here.
I'll mention as well that I really like the decisions made for how this
command handles squashing a bunch of related fixups. This "fixup
consolidation" is a use-case that this command may steal away from rebase
for me. And the way a final amend! is handled in this case is what got me
thinking about it in the general case.
Thanks for the work on this topic!
^ permalink raw reply [flat|nested] 115+ messages in thread* Re: [PATCH v8 0/5] history: add squash subcommand to fold a range
2026-07-14 4:44 ` [PATCH v8 0/5] history: add squash subcommand to fold a range Matt Hunter
@ 2026-07-14 8:38 ` Harald Nordgren
2026-07-14 9:04 ` Harald Nordgren
2026-07-14 12:36 ` Ben Knoble
1 sibling, 1 reply; 115+ messages in thread
From: Harald Nordgren @ 2026-07-14 8:38 UTC (permalink / raw)
To: Matt Hunter
Cc: Harald Nordgren via GitGitGadget, git, Phillip Wood,
D. Ben Knoble, Patrick Steinhardt
> This feature looks like it's coming together pretty well imo. I just have
> one observation I want to comment on:
>
> I noticed that 'git history squash <range>', when --reedit-message is
> omitted, will ignore any amend! message in the range that targets the
> first folded commit.
>
> On the surface, this makes sense. The feature is pretty explicit that
> it will faithfully stick with the first commit's message, unless
> modified by use of --reedit-message.
>
> However, this edge case is a little surprising, given that
> 'git history squash' seems to be aware of the semantics of fixup!, amend!,
> and squash! messages whether --reedit-message was given or not. For instance,
> the default command notices when the range contains a squash! commit whose
> target is elsewhere (a useful feature). It seems consistent then, that the
> default command would incorporate an amend! it is aware of when placing the
> "first commit's" message in the resulting squash. This seems useful to me
> as well.
>
> At the same time, I can understand why the current implementation does
> what it does. So I'm not entirely sure what the correct answer is here.
>
> I'll mention as well that I really like the decisions made for how this
> command handles squashing a bunch of related fixups. This "fixup
> consolidation" is a use-case that this command may steal away from rebase
> for me. And the way a final amend! is handled in this case is what got me
> thinking about it in the general case.
>
> Thanks for the work on this topic!
Thanks!
That's an interesting observation, I'll see what I can do about it.
Harald
^ permalink raw reply [flat|nested] 115+ messages in thread
* Re: [PATCH v8 0/5] history: add squash subcommand to fold a range
2026-07-14 8:38 ` Harald Nordgren
@ 2026-07-14 9:04 ` Harald Nordgren
0 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren @ 2026-07-14 9:04 UTC (permalink / raw)
To: Matt Hunter
Cc: Harald Nordgren via GitGitGadget, git, Phillip Wood,
D. Ben Knoble, Patrick Steinhardt
I made a fix for this and also took the opportunity to create test
helpers to clarify the tests.
Same pattern as the ones @Phillip Wood helped me with on my
'delete-merged' topic. I would like to push out a new version before
anyone needs to review the current tests -- the new version would be a
lot easier to look at. Should I?
Harald
^ permalink raw reply [flat|nested] 115+ messages in thread
* Re: [PATCH v8 0/5] history: add squash subcommand to fold a range
2026-07-14 4:44 ` [PATCH v8 0/5] history: add squash subcommand to fold a range Matt Hunter
2026-07-14 8:38 ` Harald Nordgren
@ 2026-07-14 12:36 ` Ben Knoble
2026-07-14 18:41 ` Junio C Hamano
1 sibling, 1 reply; 115+ messages in thread
From: Ben Knoble @ 2026-07-14 12:36 UTC (permalink / raw)
To: Matt Hunter
Cc: Harald Nordgren via GitGitGadget, git, Phillip Wood,
Patrick Steinhardt, Harald Nordgren
> Le 14 juil. 2026 à 00:45, Matt Hunter <m@lfurio.us> a écrit :
>
> On Fri Jul 10, 2026 at 5:06 AM EDT, Harald Nordgren via GitGitGadget wrote:
>> Adds git history squash <revision-range> to fold a range of commits.
[snip]
> I'll mention as well that I really like the decisions made for how this
> command handles squashing a bunch of related fixups. This "fixup
> consolidation" is a use-case that this command may steal away from rebase
> for me. And the way a final amend! is handled in this case is what got me
> thinking about it in the general case.
>
> Thanks for the work on this topic!
Ditto! 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.
I’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.
^ permalink raw reply [flat|nested] 115+ messages in thread
* Re: [PATCH v8 0/5] history: add squash subcommand to fold a range
2026-07-14 12:36 ` Ben Knoble
@ 2026-07-14 18:41 ` Junio C Hamano
0 siblings, 0 replies; 115+ messages in thread
From: Junio C Hamano @ 2026-07-14 18:41 UTC (permalink / raw)
To: Ben Knoble
Cc: Matt Hunter, Harald Nordgren via GitGitGadget, git, Phillip Wood,
Patrick Steinhardt, Harald Nordgren
Ben Knoble <ben.knoble@gmail.com> writes:
>> Thanks for the work on this topic!
>
> Ditto! 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.
Yes, that would be an ideal future, and these efforts move us in
that direction.
> I’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.
Since you assume "we can safely do so with feature compatibility"
above, once we are finished, there will, by definition, be no
such "special" commits that the combination cannot handle. By
the time that happens, we will have replaced the internals of
"rebase [-i]" with a new implementation that does not need to
touch the working tree.
That would indeed be an exciting future.
^ permalink raw reply [flat|nested] 115+ messages in thread
* [PATCH v9 0/5] history: add squash subcommand to fold a range
2026-07-10 9:06 ` [PATCH v8 " Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-07-14 4:44 ` [PATCH v8 0/5] history: add squash subcommand to fold a range Matt Hunter
@ 2026-07-15 15:16 ` Harald Nordgren via GitGitGadget
2026-07-15 15:16 ` [PATCH v9 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
` (5 more replies)
6 siblings, 6 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-15 15:16 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren
Adds git history squash <revision-range> to fold a range of commits.
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 | 57 ++-
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 550 ++++++++++++++++++++--
sequencer.c | 70 +--
sequencer.h | 30 ++
t/meson.build | 1 +
t/t3455-history-squash.sh | 766 +++++++++++++++++++++++++++++++
9 files changed, 1408 insertions(+), 72 deletions(-)
create mode 100755 t/t3455-history-squash.sh
base-commit: 55526a18268bbc1ddaf8a6b7850c33d984eac9e9
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v9
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v9
Pull-Request: https://github.com/git/git/pull/2337
Range-diff vs v8:
1: ba77752282 = 1: 352c818c29 history: extract helper for a commit's parent tree
2: 50f3572887 = 2: e06e49095b history: give commit_tree_ext a message template
3: 2d81a40a05 ! 3: ead974c317 history: add squash subcommand to fold a range
@@ Commit message
Add "git history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
- message and authorship and taking the tree of the newest commit, then
- replays the commits above the range on top. The squashed message comes
- from the oldest commit, or from an editor with --reedit-message. 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 check runs the range through
- todo_list_rearrange_squash(), which leaves such a marker as a plain pick.
- Markers whose target is in the range fold in as usual. As an exception, a
- range made up entirely of markers for one target is combined anyway,
- taking its message from the last amend! if there is one, so a batch of
- fixups for the same commit can be collapsed.
+ authorship and taking the tree of the newest commit, then replays the
+ commits above the range on top. The squashed message comes from the
+ oldest commit by default, or from the body of the last amend! commit
+ targeting it. An editor opens with the selected message when
+ --reedit-message is given. 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 check runs
+ the range through todo_list_rearrange_squash(), which leaves such a
+ marker as a plain pick. Markers whose target is in the range fold in as
+ usual. As an exception, a range made up entirely of markers for one
+ target is combined anyway, taking its message from the last amend! if
+ there is one, so a batch of fixups for the same commit can be collapsed.
The range is read like the arguments to "git rev-list", so several
revisions such as "HEAD~3..HEAD ^topic" may be given, and rev-list
@@ Documentation/git-history.adoc: linkgit:gitglossary[7].
+`squash <revision-range>`::
+ Fold all commits in _<revision-range>_ into the oldest commit of that
-+ range. The resulting commit keeps the oldest commit's message and
-+ authorship and takes the tree of the range's newest commit, so the
-+ whole range collapses into a single commit. Commits above the range
-+ are replayed on top of the result.
++ range. The resulting commit keeps the oldest commit's authorship and
++ takes the tree of the range's newest commit, so the whole range
++ collapses into a single commit. Commits above the range are replayed
++ on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
@@ Documentation/git-history.adoc: linkgit:gitglossary[7].
+already on `topic`. Rev-list options may also be given, but any that would
+change how the range is walked are overridden with a warning.
++
-+The oldest commit's message and authorship are preserved by default,
-+unless you specify `--reedit-message`. A merge commit inside the range is
-+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 oldest commit's message is preserved by default, except that an `amend!`
++commit targeting it replaces its message. Specify `--reedit-message` to edit
++the resulting message. A merge commit inside the range is 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.
++
+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. 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. 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.
++
+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
@@ builtin/history.c: out:
+ struct commit *base,
+ struct commit *tip,
+ struct commit *oldest,
-+ struct commit **msg_source)
++ struct commit **msg_source,
++ struct commit **amend_source)
+{
+ struct todo_list todo = TODO_LIST_INIT;
+ struct replay_opts opts = REPLAY_OPTS_INIT;
@@ builtin/history.c: out:
+ struct strvec args = STRVEC_INIT;
+ char *dangling_subject = NULL, *dangling_target = NULL;
+ bool mixed_target = false, all_fixups_one_target;
++ bool past_oldest_group = false;
+ int i, ret, nr_dangling = 0;
+
+ *msg_source = oldest;
++ *amend_source = NULL;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
@@ builtin/history.c: out:
+ char *subject;
+ size_t sublen;
+
-+ if (todo.items[i].command != TODO_PICK)
-+ continue;
+ message = repo_logmsg_reencode(repo, todo.items[i].commit,
+ NULL, NULL);
+ sublen = find_commit_subject(message, &subject_start);
++
++ if (todo.items[i].command != TODO_PICK) {
++ if (!past_oldest_group &&
++ starts_with(subject_start, "amend! "))
++ *amend_source = todo.items[i].commit;
++ repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
++ continue;
++ }
++ if (i)
++ past_oldest_group = true;
++
+ subject = xmemdupz(subject_start, sublen);
+ target = autosquash_target(subject);
+ if (target != subject) {
@@ builtin/history.c: out:
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
++ struct strbuf message = STRBUF_INIT;
+ struct oidset interior = OIDSET_INIT;
-+ struct commit *base, *oldest, *tip, *rewritten, *msg_source;
++ struct commit *base, *oldest, *tip, *rewritten, *msg_source,
++ *amend_source;
+ const struct object_id *base_tree_oid, *tip_tree_oid;
++ const char *message_template = NULL;
+ struct commit_list *parents = NULL;
+ struct rev_info revs = { 0 };
+ int ret;
@@ builtin/history.c: out:
+ if (ret < 0)
+ goto out;
+
-+ ret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source);
++ ret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,
++ &amend_source);
+ if (ret < 0)
+ goto out;
++ if (amend_source) {
++ const char *amend_message, *body;
++
++ amend_message = repo_logmsg_reencode(repo, amend_source,
++ NULL, NULL);
++ find_commit_subject(amend_message, &body);
++ body = skip_blank_lines(body + commit_subject_length(body));
++ strbuf_addstr(&message, body);
++ message_template = message.buf;
++ repo_unuse_commit_buffer(repo, amend_source, amend_message);
++ }
+
+ if (action == REF_ACTION_BRANCHES) {
+ struct interior_ref_cb cb = { .interior = &interior };
@@ builtin/history.c: out:
+ tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+ commit_list_append(base, &parents);
+
-+ ret = commit_tree_ext(repo, "squash", msg_source, NULL, parents,
++ ret = commit_tree_ext(repo, "squash", msg_source, message_template,
++ parents,
+ base_tree_oid, tip_tree_oid, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing squashed commit"));
@@ builtin/history.c: out:
+
+out:
+ strbuf_release(&reflog_msg);
++ strbuf_release(&message);
+ oidset_clear(&interior);
+ commit_list_free(parents);
+ release_revisions(&revs);
@@ t/t3455-history-squash.sh (new)
+
+. ./test-lib.sh
+
++stage_file () {
++ printf "%s\n" "$1" >file &&
++ git add file
++}
++
++commit_with_message () {
++ printf "%b" "$1" >msg &&
++ git commit --allow-empty -qF msg
++}
++
++check_commit_count () {
++ git rev-list --count "$1" >actual &&
++ echo "$2" >expect &&
++ test_cmp expect actual
++}
++
++check_log_subjects () {
++ git log --format="%s" "$1" >actual &&
++ cat >expect &&
++ test_cmp expect actual
++}
++
++check_log_messages () {
++ git log --format="%B" "$1" >actual &&
++ cat >expect &&
++ test_cmp expect actual
++}
++
+test_expect_success 'setup linear history touching two files' '
+ test_commit base file a &&
+ git tag start &&
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start..HEAD ^keep &&
+
-+ git log --format="%s" start..HEAD >actual &&
-+ cat >expect <<-\EOF &&
++ check_log_subjects start..HEAD <<-\EOF &&
+ two
+ one
+ EOF
-+ test_cmp expect actual &&
+ test_cmp_rev keep HEAD~1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start..off-history &&
+
-+ git rev-list --count start..off-history >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..off-history 1 &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D off-history
@@ t/t3455-history-squash.sh (new)
+ git history squash start.. &&
+
+ test "$predicted" = "$(git rev-parse HEAD)" &&
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..HEAD 1 &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
-+ git log --format="%s" -1 >subject &&
-+ echo one >expect &&
-+ test_cmp expect subject &&
++ check_log_subjects -1 <<-\EOF &&
++ one
++ EOF
+ git reflog >reflog &&
+ test_grep "squash: updating" reflog
+'
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start..@~1 &&
+
-+ git log --format="%s" start..HEAD >actual &&
-+ cat >expect <<-\EOF &&
++ check_log_subjects start..HEAD <<-\EOF &&
+ three
+ one
+ EOF
-+ test_cmp expect actual &&
+
+ test_cmp_rev start HEAD~2 &&
+ test "$final_tree" = "$(git rev-parse HEAD^{tree})"
@@ t/t3455-history-squash.sh (new)
+
+ git history squash "$root.." &&
+
-+ git rev-list --count "$root..HEAD" >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count "$root..HEAD" 1 &&
+ test_cmp_rev "$root" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
-+ git log --format="%s" -1 >actual &&
-+ echo target >expect &&
-+ test_cmp expect actual
++ check_commit_count start..HEAD 1 &&
++ check_log_subjects -1 <<-\EOF
++ target
++ EOF
+'
+
+test_expect_success 'refuses a below-range fixup! after an in-range commit' '
@@ t/t3455-history-squash.sh (new)
+
+test_expect_success 'combines a run of fixups for one commit below the range' '
+ git reset --hard start &&
-+ echo b >file && git add file && git commit -m "fixup! base" &&
-+ echo c >file && git add file && git commit -m "fixup! base" &&
++ stage_file b && git commit -m "fixup! base" &&
++ stage_file c && git commit -m "fixup! base" &&
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
-+ git log --format="%s" -1 >actual &&
-+ echo "fixup! base" >expect &&
-+ test_cmp expect actual
++ check_commit_count start..HEAD 1 &&
++ check_log_subjects -1 <<-\EOF
++ fixup! base
++ EOF
+'
+
+test_expect_success 'combining below-range fixups keeps the last amend! message' '
+ git reset --hard start &&
-+ echo b >file && git add file && git commit -m "fixup! base" &&
-+ printf "amend! base\n\namended body\n" >msg &&
-+ echo c >file && git add file && git commit -qF msg &&
++ stage_file b && git commit -m "fixup! base" &&
++ stage_file c &&
++ commit_with_message "amend! base\n\namended body\n" &&
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
-+ git log --format="%s" -1 >actual &&
-+ echo "amend! base" >expect &&
-+ test_cmp expect actual &&
-+ git log --format="%b" -1 >body &&
-+ test_grep "amended body" body
++ check_commit_count start..HEAD 1 &&
++ check_log_messages -1 <<-\EOF
++ amend! base
++
++ amended body
++
++ EOF
+'
+
+test_expect_success 'refuses fixups for two different commits below the range' '
+ git reset --hard start &&
-+ echo b >file && git add file && git commit -m "fixup! aaa" &&
-+ echo c >file && git add file && git commit -m "fixup! bbb" &&
++ stage_file b && git commit -m "fixup! aaa" &&
++ stage_file c && git commit -m "fixup! bbb" &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
@@ t/t3455-history-squash.sh (new)
+ test_cmp_rev "$head_before" HEAD
+'
+
-+test_expect_success 'keeps the oldest message for in-range squash! and amend!' '
++test_expect_success 'the last amend! for the oldest commit replaces its message' '
+ git reset --hard start &&
+ test_commit --no-tag marker-oldest file b &&
+ git commit --allow-empty -m "squash! marker-oldest" &&
-+ git commit --allow-empty -m "amend! marker-oldest" &&
-+ test_commit --no-tag marker-newest file c &&
++ commit_with_message "amend! marker-oldest\n\nearlier message\n" &&
++ commit_with_message \
++ "amend! marker-oldest\n\namended subject\n\namended body\n" &&
++ test_commit --no-tag marker-later file c &&
++ commit_with_message "amend! marker-later\n\nwrong message\n" &&
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
-+ git log --format="%s" -1 >actual &&
-+ echo marker-oldest >expect &&
-+ test_cmp expect actual
++ check_commit_count start..HEAD 1 &&
++ check_log_messages -1 <<-\EOF
++ amended subject
++
++ amended body
++
++ EOF
+'
+
+test_expect_success 'preserves authorship of the oldest commit' '
@@ t/t3455-history-squash.sh (new)
+
+ git history squash --update-refs=head start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..HEAD 1 &&
+ test_cmp_rev "$other_before" other
+'
+
@@ t/t3455-history-squash.sh (new)
+
+ git history squash --update-refs=head start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..HEAD 1 &&
+ test_cmp_rev "$mid_before" mid &&
+
+ git branch -D mid
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
-+ git log --format="%s" -1 >subject &&
-+ echo before-side >expect &&
-+ test_cmp expect subject &&
++ check_commit_count start..HEAD 1 &&
++ check_log_subjects -1 <<-\EOF &&
++ before-side
++ EOF
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file inner
+'
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..HEAD 1 &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file side
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..HEAD 1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file side
+'
@@ t/t3455-history-squash.sh (new)
+
+ git history squash "$base.." &&
+
-+ git rev-list --count "$base..HEAD" >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count "$base..HEAD" 1 &&
+ test_cmp_rev "$base" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..HEAD 1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file s1 &&
+ test_path_is_file s2
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..HEAD 1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file outer &&
+ test_path_is_file inner
@@ t/t3455-history-squash.sh (new)
+
+ git history squash start.. &&
+
-+ git rev-list --count start..HEAD >count &&
-+ echo 1 >expect &&
-+ test_cmp expect count &&
++ check_commit_count start..HEAD 1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file o1 &&
+ test_path_is_file o2
4: 0a735117ad = 4: 08915cee51 sequencer: share the squash message marker helpers and flags
5: baf7e6f0a6 ! 5: fb76afe31c history: re-edit a squash with every message
@@ Metadata
## Commit message ##
history: re-edit a squash with every message
- By default "git history squash" reuses the oldest commit's message.
- When --reedit-message is given it only reopened that one message, so the
+ By default "git history squash" reuses the oldest commit's message, or
+ the replacement body from an amend! commit targeting it. When
+ --reedit-message is given it only reopened that selected message, so the
messages of the other commits in the range were lost.
Gather the message of every commit in the range and build the same editor
@@ Commit message
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-history.adoc ##
-@@ Documentation/git-history.adoc: given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
- already on `topic`. Rev-list options may also be given, but any that would
+@@ Documentation/git-history.adoc: already on `topic`. Rev-list options may also be given, but any that would
change how the range is walked are overridden with a warning.
+
--The oldest commit's message and authorship are preserved by default,
--unless you specify `--reedit-message`. A merge commit inside the range is
-+The oldest commit's message and authorship are preserved by default. With
-+`--reedit-message`, an editor opens pre-filled with the messages of all the
-+folded commits so you can combine them. A merge commit inside the range is
- 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.
-@@ Documentation/git-history.adoc: A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it
+ The oldest commit's message is preserved by default, except that an `amend!`
+-commit targeting it replaces its message. Specify `--reedit-message` to edit
+-the resulting message. A merge commit inside the range is 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.
++commit targeting it replaces its message. With `--reedit-message`, an editor
++opens pre-filled with the messages of all the folded commits so you can
++combine them. A merge commit inside the range is 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.
+ +
+ 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. 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.
-+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. 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.
+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
@@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
-@@ builtin/history.c: static int cmd_history_squash(int argc,
- OPT_END(),
- };
- struct strbuf reflog_msg = STRBUF_INIT;
-+ struct strbuf message = STRBUF_INIT;
- struct oidset interior = OIDSET_INIT;
- struct commit *base, *oldest, *tip, *rewritten, *msg_source;
- const struct object_id *base_tree_oid, *tip_tree_oid;
@@ builtin/history.c: static int cmd_history_squash(int argc,
}
}
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
++ strbuf_reset(&message);
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
++ message_template = message.buf;
+ }
+
ret = setup_revwalk(repo, action, tip, &revs);
if (ret < 0)
goto out;
-@@ builtin/history.c: static int cmd_history_squash(int argc,
- tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
- commit_list_append(base, &parents);
-
-- ret = commit_tree_ext(repo, "squash", msg_source, NULL, parents,
-+ ret = commit_tree_ext(repo, "squash", msg_source,
-+ message.len ? message.buf : NULL, parents,
- base_tree_oid, tip_tree_oid, &rewritten, flags);
- if (ret < 0) {
- ret = error(_("failed writing squashed commit"));
-@@ 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);
## t/t3455-history-squash.sh ##
@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the oldest commit' '
@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
+test_expect_success '--reedit-message offers every folded-in message' '
+ git reset --hard start &&
-+ echo b >file &&
-+ git add file &&
++ stage_file b &&
+ git commit -m "re-one subject" -m "re-one body line" &&
+ test_commit --no-tag re-two file c &&
+ test_commit re-three file d &&
@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
+ #
+ EOF
+ test_cmp expect edited &&
-+ echo combined >expect &&
-+ git log --format="%s" -1 >actual &&
-+ test_cmp expect actual
++ check_log_subjects -1 <<-\EOF
++ combined
++ EOF
+'
+
+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
-+ printf "fixup! mark-base\n\nfixup body\n" >msg &&
-+ echo c >file &&
-+ git add file &&
-+ git commit -qF msg &&
-+ printf "squash! mark-base\n\nsquash remark\n" >msg &&
-+ echo d >file &&
-+ git add file &&
-+ git commit -qF msg &&
-+ printf "amend! mark-base\n\namended message\n" >msg &&
-+ echo e >file &&
-+ git add file &&
-+ git commit -qF msg &&
++ stage_file c &&
++ commit_with_message "fixup! mark-base\n\nfixup body\n" &&
++ stage_file d &&
++ commit_with_message "squash! mark-base\n\nsquash remark\n" &&
++ stage_file e &&
++ commit_with_message "amend! mark-base\n\namended message\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
+ #
+ EOF
+ test_cmp expect edited &&
-+ git log -1 --format="%B" >final &&
-+ test_grep ! "fixup body" final &&
-+ test_grep "squash remark" final &&
-+ test_grep "amended message" final
++ check_log_messages -1 <<-\EOF
++ mark-base
++
++ squash remark
++
++ amended message
++
++ EOF
+'
+
+test_expect_success '--reedit-message groups fixups under their targets' '
+ git reset --hard start &&
+ test_commit --no-tag alpha file a1 &&
+ test_commit --no-tag beta file b1 &&
-+ printf "fixup! alpha\n" >msg &&
-+ echo a2 >file &&
-+ git add file &&
-+ git commit -qF msg &&
-+ printf "fixup! beta\n" >msg &&
-+ echo b2 >file &&
-+ git add file &&
-+ git commit -qF msg &&
++ stage_file a2 &&
++ commit_with_message "fixup! alpha\n" &&
++ stage_file b2 &&
++ commit_with_message "fixup! beta\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
+test_expect_success '--reedit-message lets amend! replace its target message' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
-+ printf "amend! mark-base\n\namended message\n" >msg &&
-+ echo c >file &&
-+ git add file &&
-+ git commit -qF msg &&
-+ printf "squash! mark-base\n\nsquash remark\n" >msg &&
-+ echo d >file &&
-+ git add file &&
-+ git commit -qF msg &&
++ stage_file c &&
++ commit_with_message "amend! mark-base\n\namended message\n" &&
++ stage_file d &&
++ commit_with_message "squash! mark-base\n\nsquash remark\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
@@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
+ #
+ EOF
+ test_cmp expect edited &&
-+ git log -1 --format="%B" >final &&
-+ test_grep ! "mark-base" final &&
-+ test_grep "amended message" final &&
-+ test_grep "squash remark" final
++ check_log_messages -1 <<-\EOF
++ amended message
++
++ squash remark
++
++ EOF
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
--
gitgitgadget
^ permalink raw reply [flat|nested] 115+ messages in thread* [PATCH v9 1/5] history: extract helper for a commit's parent tree
2026-07-15 15:16 ` [PATCH v9 " Harald Nordgren via GitGitGadget
@ 2026-07-15 15:16 ` Harald Nordgren via GitGitGadget
2026-07-15 15:16 ` [PATCH v9 2/5] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
5 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-15 15:16 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Three places resolve the tree of a commit's first parent, falling back
to the empty tree for a root commit, each repeating the same parse and
oidcpy dance. Extract a first_parent_tree_oid() helper and route the
existing callers through it.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 58 +++++++++++++++++++++--------------------------
1 file changed, 26 insertions(+), 32 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index fd83de8265..9f516687fe 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -157,6 +157,25 @@ out:
return ret;
}
+static int first_parent_tree_oid(struct repository *repo,
+ struct commit *commit,
+ struct object_id *out)
+{
+ struct commit *parent = commit->parents ? commit->parents->item : NULL;
+
+ if (!parent) {
+ oidcpy(out, repo->hash_algo->empty_tree);
+ return 0;
+ }
+
+ if (repo_parse_commit(repo, parent))
+ return error(_("unable to parse parent commit %s"),
+ oid_to_hex(&parent->object.oid));
+
+ oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
+ return 0;
+}
+
static int commit_tree_with_edited_message(struct repository *repo,
const char *action,
struct commit *original,
@@ -164,21 +183,11 @@ static int commit_tree_with_edited_message(struct repository *repo,
{
struct object_id parent_tree_oid;
const struct object_id *tree_oid;
- struct commit *parent;
tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- parent = original->parents ? original->parents->item : NULL;
- if (parent) {
- if (repo_parse_commit(repo, parent)) {
- return error(_("unable to parse parent commit %s"),
- oid_to_hex(&parent->object.oid));
- }
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return commit_tree_ext(repo, action, original, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -444,18 +453,10 @@ static int commit_became_empty(struct repository *repo,
struct commit *original,
struct tree *result)
{
- struct commit *parent = original->parents ? original->parents->item : NULL;
struct object_id parent_tree_oid;
- if (parent) {
- if (repo_parse_commit(repo, parent))
- return error(_("unable to parse parent of %s"),
- oid_to_hex(&original->object.oid));
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return oideq(&result->object.oid, &parent_tree_oid);
}
@@ -799,16 +800,9 @@ static int split_commit(struct repository *repo,
struct tree *split_tree;
int ret;
- if (original->parents) {
- if (repo_parse_commit(repo, original->parents->item)) {
- ret = error(_("unable to parse parent commit %s"),
- oid_to_hex(&original->parents->item->object.oid));
- goto out;
- }
-
- parent_tree_oid = *get_commit_tree_oid(original->parents->item);
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
+ ret = -1;
+ goto out;
}
original_commit_tree_oid = get_commit_tree_oid(original);
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v9 2/5] history: give commit_tree_ext a message template
2026-07-15 15:16 ` [PATCH v9 " Harald Nordgren via GitGitGadget
2026-07-15 15:16 ` [PATCH v9 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
@ 2026-07-15 15:16 ` Harald Nordgren via GitGitGadget
2026-07-15 15:16 ` [PATCH v9 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
5 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-15 15:16 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
commit_tree_ext() reuses the message of the commit it is handed. A
caller that folds several commits together wants to seed the message
from more than that single commit, so add an optional message_template
parameter. When NULL, the behavior is unchanged.
Pass NULL from the existing fixup and split callers.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 9f516687fe..cbba25096f 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -101,6 +101,7 @@ enum commit_tree_flags {
static int commit_tree_ext(struct repository *repo,
const char *action,
struct commit *commit_with_message,
+ const char *message_template,
const struct commit_list *parents,
const struct object_id *old_tree,
const struct object_id *new_tree,
@@ -130,13 +131,16 @@ static int commit_tree_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
+ if (!message_template)
+ message_template = original_body;
+
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
+ message_template, action, &commit_message);
if (ret < 0)
goto out;
} else {
- strbuf_addstr(&commit_message, original_body);
+ strbuf_addstr(&commit_message, message_template);
}
original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -189,7 +193,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
return -1;
- return commit_tree_ext(repo, action, original, original->parents,
+ return commit_tree_ext(repo, action, original, NULL, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
@@ -644,7 +648,7 @@ static int cmd_history_fixup(int argc,
goto out;
if (!skip_commit) {
- ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
&original_tree->object.oid, &merge_result.tree->object.oid,
&rewritten, flags);
if (ret < 0) {
@@ -855,7 +859,7 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
@@ -872,7 +876,7 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v9 3/5] history: add squash subcommand to fold a range
2026-07-15 15:16 ` [PATCH v9 " Harald Nordgren via GitGitGadget
2026-07-15 15:16 ` [PATCH v9 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-07-15 15:16 ` [PATCH v9 2/5] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
@ 2026-07-15 15:16 ` Harald Nordgren via GitGitGadget
2026-07-18 8:52 ` Matt Hunter
2026-07-15 15:16 ` [PATCH v9 4/5] sequencer: share the squash message marker helpers and flags Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
5 siblings, 1 reply; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-15 15:16 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
Add "git history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
authorship and taking the tree of the newest commit, then replays the
commits above the range on top. The squashed message comes from the
oldest commit by default, or from the body of the last amend! commit
targeting it. An editor opens with the selected message when
--reedit-message is given. 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 check runs
the range through todo_list_rearrange_squash(), which leaves such a
marker as a plain pick. Markers whose target is in the range fold in as
usual. As an exception, a range made up entirely of markers for one
target is combined anyway, taking its message from the last amend! if
there is one, so a batch of fixups for the same commit can be collapsed.
The range is read like the arguments to "git rev-list", so several
revisions such as "HEAD~3..HEAD ^topic" may be given, and rev-list
options are accepted too. As "git replay" does, the walk options the fold
relies on are forced after setup_revisions() and a warning is printed if
an option changed them, so the first commit returned is the range's
oldest and its parent is the base regardless of what the user passed
(including after a "--"). A merge inside the range is folded when its
other parent is reachable from the base, otherwise the range has more
than one base and is rejected. By default the command also refuses when a
ref points at a commit that the fold would discard. Use --update-refs=head
to rewrite only the current branch instead.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/advice.adoc | 4 +
Documentation/git-history.adoc | 47 ++-
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 372 ++++++++++++++++++++
t/meson.build | 1 +
t/t3455-history-squash.sh | 565 +++++++++++++++++++++++++++++++
7 files changed, 988 insertions(+), 3 deletions(-)
create mode 100755 t/t3455-history-squash.sh
diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 81f80a9274..e2a3487778 100644
--- a/Documentation/config/advice.adoc
+++ b/Documentation/config/advice.adoc
@@ -59,6 +59,10 @@ 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.
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 2ba8121795..2c0d861303 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
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>...]
+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
DESCRIPTION
-----------
@@ -42,8 +43,11 @@ at once.
LIMITATIONS
-----------
-This command does not (yet) work with histories that contain merges. You
-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
+This command does not (yet) replay merge commits onto the rewritten
+history: if a commit that would be replayed is a merge, the operation is
+rejected, and you should use linkgit:git-rebase[1] with the
+`--rebase-merges` flag instead. The `squash` subcommand can still fold a
+merge that lies inside the range, as long as the range has a single base.
Furthermore, the command does not support operations that can result in merge
conflicts. This limitation is by design as history rewrites are not intended to
@@ -97,6 +101,42 @@ linkgit:gitglossary[7].
It is invalid to select either all or no hunks, as that would lead to
one of the commits becoming empty.
+`squash <revision-range>`::
+ Fold all commits in _<revision-range>_ into the oldest commit of that
+ range. The resulting commit keeps the oldest commit's authorship and
+ takes the tree of the range's newest commit, so the whole range
+ collapses into a single commit. Commits above the range are replayed
+ on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash HEAD~3..HEAD` folds the three most recent commits into
+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
+while leaving the two newest commits in place. Several revisions may be
+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
+already on `topic`. Rev-list options may also be given, but any that would
+change how the range is walked are overridden with a warning.
++
+The oldest commit's message is preserved by default, except that an `amend!`
+commit targeting it replaces its message. Specify `--reedit-message` to edit
+the resulting message. A merge commit inside the range is 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.
++
+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.
++
+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
+`--update-refs=head` to rewrite only the current branch and leave such
+refs pointing at the old commits.
+
OPTIONS
-------
@@ -107,7 +147,8 @@ OPTIONS
ref updates is generally safe.
`--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.
`--empty=(drop|keep|abort)`::
Control what happens when a commit becomes empty as a result of the
diff --git a/advice.c b/advice.c
index 63bf8b0c5f..401d047391 100644
--- a/advice.c
+++ b/advice.c
@@ -58,6 +58,7 @@ 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" },
diff --git a/advice.h b/advice.h
index 66f6cd6a77..3f0b4f0485 100644
--- a/advice.h
+++ b/advice.h
@@ -25,6 +25,7 @@ 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,
diff --git a/builtin/history.c b/builtin/history.c
index cbba25096f..edf98a21d3 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
#define USE_THE_REPOSITORY_VARIABLE
#include "builtin.h"
+#include "advice.h"
#include "cache-tree.h"
#include "commit.h"
#include "commit-reach.h"
@@ -30,6 +31,8 @@
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
+#define GIT_HISTORY_SQUASH_USAGE \
+ N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>")
static void change_data_free(void *util, const char *str UNUSED)
{
@@ -973,6 +976,373 @@ out:
return ret;
}
+/*
+ * Resolve a "<base>..<tip>" revision range into the base commit just outside
+ * the range (which becomes the parent of the squashed commit), the oldest
+ * commit contained in the range (whose message the squash reuses), and the
+ * range tip (whose tree becomes the result). A merge inside the range is fine,
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+ const char **argv,
+ struct commit **base_out,
+ struct commit **oldest_out,
+ struct commit **tip_out,
+ struct oidset *interior_out)
+{
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+ struct commit_list *boundaries = NULL, *b;
+ struct strvec args = STRVEC_INIT;
+ size_t i;
+ int ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ revs.reverse = 1;
+ revs.topo_order = 1;
+ revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
+ revs.simplify_history = 0;
+ revs.boundary = 1;
+
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--ancestry-path");
+ strvec_pushv(&args, argv);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (args.nr != 1) {
+ ret = error(_("unrecognized argument: %s"), args.v[1]);
+ goto out;
+ }
+
+ if (revs.reverse != 1 || revs.topo_order != 1 ||
+ revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||
+ revs.simplify_history != 0) {
+ 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;
+ }
+
+ /*
+ * A squash needs a base to reparent onto, so the range has to exclude
+ * something, as in "<base>..<tip>". A revision range with no such
+ * bottom commit 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(_("not a '<base>..<tip>' revision range"));
+ goto out;
+ }
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ /*
+ * Set boundary commits aside for the base check below, and put every
+ * in-range commit but the tip into the interior set. A ref pointing
+ * at an interior commit would dangle once the range is folded away.
+ */
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
+ commit_list_insert(commit, &boundaries);
+ continue;
+ }
+ if (!oldest)
+ oldest = commit;
+ if (tip)
+ oidset_insert(interior_out, &tip->object.oid);
+ tip = commit;
+ }
+
+ if (!oldest) {
+ ret = error(_("the revision range is empty"));
+ goto out;
+ } else if (oldest == tip) {
+ ret = error(_("the revision range holds a single commit; "
+ "nothing to squash"));
+ goto out;
+ } else if (!oldest->parents) {
+ BUG("an in-range commit must have a parent");
+ }
+ base = oldest->parents->item;
+
+ /*
+ * A boundary other than the base is an in-range commit reaching a
+ * commit outside the range, so the range has more than one base.
+ */
+ for (b = boundaries; b; b = b->next) {
+ if (b->item != base) {
+ ret = error(_("the revision range has more than one base; "
+ "cannot squash"));
+ goto out;
+ }
+ }
+
+ *base_out = base;
+ *oldest_out = oldest;
+ *tip_out = tip;
+ ret = 0;
+
+out:
+ commit_list_free(boundaries);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
+static const char *autosquash_target(const char *subject)
+{
+ const char *rest;
+
+ while (skip_prefix(subject, "fixup! ", &rest) ||
+ skip_prefix(subject, "squash! ", &rest) ||
+ skip_prefix(subject, "amend! ", &rest))
+ subject = rest;
+ return subject;
+}
+
+static int reject_dangling_fixups(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct commit *oldest,
+ struct commit **msg_source,
+ struct commit **amend_source)
+{
+ struct todo_list todo = TODO_LIST_INIT;
+ struct replay_opts opts = REPLAY_OPTS_INIT;
+ struct rev_info revs;
+ struct commit *commit, *last_amend = NULL;
+ struct strvec args = STRVEC_INIT;
+ char *dangling_subject = NULL, *dangling_target = NULL;
+ bool mixed_target = false, all_fixups_one_target;
+ bool past_oldest_group = false;
+ int i, ret, nr_dangling = 0;
+
+ *msg_source = oldest;
+ *amend_source = NULL;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+ while ((commit = get_revision(&revs)))
+ strbuf_addf(&todo.buf, "pick %s\n",
+ oid_to_hex(&commit->object.oid));
+
+ if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
+ todo_list_rearrange_squash(&todo) < 0) {
+ ret = error(_("could not check the range for fixups"));
+ goto out;
+ }
+
+ for (i = 0; i < todo.nr; i++) {
+ const char *message, *subject_start, *target;
+ char *subject;
+ size_t sublen;
+
+ message = repo_logmsg_reencode(repo, todo.items[i].commit,
+ NULL, NULL);
+ sublen = find_commit_subject(message, &subject_start);
+
+ if (todo.items[i].command != TODO_PICK) {
+ if (!past_oldest_group &&
+ starts_with(subject_start, "amend! "))
+ *amend_source = todo.items[i].commit;
+ repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
+ continue;
+ }
+ if (i)
+ past_oldest_group = true;
+
+ subject = xmemdupz(subject_start, sublen);
+ target = autosquash_target(subject);
+ if (target != subject) {
+ nr_dangling++;
+ if (!dangling_target) {
+ dangling_target = xstrdup(target);
+ dangling_subject = xstrdup(subject);
+ } else if (strcmp(dangling_target, target)) {
+ mixed_target = true;
+ }
+ if (starts_with(subject, "amend! "))
+ last_amend = todo.items[i].commit;
+ }
+ free(subject);
+ repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
+ }
+
+ all_fixups_one_target = nr_dangling == todo.nr && !mixed_target;
+ if (nr_dangling && !all_fixups_one_target) {
+ ret = error(_("cannot squash '%s': its target is not in the "
+ "range"), dangling_subject);
+ } else {
+ if (last_amend)
+ *msg_source = last_amend;
+ ret = 0;
+ }
+
+out:
+ free(dangling_subject);
+ free(dangling_target);
+ todo_list_release(&todo);
+ replay_opts_release(&opts);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ 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,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_SQUASH_USAGE,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct strbuf message = STRBUF_INIT;
+ struct oidset interior = OIDSET_INIT;
+ struct commit *base, *oldest, *tip, *rewritten, *msg_source,
+ *amend_source;
+ const struct object_id *base_tree_oid, *tip_tree_oid;
+ const char *message_template = NULL;
+ struct commit_list *parents = NULL;
+ struct rev_info revs = { 0 };
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage,
+ PARSE_OPT_KEEP_UNKNOWN_OPT);
+ if (!argc) {
+ ret = error(_("command expects a revision range"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
+ &interior);
+ if (ret < 0)
+ goto out;
+
+ ret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,
+ &amend_source);
+ if (ret < 0)
+ goto out;
+ if (amend_source) {
+ const char *amend_message, *body;
+
+ amend_message = repo_logmsg_reencode(repo, amend_source,
+ NULL, NULL);
+ find_commit_subject(amend_message, &body);
+ body = skip_blank_lines(body + commit_subject_length(body));
+ strbuf_addstr(&message, body);
+ message_template = message.buf;
+ repo_unuse_commit_buffer(repo, amend_source, amend_message);
+ }
+
+ 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;
+
+ base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+ tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+ commit_list_append(base, &parents);
+
+ ret = commit_tree_ext(repo, "squash", msg_source, message_template,
+ parents,
+ base_tree_oid, tip_tree_oid, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing squashed commit"));
+ goto out;
+ }
+
+ strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, tip, rewritten,
+ reflog_msg.buf, dry_run,
+ REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ strbuf_release(&reflog_msg);
+ strbuf_release(&message);
+ oidset_clear(&interior);
+ commit_list_free(parents);
+ release_revisions(&revs);
+ return ret;
+}
+
int cmd_history(int argc,
const char **argv,
const char *prefix,
@@ -982,6 +1352,7 @@ int cmd_history(int argc,
GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
+ GIT_HISTORY_SQUASH_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
@@ -989,6 +1360,7 @@ int cmd_history(int argc,
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
+ OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
OPT_END(),
};
diff --git a/t/meson.build b/t/meson.build
index 7c3c070426..459e251623 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -400,6 +400,7 @@ integration_tests = [
't3451-history-reword.sh',
't3452-history-split.sh',
't3453-history-fixup.sh',
+ 't3455-history-squash.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
new file mode 100755
index 0000000000..d6199b9644
--- /dev/null
+++ b/t/t3455-history-squash.sh
@@ -0,0 +1,565 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+stage_file () {
+ printf "%s\n" "$1" >file &&
+ git add file
+}
+
+commit_with_message () {
+ printf "%b" "$1" >msg &&
+ git commit --allow-empty -qF msg
+}
+
+check_commit_count () {
+ git rev-list --count "$1" >actual &&
+ echo "$2" >expect &&
+ test_cmp expect actual
+}
+
+check_log_subjects () {
+ git log --format="%s" "$1" >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+check_log_messages () {
+ git log --format="%B" "$1" >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+test_expect_success 'setup linear history touching two files' '
+ test_commit base file a &&
+ git tag start &&
+ test_commit --no-tag one other x &&
+ test_commit --no-tag two file c &&
+ test_commit three file d
+'
+
+test_expect_success 'errors on missing range argument' '
+ test_must_fail git history squash 2>err &&
+ test_grep "expects a revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+ test_must_fail git history squash HEAD..HEAD 2>err &&
+ test_grep "the revision range is empty" err
+'
+
+test_expect_success 'errors on a single revision that is not a range' '
+ test_must_fail git history squash HEAD 2>err &&
+ test_grep "not a .*range" err &&
+ test_must_fail git history squash HEAD~1 2>err &&
+ test_grep "not a .*range" err
+'
+
+test_expect_success 'errors on a range holding a single commit' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "HEAD^!" 2>err &&
+ test_grep "single commit; nothing to squash" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'accepts multiple revision arguments with an exclusion' '
+ git reset --hard three &&
+ git branch -f keep HEAD~2 &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..HEAD ^keep &&
+
+ check_log_subjects start..HEAD <<-\EOF &&
+ two
+ one
+ EOF
+ test_cmp_rev keep HEAD~1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+ git branch -D keep
+'
+
+test_expect_success 'squashes a branch the current branch is not on' '
+ git reset --hard three &&
+ main=$(git symbolic-ref --short HEAD) &&
+ head_before=$(git rev-parse HEAD) &&
+ git checkout -b off-history start &&
+ test_commit --no-tag off-one off a &&
+ test_commit --no-tag off-two off b &&
+ git checkout "$main" &&
+
+ git history squash start..off-history &&
+
+ check_commit_count start..off-history 1 &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D off-history
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --dry-run start.. >out &&
+ predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git history squash start.. &&
+
+ test "$predicted" = "$(git rev-parse HEAD)" &&
+ check_commit_count start..HEAD 1 &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ check_log_subjects -1 <<-\EOF &&
+ one
+ EOF
+ git reflog >reflog &&
+ test_grep "squash: updating" reflog
+'
+
+test_expect_success 'sanitizes rev-list walk options, before and after --' '
+ git reset --hard three &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --date-order start.. 2>err &&
+ test_grep "ignoring rev-list options" err &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+ git reset --hard three &&
+ git history squash -- --reverse start.. 2>err &&
+ test_grep "ignoring rev-list options" err &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+ git reset --hard three &&
+ final_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..@~1 &&
+
+ check_log_subjects start..HEAD <<-\EOF &&
+ three
+ one
+ EOF
+
+ test_cmp_rev start HEAD~2 &&
+ test "$final_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes when the base is the root commit' '
+ git reset --hard three &&
+ root=$(git rev-list --max-parents=0 HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash "$root.." &&
+
+ check_commit_count "$root..HEAD" 1 &&
+ test_cmp_rev "$root" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+
+test_expect_success 'folds fixups whose target is in the range' '
+ git reset --hard start &&
+ test_commit --no-tag target file b &&
+ git commit --allow-empty -m "fixup! target" &&
+ git commit --allow-empty -m "fixup! target" &&
+ test_commit --no-tag later file c &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_subjects -1 <<-\EOF
+ target
+ EOF
+'
+
+test_expect_success 'refuses a below-range fixup! after an in-range commit' '
+ git reset --hard start &&
+ test_commit --no-tag inside file b &&
+ test_commit --no-tag "fixup! outside" file c &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "target is not in the range" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'combines a run of fixups for one commit below the range' '
+ git reset --hard start &&
+ stage_file b && git commit -m "fixup! base" &&
+ stage_file c && git commit -m "fixup! base" &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_subjects -1 <<-\EOF
+ fixup! base
+ EOF
+'
+
+test_expect_success 'combining below-range fixups keeps the last amend! message' '
+ git reset --hard start &&
+ stage_file b && git commit -m "fixup! base" &&
+ stage_file c &&
+ commit_with_message "amend! base\n\namended body\n" &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_messages -1 <<-\EOF
+ amend! base
+
+ amended body
+
+ EOF
+'
+
+test_expect_success 'refuses fixups for two different commits below the range' '
+ git reset --hard start &&
+ stage_file b && git commit -m "fixup! aaa" &&
+ stage_file c && git commit -m "fixup! bbb" &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "target is not in the range" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'the last amend! for the oldest commit replaces its message' '
+ git reset --hard start &&
+ test_commit --no-tag marker-oldest file b &&
+ git commit --allow-empty -m "squash! marker-oldest" &&
+ commit_with_message "amend! marker-oldest\n\nearlier message\n" &&
+ commit_with_message \
+ "amend! marker-oldest\n\namended subject\n\namended body\n" &&
+ test_commit --no-tag marker-later file c &&
+ commit_with_message "amend! marker-later\n\nwrong message\n" &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_messages -1 <<-\EOF
+ amended subject
+
+ amended body
+
+ EOF
+'
+
+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 --no-tag oldest file b &&
+ test_commit newest file c &&
+
+ git history squash start.. &&
+
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Squasher <squash@example.com>" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--update-refs=head only moves HEAD' '
+ git reset --hard three &&
+ git branch -f other HEAD &&
+ other_before=$(git rev-parse other) &&
+
+ git history squash --update-refs=head start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ test_cmp_rev "$other_before" other
+'
+
+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 &&
+ head_before=$(git rev-parse HEAD) &&
+
+ 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 &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ 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~1 &&
+ mid_before=$(git rev-parse mid) &&
+
+ git history squash --update-refs=head start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ test_cmp_rev "$mid_before" mid &&
+
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag before-side file b &&
+ git checkout -b inner-side &&
+ test_commit --no-tag on-inner-side inner x &&
+ git checkout "$main" &&
+ test_commit --no-tag after-side file c &&
+ git merge --no-ff -m merge inner-side &&
+ git branch -D inner-side &&
+ test_commit --no-tag after-merge file d &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_subjects -1 <<-\EOF &&
+ before-side
+ EOF
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file inner
+'
+
+test_expect_success 'folds a merge of a branch that forked at the base' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b base-fork-side &&
+ test_commit --no-tag base-fork-side side x &&
+ git checkout "$main" &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file side
+'
+
+test_expect_success 'refuses a merge whose other parent is outside the range' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b outside-parent &&
+ test_commit --no-tag outside-parent outside x &&
+ git checkout "$main" &&
+ test_commit --no-tag outside-main file b &&
+ base=$(git rev-parse HEAD) &&
+ test_commit --no-tag outside-mid file c &&
+ git merge --no-ff -m "merge outside-parent" outside-parent &&
+ git branch -D outside-parent &&
+ merged=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range whose tip is a merge commit' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag tipmerge-base file b &&
+ git checkout -b tipmerge-side &&
+ test_commit --no-tag tipmerge-side side x &&
+ git checkout "$main" &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b basemerge-side &&
+ test_commit --no-tag basemerge-side side x &&
+ git checkout "$main" &&
+ 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.." &&
+
+ check_commit_count "$base..HEAD" 1 &&
+ test_cmp_rev "$base" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'folds a range with two interior merges' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ 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 "$main" &&
+ 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 "$main" &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ 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-one file b &&
+ test_commit --no-tag desc-two file c &&
+ 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 d &&
+ 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* Re: [PATCH v9 3/5] history: add squash subcommand to fold a range
2026-07-15 15:16 ` [PATCH v9 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-07-18 8:52 ` Matt Hunter
2026-07-18 9:28 ` Harald Nordgren
0 siblings, 1 reply; 115+ messages in thread
From: Matt Hunter @ 2026-07-18 8:52 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Harald Nordgren
Hi Harald,
The new functionality for amend! messages seems to be working well, so I
dug a little deeper and found the following...
On Wed Jul 15, 2026 at 11:16 AM EDT, Harald Nordgren via GitGitGadget wrote:
> ++
> +The range is given in the usual `<base>..<tip>` form, where _<base>_ is
> +the commit just below the oldest commit to squash. For example, `git
> +history squash HEAD~3..HEAD` folds the three most recent commits into
> +one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
> +while leaving the two newest commits in place. Several revisions may be
> +given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
> +already on `topic`. Rev-list options may also be given, but any that would
> +change how the range is walked are overridden with a warning.
> ++
> +The oldest commit's message is preserved by default, except that an `amend!`
> +commit targeting it replaces its message.
The new behavior from v9 is documented here, but...
> Specify `--reedit-message` to edit
> +the resulting message. A merge commit inside the range is 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.
> ++
> +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.
...a redundant explanation appears here too. Personally, I think this
paragraph flows better if the 'The body after an `amend!`...targets that
commit.' sentence were removed.
> +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
> +`--update-refs=branches` the command refuses. Rerun with
> +`--update-refs=head` to rewrite only the current branch and leave such
> +refs pointing at the old commits.
> +
> OPTIONS
> -------
>
> @@ -107,7 +147,8 @@ OPTIONS
> ref updates is generally safe.
>
> `--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.
At the moment of this patch, this is a false statement, though it is
made true by patch 5/5 pre-filling all messages.
> diff --git a/builtin/history.c b/builtin/history.c
> index cbba25096f..edf98a21d3 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> +
> + repo_init_revisions(repo, &revs, NULL);
> + revs.reverse = 1;
> + revs.topo_order = 1;
> + revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
> + revs.simplify_history = 0;
> + revs.boundary = 1;
> +
> + strvec_push(&args, "ignored");
> + strvec_push(&args, "--ancestry-path");
> + strvec_pushv(&args, argv);
> + setup_revisions_from_strvec(&args, &revs, NULL);
> + if (args.nr != 1) {
> + ret = error(_("unrecognized argument: %s"), args.v[1]);
> + goto out;
> + }
> +
> + if (revs.reverse != 1 || revs.topo_order != 1 ||
> + revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||
> + revs.simplify_history != 0) {
> + 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;
> + }
Should revs.boundary still == 1 be asserted here too?
> +
> + base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
> + tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
> + commit_list_append(base, &parents);
> +
> + ret = commit_tree_ext(repo, "squash", msg_source, message_template,
> + parents,
> + base_tree_oid, tip_tree_oid, &rewritten, flags);
> + if (ret < 0) {
> + ret = error(_("failed writing squashed commit"));
> + goto out;
> + }
> +
> + strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
With this format string, the reflog will miss cases like:
git history squash HEAD~5..HEAD ^origin/master
Only "squash: updating HEAD~5..HEAD" will be recorded in the log.
^ permalink raw reply [flat|nested] 115+ messages in thread
* [PATCH v9 4/5] sequencer: share the squash message marker helpers and flags
2026-07-15 15:16 ` [PATCH v9 " Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-07-15 15:16 ` [PATCH v9 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-07-15 15:16 ` Harald Nordgren via GitGitGadget
2026-07-15 15:16 ` [PATCH v9 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
2026-07-20 8:26 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
5 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-15 15:16 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
When "git rebase -i" squashes commits it builds an editor template with a
"This is a combination of N commits." banner, a "This is the 1st/Nth
commit message:" header above each kept message (or a "will be skipped"
header for a dropped one), and a commented-out subject for any fixup!,
squash! or amend! commit. The banner, the headers and the
subject-commenting all live in static helpers in sequencer.c wired to the
rebase state, so no other command can present a squash the same way.
Pull the three pieces out into add_squash_combination_header(),
add_squash_message_header() (which takes a flag for the "will be skipped"
variant) and squash_subject_comment_len(), and use them from
update_squash_messages() and append_squash_message(). Also move the
todo_item_flags enum to the header, so a caller reading the output of
todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)
from a plain fixup!. A later change reuses all of this to give "git
history squash --reedit-message" the same template.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
sequencer.c | 70 +++++++++++++++++++++++++++++------------------------
sequencer.h | 30 +++++++++++++++++++++++
2 files changed, 69 insertions(+), 31 deletions(-)
diff --git a/sequencer.c b/sequencer.c
index 0fe8fed6c3..2387afd9b5 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)
}
}
-enum todo_item_flags {
- TODO_EDIT_MERGE_MSG = (1 << 0),
- TODO_REPLACE_FIXUP_MSG = (1 << 1),
- TODO_EDIT_FIXUP_MSG = (1 << 2),
-};
-
static const char first_commit_msg_str[] = N_("This is the 1st commit message:");
static const char nth_commit_msg_fmt[] = N_("This is the commit message #%d:");
static const char skip_first_commit_msg_str[] = N_("The 1st commit message will be skipped:");
static const char skip_nth_commit_msg_fmt[] = N_("The commit message #%d will be skipped:");
static const char combined_commit_msg_fmt[] = N_("This is a combination of %d commits.");
+void add_squash_combination_header(struct strbuf *buf, int n)
+{
+ strbuf_addf(buf, "%s ", comment_line_str);
+ strbuf_addf(buf, _(combined_commit_msg_fmt), n);
+}
+
+void add_squash_message_header(struct strbuf *buf, int n, int skip)
+{
+ strbuf_addf(buf, "%s ", comment_line_str);
+ if (n == 1)
+ strbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :
+ _(first_commit_msg_str));
+ else
+ strbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :
+ _(nth_commit_msg_fmt), n);
+}
+
+size_t squash_subject_comment_len(const char *body, int squashing)
+{
+ if (starts_with(body, "amend!") ||
+ (squashing && (starts_with(body, "squash!") ||
+ starts_with(body, "fixup!"))))
+ return commit_subject_length(body);
+ return 0;
+}
+
static int is_fixup_flag(enum todo_command command, unsigned flag)
{
return command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||
@@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,
{
struct replay_ctx *ctx = opts->ctx;
const char *fixup_msg;
- size_t commented_len = 0, fixup_off;
- /*
- * amend is non-interactive and not normally used with fixup!
- * or squash! commits, so only comment out those subjects when
- * squashing commit messages.
- */
- if (starts_with(body, "amend!") ||
- ((command == TODO_SQUASH || seen_squash(ctx)) &&
- (starts_with(body, "squash!") || starts_with(body, "fixup!"))))
- commented_len = commit_subject_length(body);
+ size_t commented_len, fixup_off;
+
+ commented_len = squash_subject_comment_len(body,
+ command == TODO_SQUASH || seen_squash(ctx));
- strbuf_addf(buf, "\n%s ", comment_line_str);
- strbuf_addf(buf, _(nth_commit_msg_fmt),
- ++ctx->current_fixup_count + 1);
+ strbuf_addch(buf, '\n');
+ add_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);
strbuf_addstr(buf, "\n\n");
strbuf_add_commented_lines(buf, body, commented_len, comment_line_str);
/* buf->buf may be reallocated so store an offset into the buffer */
@@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r,
eol = !starts_with(buf.buf, comment_line_str) ?
buf.buf : strchrnul(buf.buf, '\n');
- strbuf_addf(&header, "%s ", comment_line_str);
- strbuf_addf(&header, _(combined_commit_msg_fmt),
- ctx->current_fixup_count + 2);
+ add_squash_combination_header(&header,
+ ctx->current_fixup_count + 2);
strbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);
strbuf_release(&header);
if (is_fixup_flag(command, flag) && !seen_squash(ctx))
@@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r,
repo_unuse_commit_buffer(r, head_commit, head_message);
return error(_("cannot write '%s'"), rebase_path_fixup_msg());
}
- strbuf_addf(&buf, "%s ", comment_line_str);
- strbuf_addf(&buf, _(combined_commit_msg_fmt), 2);
- strbuf_addf(&buf, "\n%s ", comment_line_str);
- strbuf_addstr(&buf, is_fixup_flag(command, flag) ?
- _(skip_first_commit_msg_str) :
- _(first_commit_msg_str));
+ add_squash_combination_header(&buf, 2);
+ strbuf_addch(&buf, '\n');
+ add_squash_message_header(&buf, 1, is_fixup_flag(command, flag));
strbuf_addstr(&buf, "\n\n");
if (is_fixup_flag(command, flag))
strbuf_add_commented_lines(&buf, body, strlen(body),
@@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r,
if (command == TODO_SQUASH || is_fixup_flag(command, flag)) {
res = append_squash_message(&buf, body, command, opts, flag);
} else if (command == TODO_FIXUP) {
- strbuf_addf(&buf, "\n%s ", comment_line_str);
- strbuf_addf(&buf, _(skip_nth_commit_msg_fmt),
- ++ctx->current_fixup_count + 1);
+ strbuf_addch(&buf, '\n');
+ add_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);
strbuf_addstr(&buf, "\n\n");
strbuf_add_commented_lines(&buf, body, strlen(body),
comment_line_str);
diff --git a/sequencer.h b/sequencer.h
index 64a9c7fb1b..b01f897020 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -119,6 +119,13 @@ enum todo_command {
TODO_COMMENT
};
+/* Bits for the "flags" member of struct todo_item */
+enum todo_item_flags {
+ TODO_EDIT_MERGE_MSG = (1 << 0),
+ TODO_REPLACE_FIXUP_MSG = (1 << 1),
+ TODO_EDIT_FIXUP_MSG = (1 << 2),
+};
+
struct todo_item {
enum todo_command command;
struct commit *commit;
@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);
*/
void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);
+/*
+ * Append the "This is a combination of N commits." banner that "git rebase
+ * -i" writes at the top of a squashed commit's message, commented out with
+ * the comment character.
+ */
+void add_squash_combination_header(struct strbuf *buf, int n);
+
+/*
+ * Append the header (1-based N) that "git rebase -i" writes above each message
+ * when squashing, commented out with the comment character. With SKIP it reads
+ * "The ... commit message will be skipped" for a message that is dropped (a
+ * fixup), otherwise "This is the ... commit message".
+ */
+void add_squash_message_header(struct strbuf *buf, int n, int skip);
+
+/*
+ * Return the length of the leading subject of BODY when it should be commented
+ * out in a squash message, or 0 otherwise. An "amend!" subject always
+ * qualifies; "squash!" and "fixup!" subjects only when SQUASHING, since a
+ * plain fixup chain keeps them.
+ */
+size_t squash_subject_comment_len(const char *body, int squashing);
+
void append_conflicts_hint(struct index_state *istate,
struct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);
enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v9 5/5] history: re-edit a squash with every message
2026-07-15 15:16 ` [PATCH v9 " Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-07-15 15:16 ` [PATCH v9 4/5] sequencer: share the squash message marker helpers and flags Harald Nordgren via GitGitGadget
@ 2026-07-15 15:16 ` Harald Nordgren via GitGitGadget
2026-07-18 8:52 ` Matt Hunter
2026-07-20 8:26 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
5 siblings, 1 reply; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-15 15:16 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
By default "git history squash" reuses the oldest commit's message, or
the replacement body from an amend! commit targeting it. When
--reedit-message is given it only reopened that selected message, so the
messages of the other commits in the range were lost.
Gather the message of every commit in the range and build the same editor
template that "git rebase -i --autosquash" shows for a squash, reusing
add_squash_combination_header(), add_squash_message_header() and
squash_subject_comment_len(). Feed the range through
todo_list_rearrange_squash() so that each fixup!, squash! or amend! is
grouped under the commit it targets rather than shown in commit order,
exactly as autosquash would arrange them.
Only the message text differs, the changes are always folded in. A fixup!
message is commented out in full under a "will be skipped" header, a
squash! keeps its body with only the marker subject commented, and an
amend! replaces its target's message unless a squash! already folded into
that target, in which case it behaves like a squash!.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-history.adoc | 20 +++-
builtin/history.c | 104 +++++++++++++++++
t/t3455-history-squash.sh | 201 +++++++++++++++++++++++++++++++++
3 files changed, 320 insertions(+), 5 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 2c0d861303..dc5580531f 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -118,11 +118,12 @@ already on `topic`. Rev-list options may also be given, but any that would
change how the range is walked are overridden with a warning.
+
The oldest commit's message is preserved by default, except that an `amend!`
-commit targeting it replaces its message. Specify `--reedit-message` to edit
-the resulting message. A merge commit inside the range is 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.
+commit targeting it replaces its message. With `--reedit-message`, an editor
+opens pre-filled with the messages of all the folded commits so you can
+combine them. A merge commit inside the range is 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.
+
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
@@ -130,6 +131,15 @@ 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.
+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
+(commented out in full), a `squash!` keeps its body with only the marker
+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
diff --git a/builtin/history.c b/builtin/history.c
index edf98a21d3..b1f84e8297 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1223,6 +1223,102 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
return 0;
}
+static bool amend_replaces_target(struct todo_list *todo, int target)
+{
+ int i;
+
+ for (i = target + 1; i < todo->nr &&
+ todo->items[i].command != TODO_PICK; i++) {
+ if (todo->items[i].command == TODO_SQUASH)
+ return false;
+ if (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)
+ return true;
+ }
+ return false;
+}
+
+static int build_squash_message(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct strbuf *out)
+{
+ struct rev_info revs;
+ struct commit *commit;
+ struct strvec args = STRVEC_INIT;
+ struct todo_list todo = TODO_LIST_INIT;
+ struct replay_opts opts = REPLAY_OPTS_INIT;
+ int i, nr_commits, ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs)))
+ strbuf_addf(&todo.buf, "pick %s\n",
+ oid_to_hex(&commit->object.oid));
+
+ if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
+ todo_list_rearrange_squash(&todo) < 0) {
+ ret = error(_("could not prepare the squash message"));
+ goto out;
+ }
+
+ nr_commits = todo.nr;
+ for (i = 0; i < nr_commits; i++) {
+ struct todo_item *item = &todo.items[i];
+ const char *message, *body;
+ size_t commented_len;
+ bool skip, squashing;
+
+ squashing = item->command == TODO_SQUASH ||
+ (item->flags & TODO_REPLACE_FIXUP_MSG);
+ if (item->command == TODO_PICK)
+ skip = amend_replaces_target(&todo, i);
+ else
+ skip = !squashing;
+
+ message = repo_logmsg_reencode(repo, item->commit, NULL, NULL);
+ find_commit_subject(message, &body);
+
+ if (skip)
+ commented_len = strlen(body);
+ else if (squashing)
+ commented_len = squash_subject_comment_len(body, 1);
+ else
+ commented_len = 0;
+
+ if (!i)
+ add_squash_combination_header(out, nr_commits);
+ strbuf_addch(out, '\n');
+ add_squash_message_header(out, i + 1, skip);
+ strbuf_addstr(out, "\n\n");
+ strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
+ strbuf_addstr(out, body + commented_len);
+ strbuf_complete_line(out);
+
+ repo_unuse_commit_buffer(repo, item->commit, message);
+ }
+
+ ret = 0;
+
+out:
+ todo_list_release(&todo);
+ replay_opts_release(&opts);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
@@ -1306,6 +1402,14 @@ static int cmd_history_squash(int argc,
}
}
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ strbuf_reset(&message);
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
+ message_template = message.buf;
+ }
+
ret = setup_revwalk(repo, action, tip, &revs);
if (ret < 0)
goto out;
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index d6199b9644..f2835f5379 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -267,6 +267,207 @@ test_expect_success 'preserves authorship of the oldest commit' '
test_cmp expect actual
'
+test_expect_success '--reedit-message offers every folded-in message' '
+ git reset --hard start &&
+ stage_file b &&
+ git commit -m "re-one subject" -m "re-one body line" &&
+ test_commit --no-tag re-two file c &&
+ test_commit re-three file d &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited &&
+ echo combined >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 3 commits.
+ # This is the 1st commit message:
+
+ re-one subject
+
+ re-one body line
+
+ # This is the commit message #2:
+
+ re-two
+
+ # This is the commit message #3:
+
+ re-three
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ check_log_subjects -1 <<-\EOF
+ combined
+ EOF
+'
+
+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
+ stage_file c &&
+ commit_with_message "fixup! mark-base\n\nfixup body\n" &&
+ stage_file d &&
+ commit_with_message "squash! mark-base\n\nsquash remark\n" &&
+ stage_file e &&
+ commit_with_message "amend! mark-base\n\namended message\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 4 commits.
+ # This is the 1st commit message:
+
+ mark-base
+
+ # The commit message #2 will be skipped:
+
+ # fixup! mark-base
+ #
+ # fixup body
+
+ # This is the commit message #3:
+
+ # squash! mark-base
+
+ squash remark
+
+ # This is the commit message #4:
+
+ # amend! mark-base
+
+ amended message
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ check_log_messages -1 <<-\EOF
+ mark-base
+
+ squash remark
+
+ amended message
+
+ EOF
+'
+
+test_expect_success '--reedit-message groups fixups under their targets' '
+ git reset --hard start &&
+ test_commit --no-tag alpha file a1 &&
+ test_commit --no-tag beta file b1 &&
+ stage_file a2 &&
+ commit_with_message "fixup! alpha\n" &&
+ stage_file b2 &&
+ commit_with_message "fixup! beta\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 4 commits.
+ # This is the 1st commit message:
+
+ alpha
+
+ # The commit message #2 will be skipped:
+
+ # fixup! alpha
+
+ # This is the commit message #3:
+
+ beta
+
+ # The commit message #4 will be skipped:
+
+ # fixup! beta
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited
+'
+
+test_expect_success '--reedit-message lets amend! replace its target message' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
+ stage_file c &&
+ commit_with_message "amend! mark-base\n\namended message\n" &&
+ stage_file d &&
+ commit_with_message "squash! mark-base\n\nsquash remark\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 3 commits.
+ # The 1st commit message will be skipped:
+
+ # mark-base
+
+ # This is the commit message #2:
+
+ # amend! mark-base
+
+ amended message
+
+ # This is the commit message #3:
+
+ # squash! mark-base
+
+ squash remark
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ check_log_messages -1 <<-\EOF
+ amended message
+
+ squash remark
+
+ EOF
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ write_script editor <<-\EOF &&
+ >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ test_must_fail git history squash --reedit-message start.. &&
+
+ test_cmp_rev "$head_before" HEAD
+'
+
test_expect_success '--update-refs=head only moves HEAD' '
git reset --hard three &&
git branch -f other HEAD &&
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* Re: [PATCH v9 5/5] history: re-edit a squash with every message
2026-07-15 15:16 ` [PATCH v9 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
@ 2026-07-18 8:52 ` Matt Hunter
2026-07-18 9:36 ` Harald Nordgren
0 siblings, 1 reply; 115+ messages in thread
From: Matt Hunter @ 2026-07-18 8:52 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Harald Nordgren
On Wed Jul 15, 2026 at 11:16 AM EDT, Harald Nordgren via GitGitGadget wrote:
> @@ -130,6 +131,15 @@ 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.
This sentence kept puzzling me when I re-read this part. That isn't to
say that it doesn't make sense on its own, or isn't correct. But in
this context, I wasn't sure why it was included.
For whatever reason, the diff from your v8 made it click, and I believe
you're trying to explain the previous sentence about the last `amend!`,
stating that all the other _effects_ of the other fixup!s are also kept
even though the message changes. Is that right?
If so, I might suggest removing this sentence too. At least to my
brain, it doesn't contribute to my understanding of the command, and
makes the paragraph feel like it changes subject in the middle.
> +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
> +(commented out in full), a `squash!` keeps its body with only the marker
> +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!`.
This bit that comes right after would possibly do better as its own
paragraph imo.
Thanks!
^ permalink raw reply [flat|nested] 115+ messages in thread
* [PATCH v10 0/5] history: add squash subcommand to fold a range
2026-07-15 15:16 ` [PATCH v9 " Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-07-15 15:16 ` [PATCH v9 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
@ 2026-07-20 8:26 ` Harald Nordgren via GitGitGadget
2026-07-20 8:27 ` [PATCH v10 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
` (5 more replies)
5 siblings, 6 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-20 8:26 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren
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
^ permalink raw reply [flat|nested] 115+ messages in thread* [PATCH v10 1/5] history: extract helper for a commit's parent tree
2026-07-20 8:26 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-07-20 8:27 ` Harald Nordgren via GitGitGadget
2026-07-20 8:27 ` [PATCH v10 2/5] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
5 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-20 8:27 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Three places resolve the tree of a commit's first parent, falling back
to the empty tree for a root commit, each repeating the same parse and
oidcpy dance. Extract a first_parent_tree_oid() helper and route the
existing callers through it.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 58 +++++++++++++++++++++--------------------------
1 file changed, 26 insertions(+), 32 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index d28c1f08bb..673744a55a 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -164,6 +164,25 @@ out:
return ret;
}
+static int first_parent_tree_oid(struct repository *repo,
+ struct commit *commit,
+ struct object_id *out)
+{
+ struct commit *parent = commit->parents ? commit->parents->item : NULL;
+
+ if (!parent) {
+ oidcpy(out, repo->hash_algo->empty_tree);
+ return 0;
+ }
+
+ if (repo_parse_commit(repo, parent))
+ return error(_("unable to parse parent commit %s"),
+ oid_to_hex(&parent->object.oid));
+
+ oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
+ return 0;
+}
+
static int commit_tree_with_edited_message(struct repository *repo,
const char *action,
struct commit *original,
@@ -171,21 +190,11 @@ static int commit_tree_with_edited_message(struct repository *repo,
{
struct object_id parent_tree_oid;
const struct object_id *tree_oid;
- struct commit *parent;
tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- parent = original->parents ? original->parents->item : NULL;
- if (parent) {
- if (repo_parse_commit(repo, parent)) {
- return error(_("unable to parse parent commit %s"),
- oid_to_hex(&parent->object.oid));
- }
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return commit_tree_ext(repo, action, original, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -475,18 +484,10 @@ static int commit_became_empty(struct repository *repo,
struct commit *original,
struct tree *result)
{
- struct commit *parent = original->parents ? original->parents->item : NULL;
struct object_id parent_tree_oid;
- if (parent) {
- if (repo_parse_commit(repo, parent))
- return error(_("unable to parse parent of %s"),
- oid_to_hex(&original->object.oid));
-
- parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
- }
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
+ return -1;
return oideq(&result->object.oid, &parent_tree_oid);
}
@@ -830,16 +831,9 @@ static int split_commit(struct repository *repo,
struct tree *split_tree;
int ret;
- if (original->parents) {
- if (repo_parse_commit(repo, original->parents->item)) {
- ret = error(_("unable to parse parent commit %s"),
- oid_to_hex(&original->parents->item->object.oid));
- goto out;
- }
-
- parent_tree_oid = *get_commit_tree_oid(original->parents->item);
- } else {
- oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
+ ret = -1;
+ goto out;
}
original_commit_tree_oid = get_commit_tree_oid(original);
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v10 2/5] history: give commit_tree_ext a message template
2026-07-20 8:26 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-07-20 8:27 ` [PATCH v10 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
@ 2026-07-20 8:27 ` Harald Nordgren via GitGitGadget
2026-07-20 8:27 ` [PATCH v10 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
5 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-20 8:27 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
commit_tree_ext() reuses the message of the commit it is handed. A
caller that folds several commits together wants to seed the message
from more than that single commit, so add an optional message_template
parameter. When NULL, the behavior is unchanged.
Pass NULL from the existing fixup and split callers.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/history.c | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 673744a55a..b592b98393 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -108,6 +108,7 @@ enum commit_tree_flags {
static int commit_tree_ext(struct repository *repo,
const char *action,
struct commit *commit_with_message,
+ const char *message_template,
const struct commit_list *parents,
const struct object_id *old_tree,
const struct object_id *new_tree,
@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
+ if (!message_template)
+ message_template = original_body;
+
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
+ message_template, action, &commit_message);
if (ret < 0)
goto out;
} else {
- strbuf_addstr(&commit_message, original_body);
+ strbuf_addstr(&commit_message, message_template);
}
original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
return -1;
- return commit_tree_ext(repo, action, original, original->parents,
+ return commit_tree_ext(repo, action, original, NULL, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
@@ -675,7 +679,7 @@ static int cmd_history_fixup(int argc,
goto out;
if (!skip_commit) {
- ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
&original_tree->object.oid, &merge_result.tree->object.oid,
&rewritten, flags);
if (ret < 0) {
@@ -886,7 +890,7 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
@@ -903,7 +907,7 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v10 3/5] history: add squash subcommand to fold a range
2026-07-20 8:26 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
2026-07-20 8:27 ` [PATCH v10 1/5] history: extract helper for a commit's parent tree Harald Nordgren via GitGitGadget
2026-07-20 8:27 ` [PATCH v10 2/5] history: give commit_tree_ext a message template Harald Nordgren via GitGitGadget
@ 2026-07-20 8:27 ` Harald Nordgren via GitGitGadget
2026-07-20 8:27 ` [PATCH v10 4/5] sequencer: share the squash message marker helpers and flags Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
5 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-20 8:27 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Folding a series of commits into one required either an interactive
rebase where each commit after the first was hand-edited to "fixup", or
a "git reset --soft" to the merge base followed by "git commit --amend".
Add "git history squash <revision-range>" to do this directly. It folds
every commit in the range into the oldest one, keeping that commit's
authorship and taking the tree of the newest commit, then replays the
commits above the range on top. The squashed message comes from the
oldest commit by default, or from the body of the last amend! commit
targeting it. An editor opens with the selected message when
--reedit-message is given. 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 check runs
the range through todo_list_rearrange_squash(), which leaves such a
marker as a plain pick. Markers whose target is in the range fold in as
usual. As an exception, a range made up entirely of markers for one
target is combined anyway, taking its message from the last amend! if
there is one, so a batch of fixups for the same commit can be collapsed.
The range is read like the arguments to "git rev-list", so several
revisions such as "HEAD~3..HEAD ^topic" may be given, and rev-list
options are accepted too. As "git replay" does, the walk options the fold
relies on are forced after setup_revisions() and a warning is printed if
an option changed them, so the first commit returned is the range's
oldest and its parent is the base regardless of what the user passed
(including after a "--"). A merge inside the range is folded when its
other parent is reachable from the base, otherwise the range has more
than one base and is rejected. By default the command also refuses when a
ref points at a commit that the fold would discard. Use --update-refs=head
to rewrite only the current branch instead.
Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/advice.adoc | 4 +
Documentation/git-history.adoc | 45 ++-
advice.c | 1 +
advice.h | 1 +
builtin/history.c | 374 ++++++++++++++++++++
t/meson.build | 1 +
t/t3455-history-squash.sh | 569 +++++++++++++++++++++++++++++++
7 files changed, 992 insertions(+), 3 deletions(-)
create mode 100755 t/t3455-history-squash.sh
diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 81f80a9274..e2a3487778 100644
--- a/Documentation/config/advice.adoc
+++ b/Documentation/config/advice.adoc
@@ -59,6 +59,10 @@ 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.
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 28b477cd37..e1e930f355 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -12,6 +12,7 @@ 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>...]
+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
DESCRIPTION
-----------
@@ -43,8 +44,11 @@ at once.
LIMITATIONS
-----------
-This command does not (yet) work with histories that contain merges. You
-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
+This command does not (yet) replay merge commits onto the rewritten
+history: if a commit that would be replayed is a merge, the operation is
+rejected, and you should use linkgit:git-rebase[1] with the
+`--rebase-merges` flag instead. The `squash` subcommand can still fold a
+merge that lies inside the range, as long as the range has a single base.
Furthermore, the command does not support operations that can result in merge
conflicts. This limitation is by design as history rewrites are not intended to
@@ -113,6 +117,41 @@ linkgit:gitglossary[7].
It is invalid to select either all or no hunks, as that would lead to
one of the commits becoming empty.
+`squash <revision-range>`::
+ Fold all commits in _<revision-range>_ into the oldest commit of that
+ range. The resulting commit keeps the oldest commit's authorship and
+ takes the tree of the range's newest commit, so the whole range
+ collapses into a single commit. Commits above the range are replayed
+ on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash HEAD~3..HEAD` folds the three most recent commits into
+one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
+while leaving the two newest commits in place. Several revisions may be
+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
+already on `topic`. Rev-list options may also be given, but any that would
+change how the range is walked are overridden with a warning.
++
+The oldest commit's message is preserved by default, except that an `amend!`
+commit targeting it replaces its message. Specify `--reedit-message` to edit
+the resulting message. A merge commit inside the range is 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.
++
+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. 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
+`--update-refs=branches` the command refuses. Rerun with
+`--update-refs=head` to rewrite only the current branch and leave such
+refs pointing at the old commits.
+
OPTIONS
-------
@@ -123,7 +162,7 @@ OPTIONS
ref updates is generally safe.
`--reedit-message`::
- Open an editor to modify the target commit's message.
+ 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
diff --git a/advice.c b/advice.c
index 63bf8b0c5f..401d047391 100644
--- a/advice.c
+++ b/advice.c
@@ -58,6 +58,7 @@ 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" },
diff --git a/advice.h b/advice.h
index 66f6cd6a77..3f0b4f0485 100644
--- a/advice.h
+++ b/advice.h
@@ -25,6 +25,7 @@ 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,
diff --git a/builtin/history.c b/builtin/history.c
index b592b98393..423c8beaaf 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
#define USE_THE_REPOSITORY_VARIABLE
#include "builtin.h"
+#include "advice.h"
#include "cache-tree.h"
#include "commit.h"
#include "commit-reach.h"
@@ -34,6 +35,8 @@
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
+#define GIT_HISTORY_SQUASH_USAGE \
+ N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>")
static void change_data_free(void *util, const char *str UNUSED)
{
@@ -1004,6 +1007,375 @@ out:
return ret;
}
+/*
+ * Resolve a "<base>..<tip>" revision range into the base commit just outside
+ * the range (which becomes the parent of the squashed commit), the oldest
+ * commit contained in the range (whose message the squash reuses), and the
+ * range tip (whose tree becomes the result). A merge inside the range is fine,
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+ const char **argv,
+ struct commit **base_out,
+ struct commit **oldest_out,
+ struct commit **tip_out,
+ struct oidset *interior_out)
+{
+ struct rev_info revs;
+ struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+ struct commit_list *boundaries = NULL, *b;
+ struct strvec args = STRVEC_INIT;
+ size_t i;
+ int ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ revs.reverse = 1;
+ revs.topo_order = 1;
+ revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
+ revs.simplify_history = 0;
+ revs.boundary = 1;
+
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--ancestry-path");
+ strvec_pushv(&args, argv);
+ setup_revisions_from_strvec(&args, &revs, NULL);
+ if (args.nr != 1) {
+ ret = error(_("unrecognized argument: %s"), args.v[1]);
+ goto out;
+ }
+
+ if (revs.reverse != 1 || revs.topo_order != 1 ||
+ revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||
+ 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;
+ }
+
+ /*
+ * A squash needs a base to reparent onto, so the range has to exclude
+ * something, as in "<base>..<tip>". A revision range with no such
+ * bottom commit 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(_("not a '<base>..<tip>' revision range"));
+ goto out;
+ }
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ /*
+ * Set boundary commits aside for the base check below, and put every
+ * in-range commit but the tip into the interior set. A ref pointing
+ * at an interior commit would dangle once the range is folded away.
+ */
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
+ commit_list_insert(commit, &boundaries);
+ continue;
+ }
+ if (!oldest)
+ oldest = commit;
+ if (tip)
+ oidset_insert(interior_out, &tip->object.oid);
+ tip = commit;
+ }
+
+ if (!oldest) {
+ ret = error(_("the revision range is empty"));
+ goto out;
+ } else if (oldest == tip) {
+ ret = error(_("the revision range holds a single commit; "
+ "nothing to squash"));
+ goto out;
+ } else if (!oldest->parents) {
+ BUG("an in-range commit must have a parent");
+ }
+ base = oldest->parents->item;
+
+ /*
+ * A boundary other than the base is an in-range commit reaching a
+ * commit outside the range, so the range has more than one base.
+ */
+ for (b = boundaries; b; b = b->next) {
+ if (b->item != base) {
+ ret = error(_("the revision range has more than one base; "
+ "cannot squash"));
+ goto out;
+ }
+ }
+
+ *base_out = base;
+ *oldest_out = oldest;
+ *tip_out = tip;
+ ret = 0;
+
+out:
+ commit_list_free(boundaries);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
+static const char *autosquash_target(const char *subject)
+{
+ const char *rest;
+
+ while (skip_prefix(subject, "fixup! ", &rest) ||
+ skip_prefix(subject, "squash! ", &rest) ||
+ skip_prefix(subject, "amend! ", &rest))
+ subject = rest;
+ return subject;
+}
+
+static int reject_dangling_fixups(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct commit *oldest,
+ struct commit **msg_source,
+ struct commit **amend_source)
+{
+ struct todo_list todo = TODO_LIST_INIT;
+ struct replay_opts opts = REPLAY_OPTS_INIT;
+ struct rev_info revs;
+ struct commit *commit, *last_amend = NULL;
+ struct strvec args = STRVEC_INIT;
+ char *dangling_subject = NULL, *dangling_target = NULL;
+ bool mixed_target = false, all_fixups_one_target;
+ bool past_oldest_group = false;
+ int i, ret, nr_dangling = 0;
+
+ *msg_source = oldest;
+ *amend_source = NULL;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+ while ((commit = get_revision(&revs)))
+ strbuf_addf(&todo.buf, "pick %s\n",
+ oid_to_hex(&commit->object.oid));
+
+ if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
+ todo_list_rearrange_squash(&todo) < 0) {
+ ret = error(_("could not check the range for fixups"));
+ goto out;
+ }
+
+ for (i = 0; i < todo.nr; i++) {
+ const char *message, *subject_start, *target;
+ char *subject;
+ size_t sublen;
+
+ message = repo_logmsg_reencode(repo, todo.items[i].commit,
+ NULL, NULL);
+ sublen = find_commit_subject(message, &subject_start);
+
+ if (todo.items[i].command != TODO_PICK) {
+ if (!past_oldest_group &&
+ starts_with(subject_start, "amend! "))
+ *amend_source = todo.items[i].commit;
+ repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
+ continue;
+ }
+ if (i)
+ past_oldest_group = true;
+
+ subject = xmemdupz(subject_start, sublen);
+ target = autosquash_target(subject);
+ if (target != subject) {
+ nr_dangling++;
+ if (!dangling_target) {
+ dangling_target = xstrdup(target);
+ dangling_subject = xstrdup(subject);
+ } else if (strcmp(dangling_target, target)) {
+ mixed_target = true;
+ }
+ if (starts_with(subject, "amend! "))
+ last_amend = todo.items[i].commit;
+ }
+ free(subject);
+ repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
+ }
+
+ all_fixups_one_target = nr_dangling == todo.nr && !mixed_target;
+ if (nr_dangling && !all_fixups_one_target) {
+ ret = error(_("cannot squash '%s': its target is not in the "
+ "range"), dangling_subject);
+ } else {
+ if (last_amend)
+ *msg_source = last_amend;
+ ret = 0;
+ }
+
+out:
+ free(dangling_subject);
+ free(dangling_target);
+ todo_list_release(&todo);
+ replay_opts_release(&opts);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ 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,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_SQUASH_USAGE,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct strbuf message = STRBUF_INIT;
+ struct oidset interior = OIDSET_INIT;
+ struct commit *base, *oldest, *tip, *rewritten, *msg_source,
+ *amend_source;
+ const struct object_id *base_tree_oid, *tip_tree_oid;
+ const char *message_template = NULL;
+ struct commit_list *parents = NULL;
+ struct rev_info revs = { 0 };
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage,
+ PARSE_OPT_KEEP_UNKNOWN_OPT);
+ if (!argc) {
+ ret = error(_("command expects a revision range"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
+ &interior);
+ if (ret < 0)
+ goto out;
+
+ ret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,
+ &amend_source);
+ if (ret < 0)
+ goto out;
+ if (amend_source) {
+ const char *amend_message, *body;
+
+ amend_message = repo_logmsg_reencode(repo, amend_source,
+ NULL, NULL);
+ find_commit_subject(amend_message, &body);
+ body = skip_blank_lines(body + commit_subject_length(body));
+ strbuf_addstr(&message, body);
+ message_template = message.buf;
+ repo_unuse_commit_buffer(repo, amend_source, amend_message);
+ }
+
+ 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;
+
+ base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+ tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+ commit_list_append(base, &parents);
+
+ ret = commit_tree_ext(repo, "squash", msg_source, message_template,
+ parents,
+ base_tree_oid, tip_tree_oid, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing squashed commit"));
+ goto out;
+ }
+
+ 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,
+ REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ strbuf_release(&reflog_msg);
+ strbuf_release(&message);
+ oidset_clear(&interior);
+ commit_list_free(parents);
+ release_revisions(&revs);
+ return ret;
+}
+
static int update_worktree(struct repository *repo,
const struct commit *old_head,
const struct commit *new_head,
@@ -1192,6 +1564,7 @@ int cmd_history(int argc,
GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
+ GIT_HISTORY_SQUASH_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
@@ -1200,6 +1573,7 @@ int cmd_history(int argc,
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
+ OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
OPT_END(),
};
diff --git a/t/meson.build b/t/meson.build
index 8ae6ab6c5f..89cff16405 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -405,6 +405,7 @@ integration_tests = [
't3452-history-split.sh',
't3453-history-fixup.sh',
't3454-history-drop.sh',
+ 't3455-history-squash.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
new file mode 100755
index 0000000000..9c362f3094
--- /dev/null
+++ b/t/t3455-history-squash.sh
@@ -0,0 +1,569 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+stage_file () {
+ printf "%s\n" "$1" >file &&
+ git add file
+}
+
+commit_with_message () {
+ printf "%b" "$1" >msg &&
+ git commit --allow-empty -qF msg
+}
+
+check_commit_count () {
+ git rev-list --count "$1" >actual &&
+ echo "$2" >expect &&
+ test_cmp expect actual
+}
+
+check_log_subjects () {
+ git log --format="%s" "$1" >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+check_log_messages () {
+ git log --format="%B" "$1" >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+test_expect_success 'setup linear history touching two files' '
+ test_commit base file a &&
+ git tag start &&
+ test_commit --no-tag one other x &&
+ test_commit --no-tag two file c &&
+ test_commit three file d
+'
+
+test_expect_success 'errors on missing range argument' '
+ test_must_fail git history squash 2>err &&
+ test_grep "expects a revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+ test_must_fail git history squash HEAD..HEAD 2>err &&
+ test_grep "the revision range is empty" err
+'
+
+test_expect_success 'errors on a single revision that is not a range' '
+ test_must_fail git history squash HEAD 2>err &&
+ test_grep "not a .*range" err &&
+ test_must_fail git history squash HEAD~1 2>err &&
+ test_grep "not a .*range" err
+'
+
+test_expect_success 'errors on a range holding a single commit' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "HEAD^!" 2>err &&
+ test_grep "single commit; nothing to squash" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'accepts multiple revision arguments with an exclusion' '
+ git reset --hard three &&
+ git branch -f keep HEAD~2 &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ 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
+ EOF
+ test_cmp_rev keep HEAD~1 &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+ git branch -D keep
+'
+
+test_expect_success 'squashes a branch the current branch is not on' '
+ git reset --hard three &&
+ main=$(git symbolic-ref --short HEAD) &&
+ head_before=$(git rev-parse HEAD) &&
+ git checkout -b off-history start &&
+ test_commit --no-tag off-one off a &&
+ test_commit --no-tag off-two off b &&
+ git checkout "$main" &&
+
+ git history squash start..off-history &&
+
+ check_commit_count start..off-history 1 &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git branch -D off-history
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --dry-run start.. >out &&
+ predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ git history squash start.. &&
+
+ test "$predicted" = "$(git rev-parse HEAD)" &&
+ check_commit_count start..HEAD 1 &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ check_log_subjects -1 <<-\EOF &&
+ one
+ EOF
+ git reflog >reflog &&
+ test_grep "squash: updating" reflog
+'
+
+test_expect_success 'sanitizes rev-list walk options, before and after --' '
+ git reset --hard three &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash --date-order start.. 2>err &&
+ test_grep "ignoring rev-list options" err &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+ git reset --hard three &&
+ git history squash -- --reverse start.. 2>err &&
+ test_grep "ignoring rev-list options" err &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+ git reset --hard three &&
+ final_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start..@~1 &&
+
+ check_log_subjects start..HEAD <<-\EOF &&
+ three
+ one
+ EOF
+
+ test_cmp_rev start HEAD~2 &&
+ test "$final_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes when the base is the root commit' '
+ git reset --hard three &&
+ root=$(git rev-list --max-parents=0 HEAD) &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash "$root.." &&
+
+ check_commit_count "$root..HEAD" 1 &&
+ test_cmp_rev "$root" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+
+test_expect_success 'folds fixups whose target is in the range' '
+ git reset --hard start &&
+ test_commit --no-tag target file b &&
+ git commit --allow-empty -m "fixup! target" &&
+ git commit --allow-empty -m "fixup! target" &&
+ test_commit --no-tag later file c &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_subjects -1 <<-\EOF
+ target
+ EOF
+'
+
+test_expect_success 'refuses a below-range fixup! after an in-range commit' '
+ git reset --hard start &&
+ test_commit --no-tag inside file b &&
+ test_commit --no-tag "fixup! outside" file c &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "target is not in the range" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'combines a run of fixups for one commit below the range' '
+ git reset --hard start &&
+ stage_file b && git commit -m "fixup! base" &&
+ stage_file c && git commit -m "fixup! base" &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_subjects -1 <<-\EOF
+ fixup! base
+ EOF
+'
+
+test_expect_success 'combining below-range fixups keeps the last amend! message' '
+ git reset --hard start &&
+ stage_file b && git commit -m "fixup! base" &&
+ stage_file c &&
+ commit_with_message "amend! base\n\namended body\n" &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_messages -1 <<-\EOF
+ amend! base
+
+ amended body
+
+ EOF
+'
+
+test_expect_success 'refuses fixups for two different commits below the range' '
+ git reset --hard start &&
+ stage_file b && git commit -m "fixup! aaa" &&
+ stage_file c && git commit -m "fixup! bbb" &&
+ head_before=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash start.. 2>err &&
+ test_grep "target is not in the range" err &&
+ test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'the last amend! for the oldest commit replaces its message' '
+ git reset --hard start &&
+ test_commit --no-tag marker-oldest file b &&
+ git commit --allow-empty -m "squash! marker-oldest" &&
+ commit_with_message "amend! marker-oldest\n\nearlier message\n" &&
+ commit_with_message \
+ "amend! marker-oldest\n\namended subject\n\namended body\n" &&
+ test_commit --no-tag marker-later file c &&
+ commit_with_message "amend! marker-later\n\nwrong message\n" &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_messages -1 <<-\EOF
+ amended subject
+
+ amended body
+
+ EOF
+'
+
+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 --no-tag oldest file b &&
+ test_commit newest file c &&
+
+ git history squash start.. &&
+
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Squasher <squash@example.com>" >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--update-refs=head only moves HEAD' '
+ git reset --hard three &&
+ git branch -f other HEAD &&
+ other_before=$(git rev-parse other) &&
+
+ git history squash --update-refs=head start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ test_cmp_rev "$other_before" other
+'
+
+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 &&
+ head_before=$(git rev-parse HEAD) &&
+
+ 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 &&
+ test_cmp_rev "$head_before" HEAD &&
+
+ 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~1 &&
+ mid_before=$(git rev-parse mid) &&
+
+ git history squash --update-refs=head start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ test_cmp_rev "$mid_before" mid &&
+
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag before-side file b &&
+ git checkout -b inner-side &&
+ test_commit --no-tag on-inner-side inner x &&
+ git checkout "$main" &&
+ test_commit --no-tag after-side file c &&
+ git merge --no-ff -m merge inner-side &&
+ git branch -D inner-side &&
+ test_commit --no-tag after-merge file d &&
+ tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+ git history squash start.. &&
+
+ check_commit_count start..HEAD 1 &&
+ check_log_subjects -1 <<-\EOF &&
+ before-side
+ EOF
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file inner
+'
+
+test_expect_success 'folds a merge of a branch that forked at the base' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b base-fork-side &&
+ test_commit --no-tag base-fork-side side x &&
+ git checkout "$main" &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ test_cmp_rev start HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+ test_path_is_file side
+'
+
+test_expect_success 'refuses a merge whose other parent is outside the range' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b outside-parent &&
+ test_commit --no-tag outside-parent outside x &&
+ git checkout "$main" &&
+ test_commit --no-tag outside-main file b &&
+ base=$(git rev-parse HEAD) &&
+ test_commit --no-tag outside-mid file c &&
+ git merge --no-ff -m "merge outside-parent" outside-parent &&
+ git branch -D outside-parent &&
+ merged=$(git rev-parse HEAD) &&
+
+ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "more than one base" err &&
+ test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range whose tip is a merge commit' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ test_commit --no-tag tipmerge-base file b &&
+ git checkout -b tipmerge-side &&
+ test_commit --no-tag tipmerge-side side x &&
+ git checkout "$main" &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ 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 &&
+ main=$(git symbolic-ref --short HEAD) &&
+ git checkout -b basemerge-side &&
+ test_commit --no-tag basemerge-side side x &&
+ git checkout "$main" &&
+ 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.." &&
+
+ check_commit_count "$base..HEAD" 1 &&
+ test_cmp_rev "$base" HEAD^ &&
+ test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'folds a range with two interior merges' '
+ git reset --hard start &&
+ main=$(git symbolic-ref --short HEAD) &&
+ 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 "$main" &&
+ 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 "$main" &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ 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.. &&
+
+ check_commit_count start..HEAD 1 &&
+ 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-one file b &&
+ test_commit --no-tag desc-two file c &&
+ 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 d &&
+ 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v10 4/5] sequencer: share the squash message marker helpers and flags
2026-07-20 8:26 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-07-20 8:27 ` [PATCH v10 3/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
@ 2026-07-20 8:27 ` Harald Nordgren via GitGitGadget
2026-07-20 8:27 ` [PATCH v10 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
2026-07-21 1:33 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Matt Hunter
5 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-20 8:27 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
When "git rebase -i" squashes commits it builds an editor template with a
"This is a combination of N commits." banner, a "This is the 1st/Nth
commit message:" header above each kept message (or a "will be skipped"
header for a dropped one), and a commented-out subject for any fixup!,
squash! or amend! commit. The banner, the headers and the
subject-commenting all live in static helpers in sequencer.c wired to the
rebase state, so no other command can present a squash the same way.
Pull the three pieces out into add_squash_combination_header(),
add_squash_message_header() (which takes a flag for the "will be skipped"
variant) and squash_subject_comment_len(), and use them from
update_squash_messages() and append_squash_message(). Also move the
todo_item_flags enum to the header, so a caller reading the output of
todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG)
from a plain fixup!. A later change reuses all of this to give "git
history squash --reedit-message" the same template.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
sequencer.c | 70 +++++++++++++++++++++++++++++------------------------
sequencer.h | 30 +++++++++++++++++++++++
2 files changed, 69 insertions(+), 31 deletions(-)
diff --git a/sequencer.c b/sequencer.c
index 1355a99a09..3c704fd5ab 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command)
}
}
-enum todo_item_flags {
- TODO_EDIT_MERGE_MSG = (1 << 0),
- TODO_REPLACE_FIXUP_MSG = (1 << 1),
- TODO_EDIT_FIXUP_MSG = (1 << 2),
-};
-
static const char first_commit_msg_str[] = N_("This is the 1st commit message:");
static const char nth_commit_msg_fmt[] = N_("This is the commit message #%d:");
static const char skip_first_commit_msg_str[] = N_("The 1st commit message will be skipped:");
static const char skip_nth_commit_msg_fmt[] = N_("The commit message #%d will be skipped:");
static const char combined_commit_msg_fmt[] = N_("This is a combination of %d commits.");
+void add_squash_combination_header(struct strbuf *buf, int n)
+{
+ strbuf_addf(buf, "%s ", comment_line_str);
+ strbuf_addf(buf, _(combined_commit_msg_fmt), n);
+}
+
+void add_squash_message_header(struct strbuf *buf, int n, int skip)
+{
+ strbuf_addf(buf, "%s ", comment_line_str);
+ if (n == 1)
+ strbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) :
+ _(first_commit_msg_str));
+ else
+ strbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) :
+ _(nth_commit_msg_fmt), n);
+}
+
+size_t squash_subject_comment_len(const char *body, int squashing)
+{
+ if (starts_with(body, "amend!") ||
+ (squashing && (starts_with(body, "squash!") ||
+ starts_with(body, "fixup!"))))
+ return commit_subject_length(body);
+ return 0;
+}
+
static int is_fixup_flag(enum todo_command command, unsigned flag)
{
return command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) ||
@@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body,
{
struct replay_ctx *ctx = opts->ctx;
const char *fixup_msg;
- size_t commented_len = 0, fixup_off;
- /*
- * amend is non-interactive and not normally used with fixup!
- * or squash! commits, so only comment out those subjects when
- * squashing commit messages.
- */
- if (starts_with(body, "amend!") ||
- ((command == TODO_SQUASH || seen_squash(ctx)) &&
- (starts_with(body, "squash!") || starts_with(body, "fixup!"))))
- commented_len = commit_subject_length(body);
+ size_t commented_len, fixup_off;
+
+ commented_len = squash_subject_comment_len(body,
+ command == TODO_SQUASH || seen_squash(ctx));
- strbuf_addf(buf, "\n%s ", comment_line_str);
- strbuf_addf(buf, _(nth_commit_msg_fmt),
- ++ctx->current_fixup_count + 1);
+ strbuf_addch(buf, '\n');
+ add_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0);
strbuf_addstr(buf, "\n\n");
strbuf_add_commented_lines(buf, body, commented_len, comment_line_str);
/* buf->buf may be reallocated so store an offset into the buffer */
@@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r,
eol = !starts_with(buf.buf, comment_line_str) ?
buf.buf : strchrnul(buf.buf, '\n');
- strbuf_addf(&header, "%s ", comment_line_str);
- strbuf_addf(&header, _(combined_commit_msg_fmt),
- ctx->current_fixup_count + 2);
+ add_squash_combination_header(&header,
+ ctx->current_fixup_count + 2);
strbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len);
strbuf_release(&header);
if (is_fixup_flag(command, flag) && !seen_squash(ctx))
@@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r,
repo_unuse_commit_buffer(r, head_commit, head_message);
return error(_("cannot write '%s'"), rebase_path_fixup_msg());
}
- strbuf_addf(&buf, "%s ", comment_line_str);
- strbuf_addf(&buf, _(combined_commit_msg_fmt), 2);
- strbuf_addf(&buf, "\n%s ", comment_line_str);
- strbuf_addstr(&buf, is_fixup_flag(command, flag) ?
- _(skip_first_commit_msg_str) :
- _(first_commit_msg_str));
+ add_squash_combination_header(&buf, 2);
+ strbuf_addch(&buf, '\n');
+ add_squash_message_header(&buf, 1, is_fixup_flag(command, flag));
strbuf_addstr(&buf, "\n\n");
if (is_fixup_flag(command, flag))
strbuf_add_commented_lines(&buf, body, strlen(body),
@@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r,
if (command == TODO_SQUASH || is_fixup_flag(command, flag)) {
res = append_squash_message(&buf, body, command, opts, flag);
} else if (command == TODO_FIXUP) {
- strbuf_addf(&buf, "\n%s ", comment_line_str);
- strbuf_addf(&buf, _(skip_nth_commit_msg_fmt),
- ++ctx->current_fixup_count + 1);
+ strbuf_addch(&buf, '\n');
+ add_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);
strbuf_addstr(&buf, "\n\n");
strbuf_add_commented_lines(&buf, body, strlen(body),
comment_line_str);
diff --git a/sequencer.h b/sequencer.h
index 64a9c7fb1b..b01f897020 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -119,6 +119,13 @@ enum todo_command {
TODO_COMMENT
};
+/* Bits for the "flags" member of struct todo_item */
+enum todo_item_flags {
+ TODO_EDIT_MERGE_MSG = (1 << 0),
+ TODO_REPLACE_FIXUP_MSG = (1 << 1),
+ TODO_EDIT_FIXUP_MSG = (1 << 2),
+};
+
struct todo_item {
enum todo_command command;
struct commit *commit;
@@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list);
*/
void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag);
+/*
+ * Append the "This is a combination of N commits." banner that "git rebase
+ * -i" writes at the top of a squashed commit's message, commented out with
+ * the comment character.
+ */
+void add_squash_combination_header(struct strbuf *buf, int n);
+
+/*
+ * Append the header (1-based N) that "git rebase -i" writes above each message
+ * when squashing, commented out with the comment character. With SKIP it reads
+ * "The ... commit message will be skipped" for a message that is dropped (a
+ * fixup), otherwise "This is the ... commit message".
+ */
+void add_squash_message_header(struct strbuf *buf, int n, int skip);
+
+/*
+ * Return the length of the leading subject of BODY when it should be commented
+ * out in a squash message, or 0 otherwise. An "amend!" subject always
+ * qualifies; "squash!" and "fixup!" subjects only when SQUASHING, since a
+ * plain fixup chain keeps them.
+ */
+size_t squash_subject_comment_len(const char *body, int squashing);
+
void append_conflicts_hint(struct index_state *istate,
struct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode);
enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg,
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* [PATCH v10 5/5] history: re-edit a squash with every message
2026-07-20 8:26 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-07-20 8:27 ` [PATCH v10 4/5] sequencer: share the squash message marker helpers and flags Harald Nordgren via GitGitGadget
@ 2026-07-20 8:27 ` Harald Nordgren via GitGitGadget
2026-07-21 1:33 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Matt Hunter
5 siblings, 0 replies; 115+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-07-20 8:27 UTC (permalink / raw)
To: git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Matt Hunter,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
By default "git history squash" reuses the oldest commit's message, or
the replacement body from an amend! commit targeting it. When
--reedit-message is given it only reopened that selected message, so the
messages of the other commits in the range were lost.
Gather the message of every commit in the range and build the same editor
template that "git rebase -i --autosquash" shows for a squash, reusing
add_squash_combination_header(), add_squash_message_header() and
squash_subject_comment_len(). Feed the range through
todo_list_rearrange_squash() so that each fixup!, squash! or amend! is
grouped under the commit it targets rather than shown in commit order,
exactly as autosquash would arrange them.
Only the message text differs, the changes are always folded in. A fixup!
message is commented out in full under a "will be skipped" header, a
squash! keeps its body with only the marker subject commented, and an
amend! replaces its target's message unless a squash! already folded into
that target, in which case it behaves like a squash!.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-history.adoc | 22 +++-
builtin/history.c | 104 +++++++++++++++++
t/t3455-history-squash.sh | 201 +++++++++++++++++++++++++++++++++
3 files changed, 321 insertions(+), 6 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index e1e930f355..6f3b031d2a 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -134,11 +134,12 @@ already on `topic`. Rev-list options may also be given, but any that would
change how the range is walked are overridden with a warning.
+
The oldest commit's message is preserved by default, except that an `amend!`
-commit targeting it replaces its message. Specify `--reedit-message` to edit
-the resulting message. A merge commit inside the range is 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.
+commit targeting it replaces its message. With `--reedit-message`, an editor
+opens pre-filled with the messages of all the folded commits so you can
+combine them. A merge commit inside the range is 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.
+
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
@@ -146,6 +147,14 @@ 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
+(commented out in full), a `squash!` keeps its body with only the marker
+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
@@ -162,7 +171,8 @@ 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
diff --git a/builtin/history.c b/builtin/history.c
index 423c8beaaf..2542ea33a0 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1255,6 +1255,102 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
return 0;
}
+static bool amend_replaces_target(struct todo_list *todo, int target)
+{
+ int i;
+
+ for (i = target + 1; i < todo->nr &&
+ todo->items[i].command != TODO_PICK; i++) {
+ if (todo->items[i].command == TODO_SQUASH)
+ return false;
+ if (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)
+ return true;
+ }
+ return false;
+}
+
+static int build_squash_message(struct repository *repo,
+ struct commit *base,
+ struct commit *tip,
+ struct strbuf *out)
+{
+ struct rev_info revs;
+ struct commit *commit;
+ struct strvec args = STRVEC_INIT;
+ struct todo_list todo = TODO_LIST_INIT;
+ struct replay_opts opts = REPLAY_OPTS_INIT;
+ int i, nr_commits, ret;
+
+ repo_init_revisions(repo, &revs, NULL);
+ strvec_push(&args, "ignored");
+ strvec_push(&args, "--reverse");
+ strvec_push(&args, "--topo-order");
+ strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
+ oid_to_hex(&tip->object.oid));
+ setup_revisions_from_strvec(&args, &revs, NULL);
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+
+ while ((commit = get_revision(&revs)))
+ strbuf_addf(&todo.buf, "pick %s\n",
+ oid_to_hex(&commit->object.oid));
+
+ if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
+ todo_list_rearrange_squash(&todo) < 0) {
+ ret = error(_("could not prepare the squash message"));
+ goto out;
+ }
+
+ nr_commits = todo.nr;
+ for (i = 0; i < nr_commits; i++) {
+ struct todo_item *item = &todo.items[i];
+ const char *message, *body;
+ size_t commented_len;
+ bool skip, squashing;
+
+ squashing = item->command == TODO_SQUASH ||
+ (item->flags & TODO_REPLACE_FIXUP_MSG);
+ if (item->command == TODO_PICK)
+ skip = amend_replaces_target(&todo, i);
+ else
+ skip = !squashing;
+
+ message = repo_logmsg_reencode(repo, item->commit, NULL, NULL);
+ find_commit_subject(message, &body);
+
+ if (skip)
+ commented_len = strlen(body);
+ else if (squashing)
+ commented_len = squash_subject_comment_len(body, 1);
+ else
+ commented_len = 0;
+
+ if (!i)
+ add_squash_combination_header(out, nr_commits);
+ strbuf_addch(out, '\n');
+ add_squash_message_header(out, i + 1, skip);
+ strbuf_addstr(out, "\n\n");
+ strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
+ strbuf_addstr(out, body + commented_len);
+ strbuf_complete_line(out);
+
+ repo_unuse_commit_buffer(repo, item->commit, message);
+ }
+
+ ret = 0;
+
+out:
+ todo_list_release(&todo);
+ replay_opts_release(&opts);
+ reset_revision_walk();
+ release_revisions(&revs);
+ strvec_clear(&args);
+ return ret;
+}
+
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
@@ -1338,6 +1434,14 @@ static int cmd_history_squash(int argc,
}
}
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ strbuf_reset(&message);
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
+ message_template = message.buf;
+ }
+
ret = setup_revwalk(repo, action, tip, &revs);
if (ret < 0)
goto out;
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 9c362f3094..6fce924327 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -271,6 +271,207 @@ test_expect_success 'preserves authorship of the oldest commit' '
test_cmp expect actual
'
+test_expect_success '--reedit-message offers every folded-in message' '
+ git reset --hard start &&
+ stage_file b &&
+ git commit -m "re-one subject" -m "re-one body line" &&
+ test_commit --no-tag re-two file c &&
+ test_commit re-three file d &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited &&
+ echo combined >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 3 commits.
+ # This is the 1st commit message:
+
+ re-one subject
+
+ re-one body line
+
+ # This is the commit message #2:
+
+ re-two
+
+ # This is the commit message #3:
+
+ re-three
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ check_log_subjects -1 <<-\EOF
+ combined
+ EOF
+'
+
+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
+ stage_file c &&
+ commit_with_message "fixup! mark-base\n\nfixup body\n" &&
+ stage_file d &&
+ commit_with_message "squash! mark-base\n\nsquash remark\n" &&
+ stage_file e &&
+ commit_with_message "amend! mark-base\n\namended message\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 4 commits.
+ # This is the 1st commit message:
+
+ mark-base
+
+ # The commit message #2 will be skipped:
+
+ # fixup! mark-base
+ #
+ # fixup body
+
+ # This is the commit message #3:
+
+ # squash! mark-base
+
+ squash remark
+
+ # This is the commit message #4:
+
+ # amend! mark-base
+
+ amended message
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ check_log_messages -1 <<-\EOF
+ mark-base
+
+ squash remark
+
+ amended message
+
+ EOF
+'
+
+test_expect_success '--reedit-message groups fixups under their targets' '
+ git reset --hard start &&
+ test_commit --no-tag alpha file a1 &&
+ test_commit --no-tag beta file b1 &&
+ stage_file a2 &&
+ commit_with_message "fixup! alpha\n" &&
+ stage_file b2 &&
+ commit_with_message "fixup! beta\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 4 commits.
+ # This is the 1st commit message:
+
+ alpha
+
+ # The commit message #2 will be skipped:
+
+ # fixup! alpha
+
+ # This is the commit message #3:
+
+ beta
+
+ # The commit message #4 will be skipped:
+
+ # fixup! beta
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited
+'
+
+test_expect_success '--reedit-message lets amend! replace its target message' '
+ git reset --hard start &&
+ test_commit --no-tag mark-base file b &&
+ stage_file c &&
+ commit_with_message "amend! mark-base\n\namended message\n" &&
+ stage_file d &&
+ commit_with_message "squash! mark-base\n\nsquash remark\n" &&
+
+ write_script editor <<-\EOF &&
+ cat "$1" >edited
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ git history squash --reedit-message start.. &&
+
+ cat >expect <<-EOF &&
+ # This is a combination of 3 commits.
+ # The 1st commit message will be skipped:
+
+ # mark-base
+
+ # This is the commit message #2:
+
+ # amend! mark-base
+
+ amended message
+
+ # This is the commit message #3:
+
+ # squash! mark-base
+
+ squash remark
+
+ # Please enter the commit message for the squash changes. Lines starting
+ # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+ # Changes to be committed:
+ # modified: file
+ #
+ EOF
+ test_cmp expect edited &&
+ check_log_messages -1 <<-\EOF
+ amended message
+
+ squash remark
+
+ EOF
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+ git reset --hard three &&
+ head_before=$(git rev-parse HEAD) &&
+
+ write_script editor <<-\EOF &&
+ >"$1"
+ EOF
+ test_set_editor "$(pwd)/editor" &&
+ test_must_fail git history squash --reedit-message start.. &&
+
+ test_cmp_rev "$head_before" HEAD
+'
+
test_expect_success '--update-refs=head only moves HEAD' '
git reset --hard three &&
git branch -f other HEAD &&
--
gitgitgadget
^ permalink raw reply related [flat|nested] 115+ messages in thread* Re: [PATCH v10 0/5] history: add squash subcommand to fold a range
2026-07-20 8:26 ` [PATCH v10 0/5] history: add squash subcommand to fold a range Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-07-20 8:27 ` [PATCH v10 5/5] history: re-edit a squash with every message Harald Nordgren via GitGitGadget
@ 2026-07-21 1:33 ` Matt Hunter
5 siblings, 0 replies; 115+ messages in thread
From: Matt Hunter @ 2026-07-21 1:33 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Phillip Wood, D. Ben Knoble, Patrick Steinhardt, Harald Nordgren
On Mon Jul 20, 2026 at 4:26 AM EDT, Harald Nordgren via GitGitGadget wrote:
> 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.
>
v10 looks good to me!
Thanks!
^ permalink raw reply [flat|nested] 115+ messages in thread