git/list[1] front-page[2] threads[3] people[4] search[5] about
 

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

From
PWPhillip Wood <phillip.wood123@gmail.com>
Date
Aug 3, 2026, 09:49 UTC
Message-ID
<84abbf386548600fafd33352480466362e73747a.1785750108.git.phillip.wood@dunelm.org.uk>
In-Reply-To
<cover.1785750108.git.phillip.wood@dunelm.org.uk>
From: Phillip Wood <phillip.wood@dunelm.org.uk>
Do not discard "squash!" or "amend!" messages

"squash!" and "amend!" commit messages are a strong indication that the user intended to reword the target commit when squashing. To that end only squash them if the message ends up being used. If an "amend!" message targets a commit that's being squashed, but is not the first commit that's being squashed, then refuse to squash it unless the commit message is being edited. Also refuse to squash a "squash!" message unless it is being squashed into a "fixup!" or "squash!" message with the same target, or the message is being edited. If an "amend!" or "squash!" message is being squashed into a "fixup!" message, then we retain the original target text. This means that we can safely squash 'def456 "amend! abc123"' into 'abc123 "fixup! add new feature"', and later squash the result into '098765 "add new feature"'.

The logic is implemented directly, rather than creating a rebase todo list which is more efficient and hopefully clearer (I had a hard time understanding what the existing code does). It also allows us to tailor the rules to suit the new command. In particular we are much more careful when handling "fixup! <object-name>" subjects, rejecting subjects like "fixup! HEAD^" that do not have a fixed target. We also support squashing "fixup! abc123" into "fixup! <subject of abc123>" to make it easier to squash a group of fixups together.

