Skip to content

Commit 8ebb0ce

Browse files
pks-tgitster
authored andcommitted
builtin/history: implement "reword" subcommand
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. Signed-off-by: Patrick Steinhardt <ps@pks.im> Signed-off-by: Junio C Hamano <gitster@pobox.com>
1 parent 85ae602 commit 8ebb0ce

File tree

5 files changed

+573
-9
lines changed

5 files changed

+573
-9
lines changed

Documentation/git-history.adoc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history of the current branch
88
SYNOPSIS
99
--------
1010
[synopsis]
11-
git history [<options>]
11+
git history reword <commit>
1212

1313
DESCRIPTION
1414
-----------
@@ -32,6 +32,11 @@ COMMANDS
3232

3333
Several commands are available to rewrite history in different ways:
3434

35+
`reword <commit>`::
36+
Rewrite the commit message of the specified commit. All the other
37+
details of this commit remain unchanged. This command will spawn an
38+
editor with the current message of that commit.
39+
3540
CONFIGURATION
3641
-------------
3742

builtin/history.c

Lines changed: 326 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,343 @@
1+
#define USE_THE_REPOSITORY_VARIABLE
2+
13
#include "builtin.h"
4+
#include "commit-reach.h"
5+
#include "commit.h"
6+
#include "config.h"
7+
#include "editor.h"
8+
#include "environment.h"
29
#include "gettext.h"
10+
#include "hex.h"
311
#include "parse-options.h"
12+
#include "refs.h"
13+
#include "replay.h"
14+
#include "reset.h"
15+
#include "revision.h"
16+
#include "sequencer.h"
17+
#include "strvec.h"
18+
#include "tree.h"
19+
#include "wt-status.h"
20+
21+
#define GIT_HISTORY_REWORD_USAGE N_("git history reword <commit>")
22+
23+
static int collect_commits(struct repository *repo,
24+
struct commit *old_commit,
25+
struct commit *new_commit,
26+
struct strvec *out)
27+
{
28+
struct setup_revision_opt revision_opts = {
29+
.assume_dashdash = 1,
30+
};
31+
struct strvec revisions = STRVEC_INIT;
32+
struct commit *child;
33+
struct rev_info rev = { 0 };
34+
int ret;
35+
36+
repo_init_revisions(repo, &rev, NULL);
37+
strvec_push(&revisions, "");
38+
strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
39+
if (old_commit)
40+
strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
41+
42+
setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
43+
if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
44+
ret = error(_("revision walk setup failed"));
45+
goto out;
46+
}
47+
48+
while ((child = get_revision(&rev))) {
49+
if (old_commit && !child->parents)
50+
BUG("revision walk did not find child commit");
51+
if (child->parents && child->parents->next) {
52+
ret = error(_("cannot rearrange commit history with merges"));
53+
goto out;
54+
}
55+
56+
strvec_push(out, oid_to_hex(&child->object.oid));
57+
58+
if (child->parents && old_commit &&
59+
commit_list_contains(old_commit, child->parents))
60+
break;
61+
}
62+
63+
/*
64+
* Revisions are in newest-order-first. We have to reverse the
65+
* array though so that we pick the oldest commits first.
66+
*/
67+
for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
68+
SWAP(out->v[i], out->v[j]);
69+
70+
ret = 0;
71+
72+
out:
73+
strvec_clear(&revisions);
74+
release_revisions(&rev);
75+
reset_revision_walk();
76+
return ret;
77+
}
78+
79+
static void replace_commits(struct strvec *commits,
80+
const struct object_id *commit_to_replace,
81+
const struct object_id *replacements,
82+
size_t replacements_nr)
83+
{
84+
char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
85+
struct strvec replacement_oids = STRVEC_INIT;
86+
bool found = false;
87+
88+
oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
89+
for (size_t i = 0; i < replacements_nr; i++)
90+
strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
91+
92+
for (size_t i = 0; i < commits->nr; i++) {
93+
if (strcmp(commits->v[i], commit_to_replace_oid))
94+
continue;
95+
strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
96+
found = true;
97+
break;
98+
}
99+
if (!found)
100+
BUG("could not find commit to replace");
101+
102+
strvec_clear(&replacement_oids);
103+
}
104+
105+
static int apply_commits(struct repository *repo,
106+
const struct strvec *commits,
107+
struct commit *onto,
108+
struct commit *orig_head,
109+
const char *action)
110+
{
111+
struct reset_head_opts reset_opts = { 0 };
112+
struct strbuf buf = STRBUF_INIT;
113+
int ret;
114+
115+
for (size_t i = 0; i < commits->nr; i++) {
116+
struct object_id commit_id;
117+
struct commit *commit;
118+
const char *end;
119+
120+
if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
121+
repo->hash_algo)) {
122+
ret = error(_("invalid object ID: %s"), commits->v[i]);
123+
goto out;
124+
}
125+
126+
commit = lookup_commit(repo, &commit_id);
127+
if (!commit || repo_parse_commit(repo, commit)) {
128+
ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
129+
goto out;
130+
}
131+
132+
if (!onto) {
133+
onto = commit;
134+
} else {
135+
struct tree *tree = repo_get_commit_tree(repo, commit);
136+
onto = replay_create_commit(repo, tree, commit, onto);
137+
if (!onto)
138+
break;
139+
}
140+
}
141+
142+
reset_opts.oid = &onto->object.oid;
143+
strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
144+
reset_opts.flags = RESET_HEAD_REFS_ONLY | RESET_ORIG_HEAD;
145+
reset_opts.orig_head = &orig_head->object.oid;
146+
reset_opts.default_reflog_action = action;
147+
if (reset_head(repo, &reset_opts) < 0) {
148+
ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
149+
goto out;
150+
}
151+
152+
ret = 0;
153+
154+
out:
155+
strbuf_release(&buf);
156+
return ret;
157+
}
158+
159+
static void change_data_free(void *util, const char *str UNUSED)
160+
{
161+
struct wt_status_change_data *d = util;
162+
free(d->rename_source);
163+
free(d);
164+
}
165+
166+
static int fill_commit_message(struct repository *repo,
167+
const struct object_id *old_tree,
168+
const struct object_id *new_tree,
169+
const char *default_message,
170+
const char *action,
171+
struct strbuf *out)
172+
{
173+
const char *path = git_path_commit_editmsg();
174+
const char *hint =
175+
_("Please enter the commit message for the %s changes."
176+
" Lines starting\nwith '%s' will be ignored.\n");
177+
struct wt_status s;
178+
179+
strbuf_addstr(out, default_message);
180+
strbuf_addch(out, '\n');
181+
strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
182+
write_file_buf(path, out->buf, out->len);
183+
184+
wt_status_prepare(repo, &s);
185+
FREE_AND_NULL(s.branch);
186+
s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
187+
s.commit_template = 1;
188+
s.colopts = 0;
189+
s.display_comment_prefix = 1;
190+
s.hints = 0;
191+
s.use_color = 0;
192+
s.whence = FROM_COMMIT;
193+
s.committable = 1;
194+
195+
s.fp = fopen(git_path_commit_editmsg(), "a");
196+
if (!s.fp)
197+
return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
198+
199+
wt_status_collect_changes_trees(&s, old_tree, new_tree);
200+
wt_status_print(&s);
201+
wt_status_collect_free_buffers(&s);
202+
string_list_clear_func(&s.change, change_data_free);
203+
204+
strbuf_reset(out);
205+
if (launch_editor(path, out, NULL)) {
206+
fprintf(stderr, _("Please supply the message using the -m option.\n"));
207+
return -1;
208+
}
209+
strbuf_stripspace(out, comment_line_str);
210+
211+
cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
212+
213+
if (!out->len) {
214+
fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
215+
return -1;
216+
}
217+
218+
return 0;
219+
}
220+
221+
static int cmd_history_reword(int argc,
222+
const char **argv,
223+
const char *prefix,
224+
struct repository *repo)
225+
{
226+
const char * const usage[] = {
227+
GIT_HISTORY_REWORD_USAGE,
228+
NULL,
229+
};
230+
struct option options[] = {
231+
OPT_END(),
232+
};
233+
struct strbuf final_message = STRBUF_INIT;
234+
struct commit *original_commit, *parent, *head;
235+
struct strvec commits = STRVEC_INIT;
236+
struct object_id parent_tree_oid, original_commit_tree_oid;
237+
struct object_id rewritten_commit;
238+
struct commit_list *from_list = NULL;
239+
const char *original_message, *original_body, *ptr;
240+
char *original_author = NULL;
241+
size_t len;
242+
int ret;
243+
244+
argc = parse_options(argc, argv, prefix, options, usage, 0);
245+
if (argc != 1) {
246+
ret = error(_("command expects a single revision"));
247+
goto out;
248+
}
249+
repo_config(repo, git_default_config, NULL);
250+
251+
original_commit = lookup_commit_reference_by_name(argv[0]);
252+
if (!original_commit) {
253+
ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
254+
goto out;
255+
}
256+
original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
257+
258+
parent = original_commit->parents ? original_commit->parents->item : NULL;
259+
if (parent) {
260+
if (repo_parse_commit(repo, parent)) {
261+
ret = error(_("unable to parse commit %s"),
262+
oid_to_hex(&parent->object.oid));
263+
goto out;
264+
}
265+
parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
266+
} else {
267+
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
268+
}
269+
270+
head = lookup_commit_reference_by_name("HEAD");
271+
if (!head) {
272+
ret = error(_("could not resolve HEAD to a commit"));
273+
goto out;
274+
}
275+
276+
commit_list_append(original_commit, &from_list);
277+
if (!repo_is_descendant_of(repo, head, from_list)) {
278+
ret = error (_("split commit must be reachable from current HEAD commit"));
279+
goto out;
280+
}
281+
282+
/*
283+
* Collect the list of commits that we'll have to reapply now already.
284+
* This ensures that we'll abort early on in case the range of commits
285+
* contains merges, which we do not yet handle.
286+
*/
287+
ret = collect_commits(repo, parent, head, &commits);
288+
if (ret < 0)
289+
goto out;
290+
291+
/* We retain authorship of the original commit. */
292+
original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
293+
ptr = find_commit_header(original_message, "author", &len);
294+
if (ptr)
295+
original_author = xmemdupz(ptr, len);
296+
find_commit_subject(original_message, &original_body);
297+
298+
ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
299+
original_body, "reworded", &final_message);
300+
if (ret < 0)
301+
goto out;
302+
303+
ret = commit_tree(final_message.buf, final_message.len, &original_commit_tree_oid,
304+
original_commit->parents, &rewritten_commit, original_author, NULL);
305+
if (ret < 0) {
306+
ret = error(_("failed writing reworded commit"));
307+
goto out;
308+
}
309+
310+
replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
311+
312+
ret = apply_commits(repo, &commits, parent, head, "reword");
313+
if (ret < 0)
314+
goto out;
315+
316+
ret = 0;
317+
318+
out:
319+
strbuf_release(&final_message);
320+
free_commit_list(from_list);
321+
strvec_clear(&commits);
322+
free(original_author);
323+
return ret;
324+
}
4325

