[PATCH v10 3.7/3.7] fixup! history: add squash subcommand to fold a range

Phillip Wood <[email protected]> Mon, 3 Aug 2026 10:49:27 +0100
Newsgroups org.kernel.vger.git
Message-ID <2ccc83c776ed77a80cc5e0584367540d231311de.1785750108.git.phillip.wood@dunelm.org.uk>
From: Phillip Wood <[email protected]>

Fix squashing into a merge

If the first commit of the range is a merge then the squashed commit
should also be a merge. Unfortunately, we assume that the first
commit in the range only has a single parent, but do not error out
if that's not the case. This means that fixing up a merge drops the
second and later parents. Correct this by using the list of parents
from the first commit in the range when creating the squashed commit.

Signed-off-by: Phillip Wood <[email protected]>
---
 builtin/history.c         | 20 +++++++-------------
 t/t3455-history-squash.sh | 26 ++++++++++++++++++++++++++
 2 files changed, 33 insertions(+), 13 deletions(-)

diff --git a/builtin/history.c b/builtin/history.c
index 894da69f31..e21ef31e7e 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1276,14 +1276,13 @@ static int squash_check_subject(struct repository *repo,
 static int resolve_squash_range(struct repository *repo,
 				bool update_branches,
 				int argc, const char **argv,
-				struct commit **base_out,
 				struct commit **oldest_out,
 				struct commit **tip_out,
 				char **message_out)
 {
 	struct rev_info revs;
 	struct subject_data subject_data = SUBJECT_DATA_INIT;
-	struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+	struct commit *commit, *oldest = NULL, *tip = NULL;
 	size_t i;
 	int ret, tip_count = 0;
 	struct ref_filter filter = REF_FILTER_INIT;
@@ -1426,9 +1425,6 @@ static int resolve_squash_range(struct repository *repo,
 				    "untouched."));
 		goto out;
 	}
-	base = oldest->parents->item;
-
-	*base_out = base;
 	*oldest_out = oldest;
 	*tip_out = tip;
 	*message_out = strbuf_detach(&subject_data.squash_message, NULL);
@@ -1466,10 +1462,9 @@ static int cmd_history_squash(int argc,
 		OPT_END(),
 	};
 	struct strbuf reflog_msg = STRBUF_INIT;
-	struct commit *base, *oldest, *tip, *rewritten;
+	struct commit *oldest, *tip, *rewritten;
 	const struct object_id *base_tree_oid, *tip_tree_oid;
 	char *message_template = NULL;
-	struct commit_list *parents = NULL;
 	struct rev_info revs = { 0 };
 	int ret;
 
@@ -1488,7 +1483,7 @@ static int cmd_history_squash(int argc,
 	strbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');
 
 	ret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,
-				   argc, argv, &base, &oldest, &tip,
+				   argc, argv, &oldest, &tip,
 				   &message_template);
 	if (ret < 0)
 		goto out;
@@ -1497,13 +1492,13 @@ static int cmd_history_squash(int argc,
 	if (ret < 0)
 		goto out;
 
-	base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+	base_tree_oid = &repo_get_commit_tree(repo,
+					oldest->parents->item)->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, message_template,
-			      parents,
-			      base_tree_oid, tip_tree_oid, &rewritten, flags);
+			      oldest->parents, base_tree_oid, tip_tree_oid,
+			      &rewritten, flags);
 	if (ret < 0) {
 		ret = error(_("failed writing squashed commit"));
 		goto out;
@@ -1521,7 +1516,6 @@ static int cmd_history_squash(int argc,
 
 out:
 	strbuf_release(&reflog_msg);
-	commit_list_free(parents);
 	release_revisions(&revs);
 	free(message_template);
 	return ret;
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 26c4b4e29e..1713d6dcc3 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -347,6 +347,32 @@ test_expect_success 'squash commit uses last "amend!" message' '
 	# amend! message that targets commit that is not in range is rejected
 	test_must_fail git history squash HEAD~3.. 2>err &&
 	test_grep "^error: cannot squash .* target is not being squashed" err
+'
+
+test_expect_success 'squashing fixups into a merge' '
+	test_when_finished \
+		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
+		 git branch -D feature" &&
+	git checkout -f start &&
+	test_commit F1 &&
+	git checkout -b feature start &&
+	test_commit F2 &&
+	git merge F1 &&
+	echo fixed >F1.t &&
+	cat >msg <<-EOF &&
+	amend! $(git rev-parse HEAD)
+
+	merge F1 and F2
+
+	reworded
+	EOF
+
+	git commit -a -F msg &&
+	git history squash HEAD^^! HEAD &&
+	test_cmp_rev HEAD^1 F2 &&
+	test_cmp_rev HEAD^2 F1 &&
+	test_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&
+	sed 1,2d msg | test_commit_message HEAD
 '
 
 test_expect_success '--update-refs=head only moves HEAD' '
-- 
2.54.0.200.gfd8d68259e3