The authorship of the squashed commit is always taken from the first commit being squashed which matches what rebase does (if there is an "amend!" commit the existing code takes the authorship from that instead). While an "amend!" commit might contain substantial changes that justify changing the authorship it could equally be correcting a simple typo. There is no reason to assume that changes in "amend!" commits are more significant that those in "fixup!" or "squash!" commits.

Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>
---
 builtin/history.c         | 409 +++++++++++++++++++++++++-------------
 object.h                  |   2 +-
 t/t3455-history-squash.sh | 261 ++++++++++++++----------
 3 files changed, 431 insertions(+), 241 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 84e13fd75a..894da69f31 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1011,6 +1011,260 @@ static int cmd_history_split(int argc,
 /*Remember to update object flag allocation in object.h */
 #define SQUASH_SEEN (1u << 11)
 #define SQUASH_TIP (1u << 12)
+#define SQUASH_AMEND_TARGET (1u << 13)
+
+static bool is_autosquash_subject(const char *s)
+{
+	return starts_with(s, "amend!") || starts_with(s, "fixup!") ||
+		starts_with(s, "squash!");
+}
+
+static bool skip_one_autosquash_prefix(const char *s, const char **out)
+{
+	if (skip_prefix(s, "amend!", out) || skip_prefix(s, "fixup!", out) ||
+	    skip_prefix(s, "squash!", out)) {
+		while (**out == ' ')
+			(*out)++;
+		return true;
+	}
+	return false;
+}
+
+static void truncate_message_to_subject(struct strbuf *msg)
+{
+	const char *eos = strstr(msg->buf, "\n\n");
+
+	if (eos)
+		strbuf_setlen(msg, eos - msg->buf + 1);
+}
+
+struct subject_data {
+	struct strintmap subjects;
+	struct strbuf subject;
+	struct strbuf squash_message;
+	const char *message;
+	bool edit_message;
+};
+
+#define SUBJECT_DATA_INIT {		\
+	.subjects = STRINTMAP_INIT,	\
+	.subject = STRBUF_INIT,		\
+	.squash_message = STRBUF_INIT,	\
+}
+
+static void subject_data_clear(struct subject_data *data)
+{
+	strintmap_clear(&data->subjects);
+	strbuf_release(&data->subject);
+	strbuf_release(&data->squash_message);
+}
+
+static int squash_amend_message(struct repository *repo,
+				struct commit *commit,
+				struct subject_data *data,
+				unsigned flags)
+{
+	const char *body = data->message + data->subject.len;
+
+	while (isspace(*body))
+		body++;
+
+	if (!*body) {
+		warning(_("ignoring %s (%s): message body is empty"),
+			repo_find_unique_abbrev(repo, &commit->object.oid,
+						DEFAULT_ABBREV),
+			data->subject.buf);
+		return 0;
+	}
+
+	if (data->edit_message) {
+		BUG("Editing is not implemented yet");
+	} else if (flags & SQUASH_AMEND_TARGET) {
+		if (starts_with(data->squash_message.buf, "squash!"))
+			return error(_("squashing %s (%s) would overwrite "
+				       "'squash!' message, please combine them "
+				       "using '--reedit-message'"),
+				     repo_find_unique_abbrev(repo,
+							     &commit->object.oid,
+							     DEFAULT_ABBREV),
+				     data->subject.buf);
+		if (starts_with(data->squash_message.buf, "fixup!"))
+			strbuf_splice(&data->squash_message, 0, 5, "amend!", 5);
+		if (starts_with(data->squash_message.buf, "amend!")) {
+			truncate_message_to_subject(&data->squash_message);
+			strbuf_addch(&data->squash_message, '\n');
+		} else {
+			strbuf_reset(&data->squash_message);
+		}
+		strbuf_addstr(&data->squash_message, body);
+		strbuf_complete_line(&data->squash_message);
+	} else {
+		return error(_("cannot squash %s (%s) that does not target "
+			       "base commit without '--reedit-message'"),
+			     repo_find_unique_abbrev(repo, &commit->object.oid,
+						     DEFAULT_ABBREV),
+			     data->subject.buf);
+	}
+	return 0;
+}
+
+static int squash_squash_message(struct repository *repo,
+				struct commit *commit,
+				struct subject_data *data,
+				unsigned flags)
+{
+	const char *body = data->message + data->subject.len;
+
+	while (isspace(*body))
+		body++;
+
+	if (data->edit_message) {
+		BUG("Editing is not implemented yet");
+	} else if (flags & SQUASH_AMEND_TARGET) {
+		if (starts_with(data->squash_message.buf, "fixup!")) {
+			truncate_message_to_subject(&data->squash_message);
+			strbuf_splice(&data->squash_message, 0, 5, "squash", 6);
+		}
+		if (starts_with(data->squash_message.buf, "squash!")) {
+			strbuf_addch(&data->squash_message, '\n');
+			strbuf_addstr(&data->squash_message, body);
+			strbuf_complete_line(&data->squash_message);
+		} else {
+			return error(_("squashing %s (%s) would discard its "
+				       "message, please combine them using "
+				       "'--reedit-message'"),
+				     repo_find_unique_abbrev(repo,
+							     &commit->object.oid,
+							     DEFAULT_ABBREV),
+				     data->subject.buf);
+		}
+	} else {
+		return error(_("cannot squash %s (%s) that does not target "
+			       "base commit without '--reedit-message'"),
+			      repo_find_unique_abbrev(repo, &commit->object.oid,
+						      DEFAULT_ABBREV),
+			      data->subject.buf);
+	}
+	return 0;
+}
+
+static int squash_check_can_autosquash(struct repository *repo,
+				       struct commit *commit,
+				       struct subject_data *data,
+				       unsigned flags)
+{
+	commit->object.flags |= flags & SQUASH_AMEND_TARGET;
+	if (starts_with(data->subject.buf, "amend!"))
+		return squash_amend_message(repo, commit, data, flags);
+	else if (starts_with(data->subject.buf, "squash!"))
+		return squash_squash_message(repo, commit, data, flags);
+
+	return 0;
+}
+
+static int squash_check_autosquash_subject(struct repository *repo,
+					   struct commit *commit,
+					   struct subject_data *data)
+{
+	const char* s = data->subject.buf;
+	struct commit *target;
+	struct hashmap_iter iter;
+	struct strmap_entry *entry;
+	/* Try skipping autosquash prefixes one at a time to allow
+	 * squashing
+	 *     a commit
+	 *     fixup! fixup! a commit
+	 *
+	 * where we may have started with
+	 *     a commit
+	 *     fixup! a commit
+	 *     fixup! fixup! a commit
+	 *
+	 * and squashed the first fixup separately from the second
+	 */
+	while (skip_one_autosquash_prefix(s, &s)) {
+		unsigned flags = strintmap_get(&data->subjects, s);
+		if (flags)
+			return squash_check_can_autosquash(repo, commit, data, flags);
+	}
+	/*
+	 * Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
+	 * "fixup! main". If the target is not being squshed check the subject
+	 * to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
+	 * squashed together.
+	 */
+	target = lookup_commit_reference_by_name(s);
+	if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {
+		unsigned flags =
+			target->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);
+		if (!flags) {
+			const char *subject_start;
+			const char *buffer = repo_logmsg_reencode(repo, target,
+								  NULL, NULL);
+			size_t subject_len = find_commit_subject(buffer,
+								 &subject_start);
+			char *subject = xmemdupz(subject_start, subject_len);
+
+			flags = strintmap_get(&data->subjects, subject);
+			free(subject);
+			repo_unuse_commit_buffer(repo, target, buffer);
+		}
+		if (flags)
+			return squash_check_can_autosquash(repo, commit,
+							   data, flags);
+	}
+	/* Try subject prefix matches */
+	strintmap_for_each_entry(&data->subjects, &iter, entry) {
+		s = data->subject.buf;
+		while(skip_one_autosquash_prefix(s, &s)) {
+			if (starts_with(entry->key, s)) {
+				unsigned value = (intptr_t)entry->value;
+
+				return squash_check_can_autosquash(repo, commit,
+								   data, value);
+			}
+		}
+	}
+	return error(_("cannot squash %s (%s): its target is not being "
+		       "squashed"),
+		       repo_find_unique_abbrev(repo, &commit->object.oid,
+					       DEFAULT_ABBREV),
+		       data->subject.buf);
+}
+
+static int squash_check_subject(struct repository *repo,
+				struct commit *commit,
+				struct subject_data *data)
+{
+	int ret = 0;
+	const char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);
+	size_t subject_len = find_commit_subject(buf, &data->message);
+
+	strbuf_reset(&data->subject);
+	strbuf_add(&data->subject, data->message, subject_len);
+
+	if (!strintmap_get_size(&data->subjects)) {
+		const char *s;
+
+		strbuf_addstr(&data->squash_message, data->message);
+		strbuf_complete_line(&data->squash_message);
+		/*
+		 * Strip a single autosquash prefix to allow squashing
+		 *     fixup! base
+		 *     amend! base
+		 */
+		s = data->subject.buf;
+		skip_one_autosquash_prefix(s, &s);
+		strintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);
+		commit->object.flags |= SQUASH_AMEND_TARGET;
+	} else if (is_autosquash_subject(data->subject.buf)) {
+		ret = squash_check_autosquash_subject(repo, commit, data);
+	} else {
+		strintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);
+	}
+	repo_unuse_commit_buffer(repo, commit, buf);
+	return ret;
+}
 
 /*
  * Resolve a "<base>..<tip>" revision range into the base commit just outside
@@ -1024,9 +1278,11 @@ static int resolve_squash_range(struct repository *repo,
 				int argc, const char **argv,
 				struct commit **base_out,
 				struct commit **oldest_out,
-				struct commit **tip_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;
 	size_t i;
 	int ret, tip_count = 0;
@@ -1127,12 +1383,16 @@ static int resolve_squash_range(struct repository *repo,
 		if (!oldest) {
 			commit_list_insert(commit, &filter.with_commit);
 			oldest = commit;
+		}
+		if (squash_check_subject(repo, commit, &subject_data)) {
+			ret = -1;
+			goto out;
 		}
 		tip = commit;
 		tip->object.flags |= SQUASH_SEEN | SQUASH_TIP;
 		tip_count++;
 	}
-	clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);
+	clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP | SQUASH_AMEND_TARGET);
 	reset_revision_walk();
 	if (!tip_count) {
 		ret = error(_("the revision range is empty"));
@@ -1171,122 +1431,14 @@ static int resolve_squash_range(struct repository *repo,
 	*base_out = base;
 	*oldest_out = oldest;
 	*tip_out = tip;
+	*message_out = strbuf_detach(&subject_data.squash_message, NULL);
 	ret = 0;
 
 out:
+	subject_data_clear(&subject_data);
 	release_revisions(&revs);
 	ref_filter_clear(&filter);
 	ref_array_clear(&refs);
-	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;
 }
 
@@ -1314,11 +1466,9 @@ static int cmd_history_squash(int argc,
 		OPT_END(),
 	};
 	struct strbuf reflog_msg = STRBUF_INIT;
-	struct strbuf message = STRBUF_INIT;
-	struct commit *base, *oldest, *tip, *rewritten, *msg_source,
-		*amend_source;
+	struct commit *base, *oldest, *tip, *rewritten;
 	const struct object_id *base_tree_oid, *tip_tree_oid;
-	const char *message_template = NULL;
+	char *message_template = NULL;
 	struct commit_list *parents = NULL;
 	struct rev_info revs = { 0 };
 	int ret;
@@ -1338,25 +1488,10 @@ 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);
-	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);
-	}
+				   argc, argv, &base, &oldest, &tip,
+				   &message_template);
+	if (ret < 0)
+		goto out;
 
 	ret = setup_revwalk(repo, action, tip, &revs);
 	if (ret < 0)
@@ -1366,7 +1501,7 @@ 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, message_template,
+	ret = commit_tree_ext(repo, "squash", oldest, message_template,
 			      parents,
 			      base_tree_oid, tip_tree_oid, &rewritten, flags);
 	if (ret < 0) {
@@ -1386,9 +1521,9 @@ static int cmd_history_squash(int argc,
 
 out:
 	strbuf_release(&reflog_msg);
-	strbuf_release(&message);
 	commit_list_free(parents);
 	release_revisions(&revs);
+	free(message_template);
 	return ret;
 }
 
diff --git a/object.h b/object.h
index ad18ffcc55..46cade33fb 100644
--- a/object.h
+++ b/object.h
@@ -74,7 +74,7 @@ void object_array_init(struct object_array *array);
  * bisect.c:                                        16
  * bundle.c:                                        16
  * http-push.c:                          11-----14
- * builtin/history.c:                    11-12
+ * builtin/history.c:                    11---13
  * commit-graph.c:                                15
  * commit-reach.c:                                  16-------20
  * builtin/last-modified.c:                         1617
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index d7697489a0..26c4b4e29e 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -29,14 +29,25 @@ check_log_subjects () {
 check_log_messages () {
 	git log --format="%B" "$1" >actual &&
 	cat >expect &&
+	test_cmp expect actual
+}
+
+# Checks that the author data of two commits matches
+# Usage: check_commit_author <rev1> <rev2>
+check_commit_author () {
+	git show -s --format="%an <%ae> %ad" "$1" >expect &&
+	git show -s --format="%an <%ae> %ad" "$1" >actual &&
 	test_cmp expect actual
 }
 
 test_expect_success 'setup linear history touching two files' '
 	test_commit base file a start &&
-	test_commit --no-tag one other x &&
-	test_commit --no-tag two file c &&
-	test_commit three file d
+	GIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \
+		test_commit one other x &&
+	GIT_AUTHOR_NAME=Two GIT_AUTHOR_EMAIL=two@example.com \
+		test_commit two file c &&
+	GIT_AUTHOR_NAME=Three GIT_AUTHOR_EMAIL=three@example.com \
+		test_commit three file d
 '
 
 test_expect_success 'errors on missing range argument' '
@@ -192,106 +203,150 @@ test_expect_success 'squashes when the base is the root commit' '
 	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 'squashing a mix of fixups' '
+	git reset --hard three &&
+	echo fix >file &&
+	git commit --fixup=two -a &&
+	echo really fix >file &&
+	git commit --fixup=one -a &&
+	echo really really fix >file &&
+	git commit --fixup=HEAD~1 -a && # fixup! two
+	echo really really really fix >file &&
+	git commit --fixup=HEAD~1 -a && # fixup! one
+
+	# squashing fixup! with a target that is not being squashed fails
+	test_must_fail git history squash one.. 2>err &&
+	test_grep "^error: cannot squash .* (fixup! one): its target is not being squashed" err &&
+
+	# squashing fixup! into fixup! with a different target fails
+	test_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two
+	test_grep "^error: cannot squash .* (fixup! one): its target is not being squashed" err &&
+
+	# squashing a sequence of fixup! commits into their targets
+	git history squash start..HEAD~1 &&
+	test_cmp_rev start HEAD~2 &&
+	check_commit_author one HEAD~1 &&
+	test_commit_message HEAD~1 -m one &&
+
+	# squashing "fixup! fixup! <target>" into "<target>"
+	git history squash start.. &&
+	test_cmp_rev start HEAD~1 &&
+	check_commit_author one HEAD &&
+	test_commit_message HEAD -m one
+'
+
+test_expect_success 'squashing "squash!" messages' '
+	git reset --hard two &&
+	echo fix >file &&
+	git commit --fixup=HEAD -a &&
+	echo better fix >file &&
+	git commit -a -F - <<-EOF &&
+	squash! $(git rev-parse two)
+
+	Append this
+	EOF
+
+	echo an even better fix >file &&
+	git commit -a -F - <<-EOF &&
+	squash! squash! two
+
+	Append this as well
+	EOF
+
+	# must edit when squashing "squash!" into its target
+	test_must_fail git history squash two^.. 2>err &&
+	test_grep "^error: squashing .* (squash! [a-f0-9]*) would discard its message" err &&
+
+	# squashing "squash!" into "fixup!" appends messages and changes
+	# subject prefix
+	git history squash two.. &&
+	test_cmp_rev HEAD^ two &&
+	test_commit_message HEAD <<-\EOF &&
+	squash! two
+
+	Append this
+
+	Append this as well
+	EOF
+	check_commit_author two HEAD &&
+
+	git commit --allow-empty -F - <<-\EOF &&
+	amend! two
+
+	A new message
+	EOF
+
+	# "amend!" does not replace "squash!"
+	test_must_fail git history squash HEAD~2.. 2>err &&
+	test_grep "^error: squashing .* (amend! two) would overwrite .squash!. message" err
+'
+
+test_expect_success 'squash commit uses last "amend!" message' '
+	git reset --hard three &&
+	echo fix >file &&
+	git commit --author="Fix Me <fix.me@example.com>" --fixup=HEAD -a &&
+	git commit --allow-empty -F - <<-EOF &&
+	amend! $(git rev-parse --short HEAD)
+
+	The first reword
+
+	More detail
+	EOF
+
+	git commit --allow-empty -F - <<-\EOF &&
+	amend! three
+
+	The second reword
+
+	Extra detail
+	EOF
+
+	test_commit WIP &&
+
+	cat >msg <<-EOF &&
+	amend! $(git rev-parse HEAD^ | tr a-f A-F)
+
+	The third reword
+
+	Excruciating detail
+	EOF
+
+	git commit --author="Someone Else <s.else@example.com>" --allow-empty \
+		-F msg &&
+
+	# squashing amend! updates the commit message
+	git history squash three^.. &&
+	sed -e 1,2d msg | test_commit_message HEAD &&
+	check_commit_author three HEAD &&
+	test_cmp_rev HEAD^ three^ &&
+
+	# squashing amend! into fixup! updates subject prefix
+	git reset --hard HEAD@{1} &&
+	git history squash three.. &&
+	sed "1s/.*/amend! three/" msg | test_commit_message HEAD &&
+	check_commit_author HEAD@{1}~4 HEAD &&
+	test_cmp_rev HEAD^ three &&
+
+	# squashing amend! into amend! keeps original subject line
+	git reset --hard HEAD@{1} &&
+	git history squash HEAD~3.. &&
+	sed "1s/.*/amend! three/" msg | test_commit_message HEAD &&
+	test_cmp_rev HEAD~3 three &&
+
+	# all amend! messages must target the first commit
+	git reset --hard HEAD@{1} &&
+	git commit --allow-empty -F - <<-\EOF &&
+	amend! WIP
+
+	The real message
+	EOF
+
+	test_must_fail git history squash HEAD~4.. 2>err &&
+	test_grep "^error: cannot squash .* that does not target" err &&
+
+	# 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 '--update-refs=head only moves HEAD' '
-- 
2.54.0.200.gfd8d68259e3
Previous: Phillip WoodNext: Phillip Wood
Message 119 of 185 in “rebase: add --fixup to fold a range into its oldest commit”
  1. 0/2 rebase: add --fixup to fold a range into its oldest commitHarald Nordgren via GitGitGadget, Jun 14, 2026
  2. 1/2 t3415: remove prepare-commit-msg hook after useHarald Nordgren via GitGitGadget, Jun 14, 2026
  3. 2/2 rebase: add --fixup-all to fold a rangeHarald Nordgren via GitGitGadget, Jun 14, 2026
  4. Junio C HamanoJun 15, 2026
  5. Harald NordgrenJun 15, 2026
  6. D. Ben KnobleJun 15, 2026
  7. Patrick SteinhardtJun 16, 2026
  8. Harald NordgrenJun 17, 2026
  9. 0/2 rebase: add --squash to fold a range into its first commitHarald Nordgren via GitGitGadget, Jun 15, 2026
  10. 1/2 t3415: remove prepare-commit-msg hook after useHarald Nordgren via GitGitGadget, Jun 15, 2026
  11. 2/2 rebase: add --squash to fold a rangeHarald Nordgren via GitGitGadget, Jun 15, 2026
  12. Phillip WoodJun 16, 2026
  13. Harald NordgrenJun 17, 2026
  14. Phillip WoodJun 17, 2026
  15. 0/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jun 18, 2026
  16. 1/4 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Jun 18, 2026
  17. 2/4 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Jun 18, 2026
  18. 3/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jun 18, 2026
  19. Junio C HamanoJun 18, 2026
  20. Junio C HamanoJun 18, 2026
  21. D. Ben KnobleJun 18, 2026
  22. Patrick SteinhardtJun 19, 2026
  23. 4/4 history: re-edit a squash with every messageHarald Nordgren via GitGitGadget, Jun 18, 2026
  24. D. Ben KnobleJun 18, 2026
  25. Junio C HamanoJun 19, 2026
  26. Patrick SteinhardtJun 19, 2026
  27. Junio C HamanoJun 19, 2026
  28. 0/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jun 21, 2026
  29. 1/4 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Jun 21, 2026
  30. 2/4 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Jun 21, 2026
  31. 3/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jun 21, 2026
  32. 4/4 history: re-edit a squash with every messageHarald Nordgren via GitGitGadget, Jun 21, 2026
  33. Patrick SteinhardtJun 22, 2026
  34. Harald NordgrenJun 23, 2026
  35. 0/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jun 24, 2026
  36. 1/4 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Jun 24, 2026
  37. 2/4 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Jun 24, 2026
  38. 3/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jun 24, 2026
  39. 4/4 history: re-edit a squash with every messageHarald Nordgren via GitGitGadget, Jun 24, 2026
  40. Phillip WoodJun 26, 2026
  41. Harald NordgrenJun 26, 2026
  42. Phillip WoodJun 26, 2026
  43. Junio C HamanoJun 26, 2026
  44. Harald NordgrenJun 26, 2026
  45. Patrick SteinhardtJun 29, 2026
  46. Phillip WoodJun 29, 2026
  47. Junio C HamanoJun 29, 2026
  48. Phillip WoodJul 1, 2026
  49. Harald NordgrenJun 29, 2026
  50. Phillip WoodJun 29, 2026
  51. Harald NordgrenJun 29, 2026
  52. Phillip WoodJun 30, 2026
  53. Harald NordgrenJun 30, 2026
  54. Phillip WoodJul 1, 2026
  55. Junio C HamanoJul 1, 2026
  56. Phillip WoodJul 1, 2026
  57. Junio C HamanoJul 1, 2026
  58. Phillip WoodJul 2, 2026
  59. Matt HunterJun 30, 2026
  60. Harald NordgrenJun 30, 2026
  61. Matt HunterJun 30, 2026
  62. Phillip WoodJun 30, 2026
  63. Patrick SteinhardtJul 2, 2026
  64. Junio C HamanoJul 2, 2026
  65. Harald NordgrenJun 29, 2026
  66. 0/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jun 28, 2026
  67. 1/4 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Jun 28, 2026
  68. 2/4 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Jun 28, 2026
  69. 3/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jun 28, 2026
  70. Junio C HamanoJun 29, 2026
  71. 4/4 history: re-edit a squash with every messageHarald Nordgren via GitGitGadget, Jun 28, 2026
  72. Junio C HamanoJun 29, 2026
  73. Harald NordgrenJun 29, 2026
  74. Junio C HamanoJun 29, 2026
  75. Junio C HamanoJun 29, 2026
  76. 0/5 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jul 6, 2026
  77. 1/5 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Jul 6, 2026
  78. 2/5 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Jul 6, 2026
  79. 3/5 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jul 6, 2026
  80. 4/5 sequencer: extract helpers for the squash message markersHarald Nordgren via GitGitGadget, Jul 6, 2026
  81. 5/5 history: re-edit a squash with every messageHarald Nordgren via GitGitGadget, Jul 6, 2026
  82. Phillip WoodJul 6, 2026
  83. Harald NordgrenJul 7, 2026
  84. Harald NordgrenJul 7, 2026
  85. Phillip WoodJul 7, 2026
  86. Phillip WoodJul 7, 2026
  87. Junio C HamanoJul 6, 2026
  88. 0/5 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jul 10, 2026
  89. 1/5 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Jul 10, 2026
  90. 2/5 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Jul 10, 2026
  91. 3/5 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jul 10, 2026
  92. 4/5 sequencer: share the squash message marker helpers and flagsHarald Nordgren via GitGitGadget, Jul 10, 2026
  93. 5/5 history: re-edit a squash with every messageHarald Nordgren via GitGitGadget, Jul 10, 2026
  94. Matt HunterJul 14, 2026
  95. Harald NordgrenJul 14, 2026
  96. Harald NordgrenJul 14, 2026
  97. Ben KnobleJul 14, 2026
  98. Junio C HamanoJul 14, 2026
  99. 0/5 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jul 15, 2026
  100. 1/5 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Jul 15, 2026
  101. 2/5 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Jul 15, 2026
  102. 3/5 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jul 15, 2026
  103. Matt HunterJul 18, 2026
  104. Harald NordgrenJul 18, 2026
  105. 4/5 sequencer: share the squash message marker helpers and flagsHarald Nordgren via GitGitGadget, Jul 15, 2026
  106. 5/5 history: re-edit a squash with every messageHarald Nordgren via GitGitGadget, Jul 15, 2026
  107. Matt HunterJul 18, 2026
  108. Harald NordgrenJul 18, 2026
  109. 0/5 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jul 20, 2026
  110. 1/5 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Jul 20, 2026
  111. 2/5 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Jul 20, 2026
  112. 3/5 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Jul 20, 2026
  113. Phillip WoodAug 3, 2026
  114. 1/3 fixup! history: add squash subcommand to fold a rangePhillip Wood, Aug 3, 2026
  115. 2/3 fixup! history: add squash subcommand to fold a rangePhillip Wood, Aug 3, 2026
  116. 3/3 fixup! history: add squash subcommand to fold a rangePhillip Wood, Aug 3, 2026
  117. 4/3 fixup! history: add squash subcommand to fold a rangePhillip Wood, Aug 3, 2026
  118. 5/3 fixup! history: add squash subcommand to fold a rangePhillip Wood, Aug 3, 2026
  119. 6/3 fixup! history: add squash subcommand to fold a rangePhillip Wood, Aug 3, 2026
  120. 7/3 fixup! history: add squash subcommand to fold a rangePhillip Wood, Aug 3, 2026
  121. Harald NordgrenAug 3, 2026
  122. Phillip WoodAug 4, 2026
  123. Junio C HamanoAug 4, 2026
  124. Harald NordgrenAug 4, 2026
  125. Harald NordgrenAug 4, 2026
  126. Tuomas AholaAug 7, 2026
  127. Junio C HamanoAug 4, 2026
  128. Phillip WoodAug 7, 2026
  129. Harald NordgrenAug 7, 2026
  130. Kristoffer HaugsbakkAug 6, 2026
  131. 4/5 sequencer: share the squash message marker helpers and flagsHarald Nordgren via GitGitGadget, Jul 20, 2026
  132. 5/5 history: re-edit a squash with every messageHarald Nordgren via GitGitGadget, Jul 20, 2026
  133. Matt HunterJul 21, 2026
  134. 0/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Aug 1, 2026
  135. 1/4 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Aug 1, 2026
  136. 2/4 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Aug 1, 2026
  137. 3/4 sequencer: share the squash message marker helpers and flagsHarald Nordgren via GitGitGadget, Aug 1, 2026
  138. 4/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Aug 1, 2026
  139. 0/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Aug 4, 2026
  140. 1/4 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Aug 4, 2026
  141. 2/4 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Aug 4, 2026
  142. 3/4 sequencer: share the squash message marker helpers and flagsHarald Nordgren via GitGitGadget, Aug 4, 2026
  143. 4/4 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Aug 4, 2026
  144. 0/8 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Aug 7, 2026
  145. 1/8 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Aug 7, 2026
  146. 2/8 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Aug 7, 2026
  147. 3/8 sequencer: share the squash message marker helpers and flagsHarald Nordgren via GitGitGadget, Aug 7, 2026
  148. 4/8 history: add skeleton for squash subcommandHarald Nordgren via GitGitGadget, Aug 7, 2026
  149. 5/8 history: validate squash revision rangesHarald Nordgren via GitGitGadget, Aug 7, 2026
  150. 6/8 history: protect branches when squashing a rangeHarald Nordgren via GitGitGadget, Aug 7, 2026
  151. 7/8 history: create squashed commits without editingHarald Nordgren via GitGitGadget, Aug 7, 2026
  152. Junio C HamanoAug 13, 2026
  153. Junio C HamanoAug 13, 2026
  154. Phillip WoodAug 17, 2026
  155. Junio C HamanoAug 17, 2026
  156. Junio C HamanoAug 17, 2026
  157. Junio C HamanoAug 17, 2026
  158. Junio C HamanoAug 17, 2026
  159. Harald NordgrenAug 17, 2026
  160. Kristoffer HaugsbakkAug 17, 2026
  161. 8/8 history: support editing squashed commit messagesHarald Nordgren via GitGitGadget, Aug 7, 2026
  162. 0/8 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Aug 20, 2026
  163. 1/8 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Aug 20, 2026
  164. 2/8 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Aug 20, 2026
  165. 3/8 sequencer: share the squash message marker helpers and flagsHarald Nordgren via GitGitGadget, Aug 20, 2026
  166. 4/8 history: add skeleton for squash subcommandHarald Nordgren via GitGitGadget, Aug 20, 2026
  167. 5/8 history: validate squash revision rangesHarald Nordgren via GitGitGadget, Aug 20, 2026
  168. 6/8 history: protect branches when squashing a rangeHarald Nordgren via GitGitGadget, Aug 20, 2026
  169. 7/8 history: create squashed commits without editingHarald Nordgren via GitGitGadget, Aug 20, 2026
  170. Junio C HamanoAug 20, 2026
  171. Phillip WoodAug 24, 2026
  172. Junio C HamanoAug 24, 2026
  173. Phillip WoodAug 25, 2026
  174. Junio C HamanoAug 25, 2026
  175. 8/8 history: support editing squashed commit messagesHarald Nordgren via GitGitGadget, Aug 20, 2026
  176. 0/8 history: add squash subcommand to fold a rangeHarald Nordgren via GitGitGadget, Sep 8, 2026
  177. 1/8 history: extract helper for a commit's parent treeHarald Nordgren via GitGitGadget, Sep 8, 2026
  178. 2/8 history: give commit_tree_ext a message templateHarald Nordgren via GitGitGadget, Sep 8, 2026
  179. 3/8 sequencer: share the squash message marker helpers and flagsHarald Nordgren via GitGitGadget, Sep 8, 2026
  180. 4/8 history: add skeleton for squash subcommandHarald Nordgren via GitGitGadget, Sep 8, 2026
  181. Junio C HamanoSep 8, 2026
  182. 5/8 history: validate squash revision rangesHarald Nordgren via GitGitGadget, Sep 8, 2026
  183. 6/8 history: protect branches when squashing a rangeHarald Nordgren via GitGitGadget, Sep 8, 2026
  184. 8/8 history: support editing squashed commit messagesHarald Nordgren via GitGitGadget, Sep 8, 2026
  185. 7/8 history: create squashed commits without editingHarald Nordgren via GitGitGadget, Sep 8, 2026

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.