]> git.ipfire.org Git - thirdparty/git.git/commitdiff
builtin/history: implement "reword" subcommand
authorPatrick Steinhardt <ps@pks.im>
Wed, 3 Dec 2025 10:48:32 +0000 (11:48 +0100)
committerJunio C Hamano <gitster@pobox.com>
Wed, 3 Dec 2025 21:49:45 +0000 (13:49 -0800)
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 major difference is that we do not check out the commit that is to
be reworded. 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.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
Documentation/git-history.adoc
builtin/history.c
t/meson.build
t/t3450-history.sh
t/t3451-history-reword.sh [new file with mode: 0755]

index 67b8ce20408e9b82e02dcdd3a5fd7728cae327b5..160bf5d4d2342b3aa2e0c52452cce864b29cb504 100644 (file)
@@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history of the current branch
 SYNOPSIS
 --------
 [synopsis]
-git history [<options>]
+git history reword <commit>
 
 DESCRIPTION
 -----------
@@ -32,6 +32,11 @@ 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.
+
 CONFIGURATION
 -------------
 
index f6fe32610b0645d1a5a82801ed93b906a1f219ac..17bb150b95d6bfd7e544ce4b88e65c2af96f9c95 100644 (file)
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
+#include "commit-reach.h"
+#include "commit.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 "reset.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>")
+
+static int collect_commits(struct repository *repo,
+                          struct commit *old_commit,
+                          struct commit *new_commit,
+                          struct strvec *out)
+{
+       struct setup_revision_opt revision_opts = {
+               .assume_dashdash = 1,
+       };
+       struct strvec revisions = STRVEC_INIT;
+       struct commit *child;
+       struct rev_info rev = { 0 };
+       int ret;
+
+       repo_init_revisions(repo, &rev, NULL);
+       rev.reverse = 1;
+       strvec_push(&revisions, "");
+       strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
+       if (old_commit) {
+               strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
+               strvec_pushf(&revisions, "--ancestry-path=%s", oid_to_hex(&old_commit->object.oid));
+       }
+
+       setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
+       if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
+               ret = error(_("revision walk setup failed"));
+               goto out;
+       }
+
+       while ((child = get_revision(&rev))) {
+               if (old_commit && !child->parents)
+                       BUG("revision walk did not find child commit");
+               if (child->parents && child->parents->next) {
+                       ret = error(_("cannot rearrange commit history with merges"));
+                       goto out;
+               }
+
+               strvec_push(out, oid_to_hex(&child->object.oid));
+       }
+
+       ret = 0;
+
+out:
+       strvec_clear(&revisions);
+       release_revisions(&rev);
+       reset_revision_walk();
+       return ret;
+}
+
+static int gather_commits_between_head_and_revision(struct repository *repo,
+                                                   const char *revision,
+                                                   struct commit **original_commit,
+                                                   struct commit **parent_commit,
+                                                   struct commit **head,
+                                                   struct strvec *commits)
+{
+       struct commit_list *from_list = NULL;
+       int ret;
+
+       *original_commit = lookup_commit_reference_by_name(revision);
+       if (!*original_commit) {
+               ret = error(_("commit cannot be found: %s"), revision);
+               goto out;
+       }
+
+       *parent_commit = (*original_commit)->parents ? (*original_commit)->parents->item : NULL;
+       if (*parent_commit && repo_parse_commit(repo, *parent_commit)) {
+               ret = error(_("unable to parse commit %s"),
+                           oid_to_hex(&(*parent_commit)->object.oid));
+               goto out;
+       }
+
+       *head = lookup_commit_reference_by_name("HEAD");
+       if (!(*head)) {
+               ret = error(_("could not resolve HEAD to a commit"));
+               goto out;
+       }
+
+       commit_list_append(*original_commit, &from_list);
+       if (!repo_is_descendant_of(repo, *head, from_list)) {
+               ret = error(_("commit must be reachable from current HEAD 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, *parent_commit, *head, commits);
+       if (ret < 0)
+               goto out;
+
+out:
+       free_commit_list(from_list);
+       return ret;
+}
+
+static void replace_commits(struct strvec *commits,
+                           const struct object_id *commit_to_replace,
+                           const struct object_id *replacements,
+                           size_t replacements_nr)
+{
+       char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
+       struct strvec replacement_oids = STRVEC_INIT;
+       bool found = false;
+
+       oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
+       for (size_t i = 0; i < replacements_nr; i++)
+               strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
+
+       for (size_t i = 0; i < commits->nr; i++) {
+               if (strcmp(commits->v[i], commit_to_replace_oid))
+                       continue;
+               strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
+               found = true;
+               break;
+       }
+       if (!found)
+               BUG("could not find commit to replace");
+
+       strvec_clear(&replacement_oids);
+}
+
+static int apply_commits(struct repository *repo,
+                        const struct strvec *commits,
+                        struct commit *onto,
+                        struct commit *orig_head,
+                        const char *action)
+{
+       struct reset_head_opts reset_opts = { 0 };
+       struct strbuf buf = STRBUF_INIT;
+       int ret;
+
+       for (size_t i = 0; i < commits->nr; i++) {
+               struct object_id commit_id;
+               struct commit *commit;
+               const char *end;
+
+               if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
+                                       repo->hash_algo)) {
+                       ret = error(_("invalid object ID: %s"), commits->v[i]);
+                       goto out;
+               }
+
+               commit = lookup_commit(repo, &commit_id);
+               if (!commit || repo_parse_commit(repo, commit)) {
+                       ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
+                       goto out;
+               }
+
+               if (!onto) {
+                       onto = commit;
+               } else {
+                       struct tree *tree = repo_get_commit_tree(repo, commit);
+                       onto = replay_create_commit(repo, tree, commit, onto);
+                       if (!onto) {
+                               ret = -1;
+                               goto out;
+                       }
+               }
+       }
+
+       reset_opts.oid = &onto->object.oid;
+       strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
+       reset_opts.flags = RESET_HEAD_REFS_ONLY | RESET_ORIG_HEAD;
+       reset_opts.orig_head = &orig_head->object.oid;
+       reset_opts.default_reflog_action = action;
+       if (reset_head(repo, &reset_opts) < 0) {
+               ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
+               goto out;
+       }
+
+       ret = 0;
+
+out:
+       strbuf_release(&buf);
+       return ret;
+}
+
+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_commit,
+                                          const struct object_id *new_tree_oid,
+                                          const struct commit_list *parents,
+                                          const struct object_id *parent_tree_oid,
+                                          struct object_id *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;
+       char *original_author = NULL;
+       size_t len;
+       int ret;
+
+       /* 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);
+
+       ret = fill_commit_message(repo, parent_tree_oid, new_tree_oid,
+                                 original_body, action, &commit_message);
+       if (ret < 0)
+               goto out;
+
+       original_extra_headers = read_commit_extra_headers(original_commit, exclude_gpgsig);
+
+       ret = commit_tree_extended(commit_message.buf, commit_message.len, new_tree_oid,
+                                  parents, out, original_author, NULL, NULL,
+                                  original_extra_headers);
+       if (ret < 0)
+               goto out;
+
+out:
+       free_commit_extra_headers(original_extra_headers);
+       strbuf_release(&commit_message);
+       free(original_author);
+       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,
+       };
+       struct option options[] = {
+               OPT_END(),
+       };
+       struct commit *original_commit, *parent, *head;
+       struct strvec commits = STRVEC_INIT;
+       struct object_id parent_tree_oid, original_commit_tree_oid;
+       struct object_id rewritten_commit;
+       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);
+
+       ret = gather_commits_between_head_and_revision(repo, argv[0], &original_commit,
+                                                      &parent, &head, &commits);
+       if (ret < 0)
+               goto out;
+
+       original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
+       if (parent)
+               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. */
+       ret = commit_tree_with_edited_message(repo, "reworded", original_commit,
+                                             &original_commit_tree_oid,
+                                             original_commit->parents, &parent_tree_oid,
+                                             &rewritten_commit);
+       if (ret < 0) {
+               ret = error(_("failed writing reworded commit"));
+               goto out;
+       }
+
+       replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
+
+       ret = apply_commits(repo, &commits, parent, head, "reword");
+       if (ret < 0)
+               goto out;
+
+       ret = 0;
+
+out:
+       strvec_clear(&commits);
+       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);
 }
index 62f5dca09866dfdecaa2ff555939b2ea41c8b3cc..e187aef31ed191bc8a2a6b23549922c93a7c8c28 100644 (file)
@@ -387,6 +387,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',
index 417c343d43b8d36af22f01e4b5faffb108af958f..f513463b92bf433dcddd1ca7716703238e24865d 100755 (executable)
@@ -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 (executable)
index 0000000..2a638d2
--- /dev/null
@@ -0,0 +1,236 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.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
+}
+
+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 unrelated 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 &&
+               test_must_fail git history reword ours 2>err &&
+               test_grep "commit must be reachable from current HEAD commit" 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 &&
+               reword_with_message HEAD <<-EOF &&
+               third reworded
+               EOF
+               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 &&
+
+               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 &&
+
+               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 &&
+               reword_with_message HEAD~2 <<-EOF &&
+               first reworded
+               EOF
+
+               cat >expect <<-EOF &&
+               third
+               second
+               first reworded
+               EOF
+               git log --format=%s >actual &&
+               test_cmp expect actual
+       )
+'
+
+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