+#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);
+
+ 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));
+ } 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`.
+ */
+ 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;
+ 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);
}
--- /dev/null
+#!/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