Re: [PATCH v8 7/7] builtin/history: implement "reword" subcommand
- From
Elijah Newren <newren@gmail.com>
- Date
- Jan 10, 2026, 01:20 UTC
- Message-ID
- <CABPp-BHFwvg5A295kXkc_axoibNhGDn4ZUkm0uE1u+358xSZzw@mail.gmail.com>
- In-Reply-To
- <20260107-b4-pks-history-builtin-v8-7-18e9779e3a26@pks.im>
On Wed, Jan 7, 2026 at 2:10 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 54 quoted lines
> > Implement a new "reword" subcommand for git-history(1). This subcommand > is similar to the user performing an interactive rebase with a single > commit changed to use the "reword" instruction. > > The "reword" subcommand is built on top of the replay subsystem > instead of the sequencer. This leads to some major differences compared > to git-rebase(1): > > - We do not check out the commit that is to be reworded and instead > perform the operation in-memory. This has the obvious benefit of > being significantly faster compared to git-rebase(1), but even more > importantly it allows the user to rewrite history even if there are > local changes in the working tree or in the index. > > - We do not execute any hooks, even though we leave some room for > changing this in the future. > > - By default, all local branches that contain the commit will be > rewritten. This especially helps with workflows that use stacked > branches. > > Signed-off-by: Patrick Steinhardt <ps@pks.im> > --- > Documentation/git-history.adoc | 23 ++- > builtin/history.c | 392 ++++++++++++++++++++++++++++++++++++++++- > replay.c | 5 +- > t/meson.build | 1 + > t/t3450-history.sh | 6 +- > t/t3451-history-reword.sh | 344 ++++++++++++++++++++++++++++++++++++ > 6 files changed, 758 insertions(+), 13 deletions(-) > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc > index 5a9d931efc..4eea317e5c 100644 > --- a/Documentation/git-history.adoc > +++ b/Documentation/git-history.adoc > @@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history > SYNOPSIS > -------- > [synopsis] > -git history [<options>] > +git history reword <commit> [--ref-action=(branches|head|print)] > > DESCRIPTION > ----------- > @@ -32,8 +32,9 @@ Overall, linkgit:git-history[1] aims to provide a more opinionated way to modify > your commit history that is simpler to use compared to linkgit:git-rebase[1] in > general. > > -If you want to reapply a range of commits onto a different base, or interactive > -rebases if you want to edit a range of commits. > +Use linkgit:git-rebase[1] if you want to reapply a range of commits onto a > +different base, or interactive rebases if you want to edit a range of commits > +at once.
Ah, was the previous sentence here from the former patch just a bad splitting when you were rewriting?
Show 10 quoted lines
> LIMITATIONS > ----------- > @@ -51,6 +52,22 @@ COMMANDS > > Several commands are available to rewrite history in different ways: > > +`reword <commit>`:: > + Rewrite the commit message of the specified commit. All the other > + details of this commit remain unchanged. This command will spawn an > + editor with the current message of that commit.
One isn't exactly "several"; I know you'll add more later, but since this series ends here, should that word be changed?
Show 8 quoted lines
> + > +OPTIONS > +------- > + > +`--ref-action=(branches|head|print)`:: > + Control which references will be updated by the command, if any. With > + `branches`, all local branches that point to commits which are > + decendants of the original commit will be rewritten. With `head`, only
decendants -> descendants . Or maybe double down on the typo and extend it a bit into either 'decedent' or 'decadent'. That could be fun.
Show 142 quoted lines
> + the current `HEAD` reference will be rewritten. With `print`, all
> + updates as they would be performed with `branches` are printed in a
> + format that can be consumed by linkgit:git-update-ref[1].
> +
> GIT
> ---
> Part of the linkgit:git[1] suite
> diff --git a/builtin/history.c b/builtin/history.c
> index f6fe32610b..59011ea517 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -1,22 +1,404 @@
> +#define USE_THE_REPOSITORY_VARIABLE
> +
> #include "builtin.h"
> +#include "commit.h"
> +#include "commit-reach.h"
> +#include "config.h"
> +#include "editor.h"
> +#include "environment.h"
> #include "gettext.h"
> +#include "hex.h"
> #include "parse-options.h"
> +#include "refs.h"
> +#include "replay.h"
> +#include "revision.h"
> +#include "sequencer.h"
> +#include "strvec.h"
> +#include "tree.h"
> +#include "wt-status.h"
> +
> +#define GIT_HISTORY_REWORD_USAGE \
> + N_("git history reword <commit> [--ref-action=(branches|head|print)]")
> +
> +static void change_data_free(void *util, const char *str UNUSED)
> +{
> + struct wt_status_change_data *d = util;
> + free(d->rename_source);
> + free(d);
> +}
> +
> +static int fill_commit_message(struct repository *repo,
> + const struct object_id *old_tree,
> + const struct object_id *new_tree,
> + const char *default_message,
> + const char *action,
> + struct strbuf *out)
> +{
> + const char *path = git_path_commit_editmsg();
> + const char *hint =
> + _("Please enter the commit message for the %s changes."
> + " Lines starting\nwith '%s' will be ignored, and an"
> + " empty message aborts the commit.\n");
> + struct wt_status s;
> +
> + strbuf_addstr(out, default_message);
> + strbuf_addch(out, '\n');
> + strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
> + write_file_buf(path, out->buf, out->len);
> +
> + wt_status_prepare(repo, &s);
> + FREE_AND_NULL(s.branch);
> + s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
> + s.commit_template = 1;
> + s.colopts = 0;
> + s.display_comment_prefix = 1;
> + s.hints = 0;
> + s.use_color = 0;
> + s.whence = FROM_COMMIT;
> + s.committable = 1;
> +
> + s.fp = fopen(git_path_commit_editmsg(), "a");
> + if (!s.fp)
> + return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
> +
> + wt_status_collect_changes_trees(&s, old_tree, new_tree);
> + wt_status_print(&s);
> + wt_status_collect_free_buffers(&s);
> + string_list_clear_func(&s.change, change_data_free);
> +
> + strbuf_reset(out);
> + if (launch_editor(path, out, NULL)) {
> + fprintf(stderr, _("Aborting commit as launching the editor failed.\n"));
> + return -1;
> + }
> + strbuf_stripspace(out, comment_line_str);
> +
> + cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
> +
> + if (!out->len) {
> + fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
> + return -1;
> + }
> +
> + return 0;
> +}
> +
> +static int commit_tree_with_edited_message(struct repository *repo,
> + const char *action,
> + struct commit *original,
> + struct commit **out)
> +{
> + const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
> + const char *original_message, *original_body, *ptr;
> + struct commit_extra_header *original_extra_headers = NULL;
> + struct strbuf commit_message = STRBUF_INIT;
> + struct object_id rewritten_commit_oid;
> + struct object_id original_tree_oid;
> + struct object_id parent_tree_oid;
> + char *original_author = NULL;
> + struct commit *parent;
> + size_t len;
> + int ret;
> +
> + original_tree_oid = repo_get_commit_tree(repo, original)->object.oid;
> +
> + parent = original->parents ? original->parents->item : NULL;
> + if (parent) {
> + if (repo_parse_commit(repo, parent)) {
> + ret = error(_("unable to parse parent commit %s"),
> + oid_to_hex(&parent->object.oid));
> + goto out;
> + }
> +
> + parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
> + } else {
> + oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
> + }
> +
> + /* We retain authorship of the original commit. */
> + original_message = repo_logmsg_reencode(repo, original, NULL, NULL);
> + ptr = find_commit_header(original_message, "author", &len);
> + if (ptr)
> + original_author = xmemdupz(ptr, len);
> + find_commit_subject(original_message, &original_body);
> +
> + ret = fill_commit_message(repo, &parent_tree_oid, &original_tree_oid,
> + original_body, action, &commit_message);
> + if (ret < 0)
> + goto out;
> +
> + original_extra_headers = read_commit_extra_headers(original, exclude_gpgsig);Does this grab encoding? If so, should it be excluded as well given the repo_logmsg_reencode() call?
Show 106 quoted lines
> +
> + ret = commit_tree_extended(commit_message.buf, commit_message.len, &original_tree_oid,
> + original->parents, &rewritten_commit_oid, original_author,
> + NULL, NULL, original_extra_headers);
> + if (ret < 0)
> + goto out;
> +
> + *out = lookup_commit_or_die(&rewritten_commit_oid, "rewritten commit");
> +
> +out:
> + free_commit_extra_headers(original_extra_headers);
> + strbuf_release(&commit_message);
> + free(original_author);
> + return ret;
> +}
> +
> +enum ref_action {
> + REF_ACTION_DEFAULT,
> + REF_ACTION_BRANCHES,
> + REF_ACTION_HEAD,
> + REF_ACTION_PRINT,
> +};
> +
> +static int parse_ref_action(const struct option *opt, const char *value, int unset)
> +{
> + enum ref_action *action = opt->value;
> +
> + BUG_ON_OPT_NEG_NOARG(unset, value);
> + if (!strcmp(value, "branches")) {
> + *action = REF_ACTION_BRANCHES;
> + } else if (!strcmp(value, "head")) {
> + *action = REF_ACTION_HEAD;
> + } else if (!strcmp(value, "print")) {
> + *action = REF_ACTION_PRINT;
> + } else {
> + return error(_("%s expects one of 'branches', 'head' or 'print'"),
> + opt->long_name);
> + }
> +
> + return 0;
> +}
> +
> +static int handle_reference_updates(enum ref_action action,
> + struct repository *repo,
> + struct commit *original,
> + struct commit *rewritten,
> + const char *reflog_msg)
> +{
> + const struct name_decoration *decoration;
> + struct replay_revisions_options opts = { 0 };
> + struct replay_ref_updates updates = {
> + .final_oid = rewritten->object.oid,
> + };
> + struct ref_transaction *transaction = NULL;
> + struct strvec args = STRVEC_INIT;
> + struct strbuf err = STRBUF_INIT;
> + struct commit *head = NULL;
> + struct rev_info revs;
> + char hex[GIT_MAX_HEXSZ + 1];
> + int ret;
> +
> + repo_init_revisions(repo, &revs, NULL);
> + strvec_push(&args, "ignored");
> + strvec_push(&args, "--reverse");
> + strvec_push(&args, "--topo-order");
> + strvec_push(&args, "--full-history");
> +
> + /* We only want to see commits that are descendants of the old commit. */
> + strvec_pushf(&args, "--ancestry-path=%s",
> + oid_to_hex(&original->object.oid));
> +
> + /*
> + * Ancestry path may also show ancestors of the old commit, but we
> + * don't want to see those, either.
> + */
> + strvec_pushf(&args, "^%s", oid_to_hex(&original->object.oid));
> +
> + /*
> + * When we're asked to update HEAD we need to verify that the commit
> + * that we want to rewrite is actually an ancestor of it and, if so,
> + * update it. Otherwise we'll update (or print) all descendant
> + * branches.
> + */
> + if (action == REF_ACTION_HEAD) {
> + struct commit_list *from_list = NULL;
> +
> + head = lookup_commit_reference_by_name("HEAD");
> + if (!head) {
> + ret = error(_("cannot look up HEAD"));
> + goto out;
> + }
> +
> + commit_list_insert(original, &from_list);
> + ret = repo_is_descendant_of(repo, head, from_list);
> + free_commit_list(from_list);
> +
> + if (ret < 0) {
> + ret = error(_("cannot determine descendance"));
> + goto out;
> + } else if (!ret) {
> + ret = error(_("rewritten commit must be an ancestor "
> + "of HEAD when using --ref-action=head"));
> + goto out;
> + }
> +
> + strvec_push(&args, oid_to_hex(&head->object.oid));If you used "HEAD" here instead of its oid, then you wouldn't need a separate codepath for REF_ACTION_HEAD in your "switch(action)" block below.
Show 41 quoted lines
> + } else {
> + strvec_push(&args, "--branches");
> + }
> +
> + setup_revisions_from_strvec(&args, &revs, NULL);
> + if (revs.nr)
> + BUG("revisions were set up with invalid argument '%s'", args.v[0]);
> +
> + opts.onto = oid_to_hex_r(hex, &rewritten->object.oid);
> +
> + ret = replay_revisions(repo, &revs, &opts, &updates);
> + if (ret)
> + goto out;
> +
> + switch (action) {
> + case REF_ACTION_DEFAULT:
> + case REF_ACTION_BRANCHES:
> + transaction = ref_store_transaction_begin(get_main_ref_store(repo), 0, &err);
> + if (!transaction) {
> + ret = error(_("failed to begin ref transaction: %s"), err.buf);
> + goto out;
> + }
> +
> + for (size_t i = 0; i < updates.nr; i++) {
> + ret = ref_transaction_update(transaction,
> + updates.items[i].refname,
> + &updates.items[i].new_oid,
> + &updates.items[i].old_oid,
> + NULL, NULL, 0, reflog_msg, &err);
> + if (ret) {
> + ret = error(_("failed to update ref '%s': %s"),
> + updates.items[i].refname, err.buf);
> + goto out;
> + }
> + }
> +
> + /*
> + * `replay_revisions()` only updates references that are
> + * ancestors of `rewritten`, so we need to manually
> + * handle updating references that point to `original`.
> + */This is a good catch; I was wondering if there was a way to put this logic into replay_revisions() so that other callers need not duplicate it, but since it just takes the revisions to walk over and that list is empty, it'd somehow need to know about original->object.oid; it doesn't have that info. Hmmm...
Show 32 quoted lines
> + for (decoration = get_name_decoration(&original->object);
> + decoration;
> + decoration = decoration->next)
> + {
> + if (decoration->type != DECORATION_REF_LOCAL)
> + continue;
> +
> + ret = ref_transaction_update(transaction,
> + decoration->name,
> + &rewritten->object.oid,
> + &original->object.oid,
> + NULL, NULL, 0, reflog_msg, &err);
> + if (ret) {
> + ret = error(_("failed to update ref '%s': %s"),
> + decoration->name, err.buf);
> + goto out;
> + }
> + }
> +
> + if (ref_transaction_commit(transaction, &err)) {
> + ret = error(_("failed to commit ref transaction: %s"), err.buf);
> + goto out;
> + }
> +
> + break;
> + case REF_ACTION_HEAD:
> + ret = refs_update_ref(get_main_ref_store(repo), reflog_msg, "HEAD",
> + &updates.final_oid, &head->object.oid, 0,
> + UPDATE_REFS_MSG_ON_ERR);
> + if (ret)
> + goto out;
> + break;As noted above, you wouldn't need this separate REF_ACTION_HEAD block if you passed either "HEAD" or the refname it resolved to instead of passing its oid to the revision range.
Show 112 quoted lines
> + case REF_ACTION_PRINT:
> + for (size_t i = 0; i < updates.nr; i++)
> + printf("update %s %s %s\n",
> + updates.items[i].refname,
> + oid_to_hex(&updates.items[i].new_oid),
> + oid_to_hex(&updates.items[i].old_oid));
> + break;
> + default:
> + BUG("unsupported ref action %d", action);
> + }
> +
> + ret = 0;
> +
> +out:
> + replay_ref_updates_release(&updates);
> + ref_transaction_free(transaction);
> + release_revisions(&revs);
> + strbuf_release(&err);
> + strvec_clear(&args);
> + return ret;
> +}
> +
> +static int cmd_history_reword(int argc,
> + const char **argv,
> + const char *prefix,
> + struct repository *repo)
> +{
> + const char * const usage[] = {
> + GIT_HISTORY_REWORD_USAGE,
> + NULL,
> + };
> + enum ref_action action = REF_ACTION_DEFAULT;
> + struct option options[] = {
> + OPT_CALLBACK_F(0, "ref-action", &action, N_("<action>"),
> + N_("control ref update behavior (branches|head|print)"),
> + PARSE_OPT_NONEG, parse_ref_action),
> + OPT_END(),
> + };
> + struct strbuf reflog_msg = STRBUF_INIT;
> + struct commit *original, *rewritten;
> + 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 = lookup_commit_reference_by_name(argv[0]);
> + if (!original) {
> + ret = error(_("commit cannot be found: %s"), argv[0]);
> + goto out;
> + }
> +
> + ret = commit_tree_with_edited_message(repo, "reworded", original, &rewritten);
> + if (ret < 0) {
> + ret = error(_("failed writing reworded commit"));
> + goto out;
> + }
> +
> + strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
> +
> + ret = handle_reference_updates(action, repo, original, rewritten,
> + reflog_msg.buf);
> + if (ret < 0) {
> + ret = error(_("failed replaying descendants"));
> + goto out;
> + }
> +
> + ret = 0;
> +
> +out:
> + strbuf_release(&reflog_msg);
> + return ret;
> +}
>
> int cmd_history(int argc,
> const char **argv,
> const char *prefix,
> - struct repository *repo UNUSED)
> + struct repository *repo)
> {
> const char * const usage[] = {
> - N_("git history [<options>]"),
> + GIT_HISTORY_REWORD_USAGE,
> NULL,
> };
> + parse_opt_subcommand_fn *fn = NULL;
> struct option options[] = {
> + OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
> OPT_END(),
> };
>
> argc = parse_options(argc, argv, prefix, options, usage, 0);
> - if (argc)
> - usagef("unrecognized argument: %s", argv[0]);
> - return 0;
> + return fn(argc, argv, prefix, repo);
> }
> diff --git a/replay.c b/replay.c
> index 8c2f2d3710..5203f9db4c 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -254,7 +254,9 @@ int replay_revisions(struct repository *repo, struct rev_info *revs,
> struct commit *commit;
> struct commit *onto = NULL;
> struct merge_options merge_opt;
> - struct merge_result result;
> + struct merge_result result = {
> + .clean = 1,
> + };Wait, what? Why is this being initialized this way?
Same as I said over in https://lore.kernel.org/git/CABPp-BEh7VEM6UQjkK3CxJcv54vEmueTmh9+-SyTKUxgy7Mkcg@mail.gmail.com/, why is this change here? Was this due to hitting an empty range? Actually, while supporting empty ranges didn't make sense back when I mentioned it to Siddharth (because users always specified the ranges), I think it actually does make sense now that ranges are implicit. Someone could use "git history reword HEAD" (even if "git commit --amend" already exists), and that'd result in an empty range. So, I think the change makes sense now, but I think this particular change really ought to be documented and motivated in a separate commit message rather than lumped in with the other changes in this commit.
Show 8 quoted lines
> char *advance; > int ret; > > @@ -270,7 +272,6 @@ int replay_revisions(struct repository *repo, struct rev_info *revs, > } > > init_basic_merge_options(&merge_opt, repo); > - memset(&result, 0, sizeof(result));
And this is related to the new initialization of result, so this change would be part of that separate patch.
Show 389 quoted lines
> merge_opt.show_rename_progress = 0;
> last_commit = onto;
> replayed_commits = kh_init_oid_map();
> diff --git a/t/meson.build b/t/meson.build
> index 73006b095a..c9f92450dc 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -388,6 +388,7 @@ integration_tests = [
> 't3437-rebase-fixup-options.sh',
> 't3438-rebase-broken-files.sh',
> 't3450-history.sh',
> + 't3451-history-reword.sh',
> 't3500-cherry.sh',
> 't3501-revert-cherry-pick.sh',
> 't3502-cherry-pick-merge.sh',
> diff --git a/t/t3450-history.sh b/t/t3450-history.sh
> index 417c343d43..f513463b92 100755
> --- a/t/t3450-history.sh
> +++ b/t/t3450-history.sh
> @@ -5,13 +5,13 @@ test_description='tests for git-history command'
> . ./test-lib.sh
>
> test_expect_success 'does nothing without any arguments' '
> - git history >out 2>&1 &&
> - test_must_be_empty out
> + test_must_fail git history 2>err &&
> + test_grep "need a subcommand" err
> '
>
> test_expect_success 'raises an error with unknown argument' '
> test_must_fail git history garbage 2>err &&
> - test_grep "unrecognized argument: garbage" err
> + test_grep "unknown subcommand: .garbage." err
> '
>
> test_done
> diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
> new file mode 100755
> index 0000000000..cd5883051d
> --- /dev/null
> +++ b/t/t3451-history-reword.sh
> @@ -0,0 +1,344 @@
> +#!/bin/sh
> +
> +test_description='tests for git-history reword subcommand'
> +
> +. ./test-lib.sh
> +. "$TEST_DIRECTORY/lib-log-graph.sh"
> +
> +reword_with_message () {
> + cat >message &&
> + write_script fake-editor.sh <<-\EOF &&
> + cp message "$1"
> + EOF
> + test_set_editor "$(pwd)"/fake-editor.sh &&
> + git history reword "$@" &&
> + rm fake-editor.sh message
> +}
> +
> +expect_graph () {
> + cat >expect &&
> + lib_test_cmp_graph --graph --format=%s "$@"
> +}
> +
> +expect_log () {
> + git log --format="%s" "$@" >actual &&
> + cat >expect &&
> + test_cmp expect actual
> +}
> +
> +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 &&
> + reword_with_message HEAD <<-EOF &&
> + third reworded
> + EOF
> + git symbolic-ref HEAD >actual &&
> + test_cmp expect actual &&
> +
> + expect_log <<-\EOF &&
> + third reworded
> + second
> + first
> + EOF
> +
> + git reflog >reflog &&
> + test_grep "reword: updating HEAD" reflog
> + )
> +'
> +
> +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 &&
> + reword_with_message HEAD~ <<-EOF &&
> + second reworded
> + EOF
> + git symbolic-ref HEAD >actual &&
> + test_cmp expect actual &&
> +
> + expect_log <<-\EOF
> + third
> + second reworded
> + first
> + EOF
> + )
> +'
> +
> +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 &&
> + reword_with_message HEAD~2 <<-EOF &&
> + first reworded
> + EOF
> +
> + expect_log <<-\EOF
> + third
> + second
> + first reworded
> + EOF
> + )
> +'
> +
> +test_expect_success 'can reword in a bare repo' '
> + test_when_finished "rm -rf repo repo.git" &&
> + git init repo &&
> + test_commit -C repo first &&
> + git clone --bare repo repo.git &&
> + (
> + cd repo.git &&
> + reword_with_message HEAD <<-EOF &&
> + reworded
> + EOF
> +
> + expect_log <<-\EOF
> + reworded
> + EOF
> + )
> +'
> +
> +test_expect_success 'can reword a commit on a different branch' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit base &&
> + git branch theirs &&
> + test_commit ours &&
> + git switch theirs &&
> + test_commit theirs &&
> +
> + git rev-parse ours >ours-before &&
> + reword_with_message theirs <<-EOF &&
> + Reworded theirs
> + EOF
> + git rev-parse ours >ours-after &&
> + test_cmp ours-before ours-after &&
> +
> + expect_graph --branches <<-\EOF
> + * Reworded theirs
> + | * ours
> + |/
> + * base
> + EOF
> + )
> +'
> +
> +test_expect_success 'can reword a merge commit' '
> + 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 &&
> +
> + # It is not possible to replay merge commits embedded in the
> + # history (yet).
> + test_must_fail git history reword HEAD~ 2>err &&
> + test_grep "replaying merge commits is not supported yet" err &&
> +
> + # But it is possible to reword a merge commit directly.
> + reword_with_message HEAD <<-EOF &&
> + Reworded merge commit
> + EOF
> + expect_graph <<-\EOF
> + * Reworded merge commit
> + |\
> + | * theirs
> + * | ours
> + |/
> + * base
> + EOF
> + )
> +'
> +
> +test_expect_success '--ref-action=print prints ref updates without modifying repo' '
> + test_when_finished "rm -rf repo" &&
> + git init repo --initial-branch=main &&
> + (
> + cd repo &&
> + test_commit base &&
> + git branch branch &&
> + test_commit ours &&
> + git switch branch &&
> + test_commit theirs &&
> +
> + git refs list >refs-expect &&
> + reword_with_message --ref-action=print base >updates <<-\EOF &&
> + reworded commit
> + EOF
> + git refs list >refs-actual &&
> + test_cmp refs-expect refs-actual &&
> +
> + test_grep "update refs/heads/branch" updates &&
> + test_grep "update refs/heads/main" updates &&
> + git update-ref --stdin <updates &&
> + expect_log --branches <<-\EOF
> + theirs
> + ours
> + reworded commit
> + EOF
> + )
> +'
> +
> +test_expect_success '--ref-action=head updates only HEAD' '
> + test_when_finished "rm -rf repo" &&
> + git init repo --initial-branch=main &&
> + (
> + cd repo &&
> + test_commit base &&
> + git branch branch &&
> + test_commit theirs &&
> + git switch branch &&
> + test_commit ours &&
> +
> + # When told to update HEAD, only, the command will refuse to
> + # rewrite commits that are not an ancestor of HEAD.
> + test_must_fail git history reword --ref-action=head theirs 2>err &&
> + test_grep "rewritten commit must be an ancestor of HEAD" err &&
> +
> + reword_with_message --ref-action=head base >updates <<-\EOF &&
> + reworded base
> + EOF
> + expect_log HEAD <<-\EOF &&
> + ours
> + reworded base
> + EOF
> + expect_log main <<-\EOF
> + theirs
> + base
> + EOF
> + )
> +'
> +
> +test_expect_success 'editor shows proper status' '
> + 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 ignored, and an empty message aborts the commit.
> + # Changes to be committed:
> + # new file: first.t
> + #
> + EOF
> + test_cmp expect COMMIT_EDITMSG &&
> +
> + test_commit_message HEAD <<-\EOF
> + first
> +
> + amend a comment
> + EOF
> + )
> +'
> +
> +# For now, git-history(1) does not yet execute any hooks. This is subject to
> +# change in the future, and if it does this test here is expected to start
> +# failing. In other words, this test is not an endorsement of the current
> +# status quo.
> +test_expect_success 'hooks are not executed for rewritten commits' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit first &&
> + test_commit second &&
> + test_commit third &&
> +
> + ORIG_PATH="$(pwd)" &&
> + export ORIG_PATH &&
> + for hook in prepare-commit-msg pre-commit post-commit post-rewrite commit-msg
> + do
> + write_script .git/hooks/$hook <<-\EOF || exit 1
> + touch "$ORIG_PATH/hooks.log
> + EOF
> + done &&
> +
> + reword_with_message HEAD~ <<-EOF &&
> + second reworded
> + EOF
> +
> + cat >expect <<-EOF &&
> + third
> + second reworded
> + first
> + EOF
> + git log --format=%s >actual &&
> + test_cmp expect actual &&
> +
> + test_path_is_missing hooks.log
> + )
> +'
> +
> +test_expect_success 'aborts with empty commit message' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit first &&
> +
> + ! reword_with_message HEAD 2>err </dev/null &&
> + test_grep "Aborting commit due to empty commit message." err
> + )
> +'
> +
> +test_expect_success 'retains changes in the worktree and index' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + touch a b &&
> + git add . &&
> + git commit -m "initial commit" &&
> + echo foo >a &&
> + echo bar >b &&
> + git add b &&
> + reword_with_message HEAD <<-EOF &&
> + message
> + EOF
> + cat >expect <<-\EOF &&
> + M a
> + M b
> + ?? actual
> + ?? expect
> + EOF
> + git status --porcelain >actual &&
> + test_cmp expect actual
> + )
> +'
> +
> +test_done
>
> --
> 2.52.0.542.g9473a8513b.dirty