Re: [PATCH v4 06/12] builtin/history: implement "reword" subcommand
- From
Karthik Nayak <karthik.188@gmail.com>
- Date
- Oct 14, 2025, 11:04 UTC
- Message-ID
- <CAOLa=ZSU8yr9Gn0EZ7x705qPyVM-qiMjgMCNCb8p8SMGTToxqQ@mail.gmail.com>
- In-Reply-To
- <20251001-b4-pks-history-builtin-v4-6-8e61ddb86317@pks.im>
Patrick Steinhardt <ps@pks.im> writes:
> Implement a new "reword" subcommand for git-history(1). This subcommand > is essentially the same as if a user performed an interactive rebase > with a single commit changed to use the "reword" verb. >
[snip]
Show 16 quoted lines
> diff --git a/builtin/history.c b/builtin/history.c > index f6fe32610b..7b2a0023e8 100644 > --- a/builtin/history.c > +++ b/builtin/history.c > @@ -1,22 +1,389 @@ > +#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 "oidmap.h"
Nit: This can be dropped, perhaps needed in a future patch?
Show 28 quoted lines
> #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"
> +
> +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_list *from_list = NULL;
> + struct commit *child;
> + struct rev_info rev = { 0 };
> + int ret;
> +
> + /*
> + * Check that the old commit actually is an ancestor of HEAD. If not
> + * the whole request becomes nonsensical.
> + */Missing space here
Show 7 quoted lines
> + if (old_commit) {
> + commit_list_insert(old_commit, &from_list);
> + if (!repo_is_descendant_of(repo, new_commit, from_list)) {
> + ret = error(_("commit must be reachable from current HEAD commit"));
> + goto out;
> + }
> + }Makes sense. There is an inherent assumption using the 'git history' command that you want to modify the history of the current reference.
One question, wouldn't it make sense to parse and check that the commit to be reworded should be checked to be a descendant of HEAD earlier on in `cmd_history_reword()`?
This would ensure this function `collect_commits()` doesn't worry about how it is meant to be used, and simply worries about collecting commits.
Show 27 quoted lines
> + repo_init_revisions(repo, &rev, NULL);
> + 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));
> +
> + 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));
> +
> + if (child->parents && old_commit &&
> + commit_list_contains(old_commit, child->parents))
> + break;
> + }
> +Okay makes sense here, we collect all the commits we break as soon as we reach old_commit. Since we check for merges at the start of the loop, the history should be linear.
[snip]
Show 25 quoted lines
> +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);
> +}So this basically goes over the commits that we pass and replaces a single commit with a set of commits. In our case that would be
C0 C1 C2 ... CN └─(reword) └─(HEAD)
↓ (rewrites to)
R0 R1 R2 ... RN C1 C2 ... CN
└────────────┘ └─(HEAD)
(new commits)Makes sense. I assume we use a list of replacements here for future commands.
Show 15 quoted lines
> +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 merge_options merge_opts = { 0 };
> + struct merge_result result = { 0 };
> + struct strbuf buf = STRBUF_INIT;
> + kh_oid_map_t *replayed_commits;
> + int ret;
> +
> + replayed_commits = kh_init_oid_map();
> +So this is used to the pass the replayed_commits list to the replay mechanism so that the appropriate base commit is selected.
Show 26 quoted lines
> + init_basic_merge_options(&merge_opts, repo);
> + merge_opts.show_rename_progress = 0;
> +
> + for (size_t i = 0; i < commits->nr; i++) {
> + struct object_id commit_id;
> + struct commit *commit;
> + const char *end;
> + int hash_result;
> + khint_t pos;
> +
> + 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;
> + result.clean = 1;
> + result.tree = repo_get_commit_tree(repo, commit);So if there is no onto, we're starting at the root commit and expect a clean merge.
Show 5 quoted lines
> + } else {
> + onto = replay_pick_regular_commit(repo, commit, replayed_commits,
> + onto, &merge_opts, &result);
> + if (!onto)
> + break;Else we will replay the current commit onto the prev 'onto' commit.
Show 26 quoted lines
> + }
> +
> + pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hash_result);
> + if (hash_result == 0) {
> + ret = error(_("duplicate rewritten commit: %s\n"),
> + oid_to_hex(&commit->object.oid));
> + goto out;
> + }
> + kh_value(replayed_commits, pos) = onto;
> + }
> +
> + if (!result.clean) {
> + ret = error(_("could not merge"));
> + 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;
> + }
> +We finally update the HEAD reference also.
Show 29 quoted lines
> + ret = 0;
> +
> +out:
> + kh_destroy_oid_map(replayed_commits);
> + merge_finalize(&merge_opts, &result);
> + 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 *provided_message,
> + const char *action,
> + struct strbuf *out)
> +{
> + if (!provided_message) {
> + const char *path = git_path_commit_editmsg();
> + const char *hint =
> + _("Please enter the commit message for the %s changes. Lines starting\n"
> + "with '%s' will be kept; you may remove them yourself if you want to.\n");Shouldn't this be s/kept/removed? Also this line needs to be aligned.
Show 27 quoted lines
> + 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);
> +Nice, so we show the user the diff tree and then ask them to modify the existing message as they want.
Show 26 quoted lines
> + strbuf_reset(out);
> + if (launch_editor(path, out, NULL)) {
> + fprintf(stderr, _("Please supply the message using the -m option.\n"));
> + return -1;
> + }
> + strbuf_stripspace(out, comment_line_str);
> + } else {
> + strbuf_addstr(out, provided_message);
> + }
> +
> + 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 cmd_history_reword(int argc,
> + const char **argv,
> + const char *prefix,
> + struct repository *repo)
> +{
> + const char * const usage[] = {Same as the prev commit, this should be '*const'.
Show 25 quoted lines
> + 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, *parent, *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;
> + 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);
> +Right. We only expect something like 'git history reword @~10', so we allow only one commit to be re-worded at a given time.
Show 5 quoted lines
> + 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;
> + }So here we want to parse the commit-ish name to find the commit.
Show 5 quoted lines
> + if (repo_parse_commit(repo, original_commit)) {
> + ret = error(_("unable to parse commit %s"),
> + oid_to_hex(&original_commit->object.oid));
> + goto out;
> + }Isn't this already done as part of `lookup_commit_reference_by_name_gently()` which is called by `lookup_commit_reference_by_name()` ?
> + original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid; > +
Okay we get the tree of the commit, I assume this is used to create the new commit and replay the children on top of it.
Show 27 quoted lines
> + parent = original_commit->parents ? original_commit->parents->item : NULL;
> + if (parent) {
> + if (repo_parse_commit(repo, parent)) {
> + ret = error(_("unable to parse 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);
> + }
> +
> + 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, parent, head, &commits);
> + if (ret < 0)
> + goto out;
> +The user is currently at HEAD and wishes to reword a commit.
PN ... P2 P1 C0 C1 ... CN
└─(reword) └─(HEAD)So we want to re-word C0, so we need to collect P1..CN.
Show 11 quoted lines
> + /* 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, &original_commit_tree_oid, > + original_body, commit_message, "reworded", &final_message); > + if (ret < 0) > + goto out;
We obtain the new message from the user.
> + > + ret = commit_tree(final_message.buf, final_message.len, > + &repo_get_commit_tree(repo, original_commit)->object.oid,
Can't we use original_commit_tree_oid here?
Show 8 quoted lines
> + 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);
> +Okay we replace the commits we obtained with the new rewritten commit.
Show 6 quoted lines
> + ret = apply_commits(repo, &commits, parent, head, "reword"); > + if (ret < 0) > + goto out; > + > + ret = 0; > +
So we're now asking to replay the commits onto parent and update HEAD. Looks good.
Show 16 quoted lines
> +out:
> + strbuf_release(&final_message);
> + strvec_clear(&commits);
> + free(original_author);
> + 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>]"),
> + N_("git history reword [<options>] <commit>"),This string is used twice, perhaps we move it to a macro?
[snip]
The tests looked good too!