history: re-edit a squash with every message

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> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Harald Nordgren committed Jul 20, 2026 at 08:27 UTC 9a8107a378f4a7b1415c5b0e1e9f0fbcb3244bfe
3 files changed +321 -6
Documentation/git-history.adoc
+16 -6
@@ -134,11 +134,12 @@ already on `topic`. Rev-list options may also be given, but any that would
134 change how the range is walked are overridden with a warning.
135 +
136 The oldest commit's message is preserved by default, except that an `amend!`
137 -commit targeting it replaces its message. Specify `--reedit-message` to edit
138 -the resulting message. A merge commit inside the range is folded like any
139 -other, but the range must have a single base, so a range that reaches more
140 -than one entry point (for example a side branch that forked before the range
141 -and was later merged into it) is rejected.
137 +commit targeting it replaces its message. With `--reedit-message`, an editor
138 +opens pre-filled with the messages of all the folded commits so you can
139 +combine them. A merge commit inside the range is folded like any other, but
140 +the range must have a single base, so a range that reaches more than one entry
141 +point (for example a side branch that forked before the range and was later
142 +merged into it) is rejected.
143 +
144 A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it
145 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
147 of markers for one target is combined into a single commit, keeping the last
148 `amend!` message if there is one.
149 +
150 +With `--reedit-message` the template mirrors `git rebase -i --autosquash`:
151 +each `fixup!`, `squash!`, or `amend!` is grouped under the commit it
152 +targets rather than shown in commit order. A `fixup!` message is dropped
153 +(commented out in full), a `squash!` keeps its body with only the marker
154 +subject commented, and an `amend!` replaces its target's message, unless
155 +a `squash!` folded into that target first, in which case it keeps its
156 +body like a `squash!`.
157 ++
158 A branch or tag that points at a commit inside the range would be left
159 dangling once those commits are folded away, so with the default
160 `--update-refs=branches` the command refuses. Rerun with
@@ -162,7 +171,8 @@ OPTIONS
171 ref updates is generally safe.
172
173 `--reedit-message`::
165 - Open an editor to modify the rewritten commit's message.
174 + Open an editor to modify the rewritten commit's message. For `squash`
175 + the editor is pre-filled with the messages of all the folded commits.
176
177 `--empty=(drop|keep|abort)`::
178 Control what happens when a commit becomes empty as a result of the
builtin/history.c
+104
@@ -1255,6 +1255,102 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
1255 return 0;
1256 }
1257
1258 +static bool amend_replaces_target(struct todo_list *todo, int target)
1259 +{
1260 + int i;
1261 +
1262 + for (i = target + 1; i < todo->nr &&
1263 + todo->items[i].command != TODO_PICK; i++) {
1264 + if (todo->items[i].command == TODO_SQUASH)
1265 + return false;
1266 + if (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)
1267 + return true;
1268 + }
1269 + return false;
1270 +}
1271 +
1272 +static int build_squash_message(struct repository *repo,
1273 + struct commit *base,
1274 + struct commit *tip,
1275 + struct strbuf *out)
1276 +{
1277 + struct rev_info revs;
1278 + struct commit *commit;
1279 + struct strvec args = STRVEC_INIT;
1280 + struct todo_list todo = TODO_LIST_INIT;
1281 + struct replay_opts opts = REPLAY_OPTS_INIT;
1282 + int i, nr_commits, ret;
1283 +
1284 + repo_init_revisions(repo, &revs, NULL);
1285 + strvec_push(&args, "ignored");
1286 + strvec_push(&args, "--reverse");
1287 + strvec_push(&args, "--topo-order");
1288 + strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
1289 + oid_to_hex(&tip->object.oid));
1290 + setup_revisions_from_strvec(&args, &revs, NULL);
1291 +
1292 + if (prepare_revision_walk(&revs) < 0) {
1293 + ret = error(_("error preparing revisions"));
1294 + goto out;
1295 + }
1296 +
1297 + while ((commit = get_revision(&revs)))
1298 + strbuf_addf(&todo.buf, "pick %s\n",
1299 + oid_to_hex(&commit->object.oid));
1300 +
1301 + if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
1302 + todo_list_rearrange_squash(&todo) < 0) {
1303 + ret = error(_("could not prepare the squash message"));
1304 + goto out;
1305 + }
1306 +
1307 + nr_commits = todo.nr;
1308 + for (i = 0; i < nr_commits; i++) {
1309 + struct todo_item *item = &todo.items[i];
1310 + const char *message, *body;
1311 + size_t commented_len;
1312 + bool skip, squashing;
1313 +
1314 + squashing = item->command == TODO_SQUASH ||
1315 + (item->flags & TODO_REPLACE_FIXUP_MSG);
1316 + if (item->command == TODO_PICK)
1317 + skip = amend_replaces_target(&todo, i);
1318 + else
1319 + skip = !squashing;
1320 +
1321 + message = repo_logmsg_reencode(repo, item->commit, NULL, NULL);
1322 + find_commit_subject(message, &body);
1323 +
1324 + if (skip)
1325 + commented_len = strlen(body);
1326 + else if (squashing)
1327 + commented_len = squash_subject_comment_len(body, 1);
1328 + else
1329 + commented_len = 0;
1330 +
1331 + if (!i)
1332 + add_squash_combination_header(out, nr_commits);
1333 + strbuf_addch(out, '\n');
1334 + add_squash_message_header(out, i + 1, skip);
1335 + strbuf_addstr(out, "\n\n");
1336 + strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
1337 + strbuf_addstr(out, body + commented_len);
1338 + strbuf_complete_line(out);
1339 +
1340 + repo_unuse_commit_buffer(repo, item->commit, message);
1341 + }
1342 +
1343 + ret = 0;
1344 +
1345 +out:
1346 + todo_list_release(&todo);
1347 + replay_opts_release(&opts);
1348 + reset_revision_walk();
1349 + release_revisions(&revs);
1350 + strvec_clear(&args);
1351 + return ret;
1352 +}
1353 +
1354 static int cmd_history_squash(int argc,
1355 const char **argv,
1356 const char *prefix,
@@ -1338,6 +1434,14 @@ static int cmd_history_squash(int argc,
1434 }
1435 }
1436
1437 + if (flags & COMMIT_TREE_EDIT_MESSAGE) {
1438 + strbuf_reset(&message);
1439 + ret = build_squash_message(repo, base, tip, &message);
1440 + if (ret < 0)
1441 + goto out;
1442 + message_template = message.buf;
1443 + }
1444 +
1445 ret = setup_revwalk(repo, action, tip, &revs);
1446 if (ret < 0)
1447 goto out;
t/t3455-history-squash.sh
+201
@@ -271,6 +271,207 @@ test_expect_success 'preserves authorship of the oldest commit' '
271 test_cmp expect actual
272 '
273
274 +test_expect_success '--reedit-message offers every folded-in message' '
275 + git reset --hard start &&
276 + stage_file b &&
277 + git commit -m "re-one subject" -m "re-one body line" &&
278 + test_commit --no-tag re-two file c &&
279 + test_commit re-three file d &&
280 +
281 + write_script editor <<-\EOF &&
282 + cat "$1" >edited &&
283 + echo combined >"$1"
284 + EOF
285 + test_set_editor "$(pwd)/editor" &&
286 + git history squash --reedit-message start.. &&
287 +
288 + cat >expect <<-EOF &&
289 + # This is a combination of 3 commits.
290 + # This is the 1st commit message:
291 +
292 + re-one subject
293 +
294 + re-one body line
295 +
296 + # This is the commit message #2:
297 +
298 + re-two
299 +
300 + # This is the commit message #3:
301 +
302 + re-three
303 +
304 + # Please enter the commit message for the squash changes. Lines starting
305 + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
306 + # Changes to be committed:
307 + # modified: file
308 + #
309 + EOF
310 + test_cmp expect edited &&
311 + check_log_subjects -1 <<-\EOF
312 + combined
313 + EOF
314 +'
315 +
316 +test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
317 + git reset --hard start &&
318 + test_commit --no-tag mark-base file b &&
319 + stage_file c &&
320 + commit_with_message "fixup! mark-base\n\nfixup body\n" &&
321 + stage_file d &&
322 + commit_with_message "squash! mark-base\n\nsquash remark\n" &&
323 + stage_file e &&
324 + commit_with_message "amend! mark-base\n\namended message\n" &&
325 +
326 + write_script editor <<-\EOF &&
327 + cat "$1" >edited
328 + EOF
329 + test_set_editor "$(pwd)/editor" &&
330 + git history squash --reedit-message start.. &&
331 +
332 + cat >expect <<-EOF &&
333 + # This is a combination of 4 commits.
334 + # This is the 1st commit message:
335 +
336 + mark-base
337 +
338 + # The commit message #2 will be skipped:
339 +
340 + # fixup! mark-base
341 + #
342 + # fixup body
343 +
344 + # This is the commit message #3:
345 +
346 + # squash! mark-base
347 +
348 + squash remark
349 +
350 + # This is the commit message #4:
351 +
352 + # amend! mark-base
353 +
354 + amended message
355 +
356 + # Please enter the commit message for the squash changes. Lines starting
357 + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
358 + # Changes to be committed:
359 + # modified: file
360 + #
361 + EOF
362 + test_cmp expect edited &&
363 + check_log_messages -1 <<-\EOF
364 + mark-base
365 +
366 + squash remark
367 +
368 + amended message
369 +
370 + EOF
371 +'
372 +
373 +test_expect_success '--reedit-message groups fixups under their targets' '
374 + git reset --hard start &&
375 + test_commit --no-tag alpha file a1 &&
376 + test_commit --no-tag beta file b1 &&
377 + stage_file a2 &&
378 + commit_with_message "fixup! alpha\n" &&
379 + stage_file b2 &&
380 + commit_with_message "fixup! beta\n" &&
381 +
382 + write_script editor <<-\EOF &&
383 + cat "$1" >edited
384 + EOF
385 + test_set_editor "$(pwd)/editor" &&
386 + git history squash --reedit-message start.. &&
387 +
388 + cat >expect <<-EOF &&
389 + # This is a combination of 4 commits.
390 + # This is the 1st commit message:
391 +
392 + alpha
393 +
394 + # The commit message #2 will be skipped:
395 +
396 + # fixup! alpha
397 +
398 + # This is the commit message #3:
399 +
400 + beta
401 +
402 + # The commit message #4 will be skipped:
403 +
404 + # fixup! beta
405 +
406 + # Please enter the commit message for the squash changes. Lines starting
407 + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
408 + # Changes to be committed:
409 + # modified: file
410 + #
411 + EOF
412 + test_cmp expect edited
413 +'
414 +
415 +test_expect_success '--reedit-message lets amend! replace its target message' '
416 + git reset --hard start &&
417 + test_commit --no-tag mark-base file b &&
418 + stage_file c &&
419 + commit_with_message "amend! mark-base\n\namended message\n" &&
420 + stage_file d &&
421 + commit_with_message "squash! mark-base\n\nsquash remark\n" &&
422 +
423 + write_script editor <<-\EOF &&
424 + cat "$1" >edited
425 + EOF
426 + test_set_editor "$(pwd)/editor" &&
427 + git history squash --reedit-message start.. &&
428 +
429 + cat >expect <<-EOF &&
430 + # This is a combination of 3 commits.
431 + # The 1st commit message will be skipped:
432 +
433 + # mark-base
434 +
435 + # This is the commit message #2:
436 +
437 + # amend! mark-base
438 +
439 + amended message
440 +
441 + # This is the commit message #3:
442 +
443 + # squash! mark-base
444 +
445 + squash remark
446 +
447 + # Please enter the commit message for the squash changes. Lines starting
448 + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
449 + # Changes to be committed:
450 + # modified: file
451 + #
452 + EOF
453 + test_cmp expect edited &&
454 + check_log_messages -1 <<-\EOF
455 + amended message
456 +
457 + squash remark
458 +
459 + EOF
460 +'
461 +
462 +test_expect_success '--reedit-message aborts on an empty message' '
463 + git reset --hard three &&
464 + head_before=$(git rev-parse HEAD) &&
465 +
466 + write_script editor <<-\EOF &&
467 + >"$1"
468 + EOF
469 + test_set_editor "$(pwd)/editor" &&
470 + test_must_fail git history squash --reedit-message start.. &&
471 +
472 + test_cmp_rev "$head_before" HEAD
473 +'
474 +
475 test_expect_success '--update-refs=head only moves HEAD' '
476 git reset --hard three &&
477 git branch -f other HEAD &&