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 &&