5326
int cmd_history(int argc,
6327
const char **argv,
7328
const char *prefix,
8-
struct repository *repo UNUSED)
329+
struct repository *repo)
9330
{
10331
const char * const usage[] = {
11-
N_("git history [<options>]"),
332+
GIT_HISTORY_REWORD_USAGE,
12333
NULL,
13334
};
335+
parse_opt_subcommand_fn *fn = NULL;
14336
struct option options[] = {
337+
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
15338
OPT_END(),
16339
};
17340

18341
argc = parse_options(argc, argv, prefix, options, usage, 0);
19-
if (argc)
20-
usagef("unrecognized argument: %s", argv[0]);
21-
return 0;
342+
return fn(argc, argv, prefix, repo);
22343
}

t/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ integration_tests = [
385385
't3437-rebase-fixup-options.sh',
386386
't3438-rebase-broken-files.sh',
387387
't3450-history.sh',
388+
't3451-history-reword.sh',
388389
't3500-cherry.sh',
389390
't3501-revert-cherry-pick.sh',
390391
't3502-cherry-pick-merge.sh',

t/t3450-history.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ test_description='tests for git-history command'
55
. ./test-lib.sh
66

77
test_expect_success 'does nothing without any arguments' '
8-
git history >out 2>&1 &&
9-
test_must_be_empty out
8+
test_must_fail git history 2>err &&
9+
test_grep "need a subcommand" err
1010
'
1111

1212
test_expect_success 'raises an error with unknown argument' '
1313
test_must_fail git history garbage 2>err &&
14-
test_grep "unrecognized argument: garbage" err
14+
test_grep "unknown subcommand: .garbage." err
1515
'
1616

1717
test_done

0 commit comments

Comments
 (0)