Signed-off-by: Patrick Steinhardt <ps@xxxxxx>
---
Documentation/git-history.adoc | 5 +
builtin/history.c | 104 +++++++++++++++++++++
t/meson.build | 1 +
t/t3454-history-reword.sh | 202 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 312 insertions(+)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 6f0c64b90e..cbbcef3582 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -13,6 +13,7 @@ git history continue
git history quit
git history drop <commit>
git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
+git history reword [<options>] <commit>
git history split [<options>] <commit> [--] [<pathspec>...]
DESCRIPTION
@@ -53,6 +54,10 @@ child commits, as that would lead to an empty branch.
be related to one another and must be reachable from the current `HEAD`
commit.
+`reword <commit> [--message=<message>]`::
+ Rewrite the commit message of the specified commit. All the other
+ details of this commit remain unchanged.
+
`split [--message=<message>] <commit> [--] [<pathspec>...]`::
Interactively split up <commit> into two commits by choosing
hunks introduced by it that will be moved into the new split-out
diff --git a/builtin/history.c b/builtin/history.c
index df04b8dfc6..39acf4df28 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -723,6 +723,108 @@ static int split_commit(struct repository *repo,
return ret;
}
+static int cmd_history_reword(int argc,
+ const char **argv,
+ const char *prefix,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ N_("git history reword [<options>] <commit>"),
+ NULL,
+ };
+ const char *commit_message = NULL;
+ struct option options[] = {
+ OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+ OPT_END(),
+ };
+ struct strbuf final_message = STRBUF_INIT;
+ struct commit *original_commit, *head;
+ struct strvec commits = STRVEC_INIT;
+ struct object_id parent_tree_oid, original_commit_tree_oid;
+ struct object_id rewritten_commit;
+ const char *original_message, *original_body, *ptr;
+ struct oidmap rewritten_commits = OIDMAP_INIT;
+ struct replay_oid_mapping mapping = { 0 };
+ char *original_author = NULL;
+ size_t len;
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
+ if (argc != 1) {
+ ret = error(_("command expects a single revision"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ original_commit = lookup_commit_reference_by_name(argv[0]);
+ if (!original_commit) {
+ ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
+ goto out;
+ }
+
+ head = lookup_commit_reference_by_name("HEAD");
+ if (!head) {
+ ret = error(_("could not resolve HEAD to a commit"));
+ goto out;
+ }
+
+ /*
+ * Collect the list of commits that we'll have to reapply now already.
+ * This ensures that we'll abort early on in case the range of commits
+ * contains merges, which we do not yet handle.
+ */
+ ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
+ head, &commits);
+ if (ret < 0)
+ goto out;
+
+ /* We retain authorship of the original commit. */
+ original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+ ptr = find_commit_header(original_message, "author", &len);
+ if (ptr)
+ original_author = xmemdupz(ptr, len);
+ find_commit_subject(original_message, &original_body);
+
+ if (original_commit->parents)
+ parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+ else
+ oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+ ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
+ original_body, commit_message, "reworded", &final_message);
+ if (ret < 0)
+ goto out;
+
+ ret = commit_tree(final_message.buf, final_message.len,
+ &repo_get_commit_tree(repo, original_commit)->object.oid,
+ original_commit->parents, &rewritten_commit, original_author, NULL);
+ if (ret < 0) {
+ ret = error(_("failed writing reworded commit"));
+ goto out;
+ }
+
+ replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
+
+ mapping.entry.oid = rewritten_commit;
+ mapping.rewritten_oid = original_commit->object.oid;
+ oidmap_put(&rewritten_commits, &mapping);
+
+ ret = apply_commits(repo, &commits, head, original_commit,
+ &rewritten_commits, "reword");
+ if (ret < 0)
+ goto out;
+
+ ret = 0;
+
+out:
+ oidmap_clear(&rewritten_commits, 0);
+ strbuf_release(&final_message);
+ strvec_clear(&commits);
+ free(original_author);
+ return ret;
+}
+
static int cmd_history_split(int argc,
const char **argv,
const char *prefix,
@@ -835,6 +937,7 @@ int cmd_history(int argc,
N_("git history quit"),
N_("git history drop <commit>"),
N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
+ N_("git history reword [<options>] <commit>"),
N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
NULL,
};
@@ -845,6 +948,7 @@ int cmd_history(int argc,
OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
+ OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
OPT_END(),
};
diff --git a/t/meson.build b/t/meson.build
index b3d33c8588..948223f453 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -380,6 +380,7 @@ integration_tests = [
't3451-history-drop.sh',
't3452-history-reorder.sh',
't3453-history-split.sh',
+ 't3454-history-reword.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3454-history-reword.sh b/t/t3454-history-reword.sh
new file mode 100755
index 0000000000..97bdd755fa
--- /dev/null
+++ b/t/t3454-history-reword.sh
@@ -0,0 +1,202 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit ours &&
+ git switch branch &&
+ test_commit theirs &&
+ git switch - &&
+ git merge theirs &&
+ test_must_fail git history reword HEAD~ 2>err &&
+ test_grep "cannot rearrange commit history with merges" err &&
+ test_must_fail git history reword HEAD 2>err &&
+ test_grep "cannot rearrange commit history with merges" err
+ )
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit base file &&
+ echo foo >file &&
+ test_must_fail git history reword HEAD 2>err &&
+ test_grep "Your local changes to the following files would be overwritten" err &&
+ git add file &&
+ test_must_fail git history reword HEAD 2>err &&
+ test_grep "Your local changes to the following files would be overwritten" err
+ )
+'
+
+test_expect_success 'can reword tip of a branch' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+
+ git symbolic-ref HEAD >expect &&
+ git history reword -m "third reworded" HEAD &&
+ git symbolic-ref HEAD >actual &&
+ test_cmp expect actual &&
+
+ cat >expect <<-EOF &&
+ third reworded
+ second
+ first
+ EOF
+ git log --format=%s >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can reword commit in the middle' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+
+ git symbolic-ref HEAD >expect &&
+ git history reword -m "second reworded" HEAD~ &&
+ git symbolic-ref HEAD >actual &&
+ test_cmp expect actual &&
+
+ cat >expect <<-EOF &&
+ third
+ second reworded
+ first
+ EOF
+ git log --format=%s >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can reword root commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+ git history reword -m "first reworded" HEAD~2 &&
+
+ cat >expect <<-EOF &&
+ third
+ second
+ first reworded
+ EOF
+ git log --format=%s >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can use editor to rewrite commit message' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+
+ write_script fake-editor.sh <<-\EOF &&
+ cp "$1" . &&
+ printf "\namend a comment\n" >>"$1"
+ EOF
+ test_set_editor "$(pwd)"/fake-editor.sh &&
+ git history reword HEAD &&
+
+ cat >expect <<-EOF &&
+ first
+
+ # Please enter the commit message for the reworded changes. Lines starting
+ # with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
+ # Changes to be committed:
+ # new file: first.t
+ #
+ EOF
+ test_cmp expect COMMIT_EDITMSG &&
+
+ cat >expect <<-EOF &&
+ first
+
+ amend a comment
+
+ EOF
+ git log --format=%B >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'hooks are executed for rewritten commits' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+
+ write_script .git/hooks/prepare-commit-msg <<-EOF &&
+ echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
+ EOF
+ write_script .git/hooks/post-commit <<-EOF &&
+ echo "post-commit" >>"$(pwd)/hooks.log"
+ EOF
+ write_script .git/hooks/post-rewrite <<-EOF &&
+ {
+ echo "post-rewrite: \$@"
+ cat
+ } >>"$(pwd)/hooks.log"
+ EOF
+
+ git history reword -m "second reworded" HEAD~ &&
+
+ cat >expect <<-EOF &&
+ third
+ second reworded
+ first
+ EOF
+ git log --format=%s >actual &&
+ test_cmp expect actual &&
+
+ cat >expect <<-EOF &&
+ prepare-commit-msg: .git/COMMIT_EDITMSG message
+ post-commit
+ prepare-commit-msg: .git/COMMIT_EDITMSG message
+ post-commit
+ post-rewrite: history
+ $(git rev-parse second) $(git rev-parse HEAD~)
+ $(git rev-parse third) $(git rev-parse HEAD)
+ EOF
+ test_cmp expect hooks.log
+ )
+'
+
+test_expect_success 'aborts with empty commit message' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+
+ test_must_fail git history reword -m "" HEAD 2>err &&
+ test_grep "Aborting commit due to empty commit message." err
+ )
+'
+
+test_done