diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc index 24dc907033b469..56328a7c59dad9 100644 --- a/Documentation/git-history.adoc +++ b/Documentation/git-history.adoc @@ -40,13 +40,26 @@ at once. LIMITATIONS ----------- -This command does not (yet) work with histories that contain merges. You -should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead. - -Furthermore, the command does not support operations that can result in merge -conflicts. This limitation is by design as history rewrites are not intended to -be stateful operations. The limitation can be lifted once (if) Git learns about -first-class conflicts. +This command supports two-parent merge commits in the rewrite path: +the auto-remerged tree of the original parents, the merge commit +itself, and the auto-merged tree of the rewritten parents are +combined so that the user's manual conflict resolution (textual or +semantic) is preserved through the replay. Octopus merges (more than +two parents) are not supported and are rejected with an error. + +The replay propagates the textual diffs the user actually made in +the merge commit. It does _not_ extrapolate symbol-level intent: if +rewriting the parents pulls in genuinely new content (for example, a +new caller of a function that the merge renamed), that new content +is _not_ rewritten by the replay and may need a follow-up edit. +Symbol-aware refactoring is out of scope here, just as it is for +plain rebase. + +The command does not support operations that can result in merge +conflicts on the replayed merge itself. This limitation is by design +as history rewrites are not intended to be stateful operations. Use +linkgit:git-rebase[1] with the `--rebase-merges` flag when the +rewrite is expected to require interactive conflict resolution. COMMANDS -------- diff --git a/Makefile b/Makefile index cedc234173e377..b38678b484ac6a 100644 --- a/Makefile +++ b/Makefile @@ -832,6 +832,7 @@ TEST_BUILTINS_OBJS += test-hash-speed.o TEST_BUILTINS_OBJS += test-hash.o TEST_BUILTINS_OBJS += test-hashmap.o TEST_BUILTINS_OBJS += test-hexdump.o +TEST_BUILTINS_OBJS += test-historian.o TEST_BUILTINS_OBJS += test-json-writer.o TEST_BUILTINS_OBJS += test-lazy-init-name-hash.o TEST_BUILTINS_OBJS += test-match-trees.o diff --git a/builtin/history.c b/builtin/history.c index 952693808574b7..00097b2226e042 100644 --- a/builtin/history.c +++ b/builtin/history.c @@ -195,15 +195,15 @@ static int parse_ref_action(const struct option *opt, const char *value, int uns return 0; } -static int revwalk_contains_merges(struct repository *repo, - const struct strvec *revwalk_args) +static int revwalk_contains_octopus_merges(struct repository *repo, + const struct strvec *revwalk_args) { struct strvec args = STRVEC_INIT; struct rev_info revs; int ret; strvec_pushv(&args, revwalk_args->v); - strvec_push(&args, "--min-parents=2"); + strvec_push(&args, "--min-parents=3"); repo_init_revisions(repo, &revs, NULL); @@ -217,7 +217,7 @@ static int revwalk_contains_merges(struct repository *repo, } if (get_revision(&revs)) { - ret = error(_("replaying merge commits is not supported yet!")); + ret = error(_("replaying octopus merges is not supported")); goto out; } @@ -289,7 +289,7 @@ static int setup_revwalk(struct repository *repo, strvec_push(&args, "HEAD"); } - ret = revwalk_contains_merges(repo, &args); + ret = revwalk_contains_octopus_merges(repo, &args); if (ret < 0) goto out; @@ -482,6 +482,9 @@ static int cmd_history_reword(int argc, if (ret < 0) { ret = error(_("failed replaying descendants")); goto out; + } else if (ret) { + ret = error(_("conflict during replay; some descendants were not rewritten")); + goto out; } ret = 0; @@ -721,6 +724,9 @@ static int cmd_history_split(int argc, if (ret < 0) { ret = error(_("failed replaying descendants")); goto out; + } else if (ret) { + ret = error(_("conflict during replay; some descendants were not rewritten")); + goto out; } ret = 0; diff --git a/replay.c b/replay.c index f96f1f6551ae63..e28fbca972df2f 100644 --- a/replay.c +++ b/replay.c @@ -1,6 +1,7 @@ #define USE_THE_REPOSITORY_VARIABLE #include "git-compat-util.h" +#include "commit-reach.h" #include "environment.h" #include "hex.h" #include "merge-ort.h" @@ -77,15 +78,21 @@ static void generate_revert_message(struct strbuf *msg, repo_unuse_commit_buffer(repo, commit, message); } +/* + * Build a new commit with the given tree and parent list, copying author, + * extra headers and (for pick mode) the commit message from `based_on`. + * + * Takes ownership of `parents`: it will be freed before returning, even on + * error. Parent order is preserved as supplied by the caller. + */ static struct commit *create_commit(struct repository *repo, struct tree *tree, struct commit *based_on, - struct commit *parent, + struct commit_list *parents, enum replay_mode mode) { struct object_id ret; struct object *obj = NULL; - struct commit_list *parents = NULL; char *author = NULL; char *sign_commit = NULL; /* FIXME: cli users might want to sign again */ struct commit_extra_header *extra = NULL; @@ -96,7 +103,6 @@ static struct commit *create_commit(struct repository *repo, const char *orig_message = NULL; const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL }; - commit_list_insert(parent, &parents); extra = read_commit_extra_headers(based_on, exclude_gpgsig); if (mode == REPLAY_MODE_REVERT) { generate_revert_message(&msg, based_on, repo); @@ -273,6 +279,7 @@ static struct commit *pick_regular_commit(struct repository *repo, { struct commit *base, *replayed_base; struct tree *pickme_tree, *base_tree, *replayed_base_tree; + struct commit_list *parents = NULL; if (pickme->parents) { base = pickme->parents->item; @@ -327,7 +334,192 @@ static struct commit *pick_regular_commit(struct repository *repo, if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) && !oideq(&pickme_tree->object.oid, &base_tree->object.oid)) return replayed_base; - return create_commit(repo, result->tree, pickme, replayed_base, mode); + commit_list_insert(replayed_base, &parents); + return create_commit(repo, result->tree, pickme, parents, mode); +} + +/* + * Replay a 2-parent merge commit by composing three calls into merge-ort: + * + * R = recursive merge of pickme's two original parents (auto-remerge of + * the original merge, accepting any conflicts) + * N = recursive merge of the (possibly rewritten) parents + * O = pickme's tree (the user's actual merge, including any manual + * resolutions) + * + * The picked tree comes from a non-recursive merge using R as the base, + * O as side1 and N as side2. `git diff R O` is morally `git show + * --remerge-diff $oldmerge`, so this layers the user's original manual + * resolution on top of the freshly auto-merged rewritten parents (see + * `replay-design-notes.txt` on the `replay` branch of newren/git). + * + * If the outer 3-way merge is unclean, propagate the conflict status to + * the caller via `result->clean = 0` and return NULL. The two inner + * merges (R and N) being unclean is _not_ fatal: the conflict-markered + * trees they produce are valid inputs to the outer merge, and using + * identical labels for both inner merges keeps the marker text + * byte-equal between R and N so the user's resolution recorded in O + * collapses the conflict cleanly there. Octopus merges (more than two + * parents) and revert-of-merge are rejected by the caller before this + * function is invoked. + */ +static struct commit *pick_merge_commit(struct repository *repo, + struct commit *pickme, + kh_oid_map_t *replayed_commits, + struct merge_options *merge_opt, + struct merge_result *result) +{ + struct commit *parent1, *parent2; + struct commit *replayed_par1, *replayed_par2; + struct tree *pickme_tree; + struct merge_options remerge_opt = { 0 }; + struct merge_options new_merge_opt = { 0 }; + struct merge_result remerge_res = { 0 }; + struct merge_result new_merge_res = { 0 }; + struct commit_list *parent_bases = NULL; + struct commit_list *replayed_bases = NULL; + struct commit_list *parents; + struct commit *picked = NULL; + char *ancestor_name = NULL; + + parent1 = pickme->parents->item; + parent2 = pickme->parents->next->item; + + /* + * Map the merge's parents to their replayed counterparts. With the + * boundary commits pre-seeded into `replayed_commits`, every parent + * either has an explicit mapping (rewritten or boundary -> onto) or + * sits off the rewrite path entirely; the latter must stay at the + * original parent commit, so use `parent` itself as the fallback for + * both sides. + */ + replayed_par1 = mapped_commit(replayed_commits, parent1, parent1); + replayed_par2 = mapped_commit(replayed_commits, parent2, parent2); + + /* + * Compute both pairs of merge bases up front. The fast path below + * needs them for the tree-equality check, and the slow path that + * follows reuses them to avoid recomputing. + */ + if (repo_get_merge_bases(repo, parent1, parent2, &parent_bases) < 0 || + repo_get_merge_bases(repo, replayed_par1, replayed_par2, + &replayed_bases) < 0) { + result->clean = -1; + goto out; + } + + /* + * Fast path: when both rewritten parents carry the same trees as + * the originals AND every merge base does too (in order), the + * auto-merges R and N would be tree-equal (their inputs match + * content-wise), so the outer 3-way merge trivially yields the + * original merge's tree. Skip the inner merges and write the new + * merge commit directly. + * + * This is the common case for `git history reword`, which only + * changes commit messages and so leaves every tree on the line + * being replayed unchanged. The merge-base trees must be checked + * too: tree-same parents over a tree-different base could still + * produce a different auto-merge (a conflict region that did not + * exist before, or vice versa), and the original resolution would + * be inappropriate. + */ + if (oideq(&repo_get_commit_tree(repo, parent1)->object.oid, + &repo_get_commit_tree(repo, replayed_par1)->object.oid) && + oideq(&repo_get_commit_tree(repo, parent2)->object.oid, + &repo_get_commit_tree(repo, replayed_par2)->object.oid)) { + struct commit_list *bo, *bn; + int bases_match = 1; + + for (bo = parent_bases, bn = replayed_bases; + bo && bn; + bo = bo->next, bn = bn->next) { + if (!oideq(&repo_get_commit_tree(repo, bo->item)->object.oid, + &repo_get_commit_tree(repo, bn->item)->object.oid)) { + bases_match = 0; + break; + } + } + if (bo || bn) + bases_match = 0; + + if (bases_match) { + pickme_tree = repo_get_commit_tree(repo, pickme); + parents = NULL; + commit_list_insert(replayed_par2, &parents); + commit_list_insert(replayed_par1, &parents); + picked = create_commit(repo, pickme_tree, pickme, + parents, REPLAY_MODE_PICK); + goto out; + } + } + + /* + * R: auto-remerge of the original parents. + * + * Use the same branch labels for the inner merges that compute R + * and N so conflict markers (if any) are textually identical + * between the two; the outer non-recursive merge can then collapse + * the manual resolution from O against them. + */ + init_basic_merge_options(&remerge_opt, repo); + remerge_opt.show_rename_progress = 0; + remerge_opt.branch1 = "ours"; + remerge_opt.branch2 = "theirs"; + merge_incore_recursive(&remerge_opt, parent_bases, + parent1, parent2, &remerge_res); + parent_bases = NULL; /* consumed by merge_incore_recursive */ + if (remerge_res.clean < 0) { + result->clean = remerge_res.clean; + goto out; + } + + /* N: fresh merge of the (possibly rewritten) parents. */ + init_basic_merge_options(&new_merge_opt, repo); + new_merge_opt.show_rename_progress = 0; + new_merge_opt.branch1 = "ours"; + new_merge_opt.branch2 = "theirs"; + merge_incore_recursive(&new_merge_opt, replayed_bases, + replayed_par1, replayed_par2, &new_merge_res); + replayed_bases = NULL; /* consumed by merge_incore_recursive */ + if (new_merge_res.clean < 0) { + result->clean = new_merge_res.clean; + goto out; + } + + /* + * Outer non-recursive merge: base=R, side1=O (pickme), side2=N. + */ + pickme_tree = repo_get_commit_tree(repo, pickme); + ancestor_name = xstrfmt("auto-remerge of %s", + oid_to_hex(&pickme->object.oid)); + merge_opt->ancestor = ancestor_name; + merge_opt->branch1 = short_commit_name(repo, pickme); + merge_opt->branch2 = "merge of replayed parents"; + merge_incore_nonrecursive(merge_opt, + remerge_res.tree, + pickme_tree, + new_merge_res.tree, + result); + merge_opt->ancestor = NULL; + merge_opt->branch1 = NULL; + merge_opt->branch2 = NULL; + if (!result->clean) + goto out; + + parents = NULL; + commit_list_insert(replayed_par2, &parents); + commit_list_insert(replayed_par1, &parents); + picked = create_commit(repo, result->tree, pickme, parents, + REPLAY_MODE_PICK); + +out: + free(ancestor_name); + free_commit_list(parent_bases); + free_commit_list(replayed_bases); + merge_finalize(&remerge_opt, &remerge_res); + merge_finalize(&new_merge_opt, &new_merge_res); + return picked; } void replay_result_release(struct replay_result *result) @@ -407,17 +599,63 @@ int replay_revisions(struct rev_info *revs, merge_opt.show_rename_progress = 0; last_commit = onto; replayed_commits = kh_init_oid_map(); + + /* + * Seed the rewritten-commit map with each negative-side ("BOTTOM") + * cmdline entry pointing at `onto`. This matters for merge replay: + * a 2-parent merge whose first parent is the boundary (e.g. the + * commit being reworded) must replay onto the rewritten boundary, + * yet pick_merge_commit uses a self fallback so the second parent + * (a side branch off the walk) is preserved as-is. Pre-seeding the + * boundary disambiguates the two: in the map -> rewritten, missing + * -> stays put. + * + * Only do this for the pick path; revert mode chains reverts + * through last_commit and a pre-seeded boundary would short-circuit + * that chain. + */ + if (mode == REPLAY_MODE_PICK) { + for (size_t i = 0; i < revs->cmdline.nr; i++) { + struct rev_cmdline_entry *e = &revs->cmdline.rev[i]; + struct commit *boundary; + khint_t hpos; + int hr; + + if (!(e->flags & BOTTOM)) + continue; + boundary = lookup_commit_reference_gently(revs->repo, + &e->item->oid, 1); + if (!boundary) + continue; + hpos = kh_put_oid_map(replayed_commits, + boundary->object.oid, &hr); + if (hr != 0) + kh_value(replayed_commits, hpos) = onto; + } + } + while ((commit = get_revision(revs))) { const struct name_decoration *decoration; khint_t pos; int hr; - if (commit->parents && commit->parents->next) - die(_("replaying merge commits is not supported yet!")); - - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - mode == REPLAY_MODE_REVERT ? last_commit : onto, - &merge_opt, &result, mode); + if (commit->parents && commit->parents->next) { + if (commit->parents->next->next) { + ret = error(_("replaying octopus merges is not supported")); + goto out; + } + if (mode == REPLAY_MODE_REVERT) { + ret = error(_("reverting merge commits is not supported")); + goto out; + } + last_commit = pick_merge_commit(revs->repo, commit, + replayed_commits, + &merge_opt, &result); + } else { + last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, + mode == REPLAY_MODE_REVERT ? last_commit : onto, + &merge_opt, &result, mode); + } if (!last_commit) break; diff --git a/t/helper/meson.build b/t/helper/meson.build index 675e64c0101b61..704edd1e1faf79 100644 --- a/t/helper/meson.build +++ b/t/helper/meson.build @@ -29,6 +29,7 @@ test_tool_sources = [ 'test-hash.c', 'test-hashmap.c', 'test-hexdump.c', + 'test-historian.c', 'test-json-writer.c', 'test-lazy-init-name-hash.c', 'test-match-trees.c', diff --git a/t/helper/test-historian.c b/t/helper/test-historian.c new file mode 100644 index 00000000000000..2250d420c0eeed --- /dev/null +++ b/t/helper/test-historian.c @@ -0,0 +1,189 @@ +/* + * Build a small history out of a tiny declarative input. Used by tests + * that need specific merge topologies without long sequences of + * plumbing commands or fragile shell helpers. + * + * The historian reads stdin line by line and emits an equivalent + * stream to a `git fast-import` child process. It also allocates marks + * for named objects so tests can refer to commits and blobs by name. + * + * Input directives (one per line, shell-style quoting): + * + * blob NAME LINE1 LINE2 ... + * Each LINE becomes a content line in the blob; lines are + * joined with '\n' and the blob ends with a final '\n'. With + * no LINEs, the blob is empty. + * + * commit NAME BRANCH SUBJECT [from=PARENT] [merge=PARENT]... [PATH=BLOB]... + * Creates a commit on refs/heads/BRANCH using the listed + * file=blob mappings as the entire tree (no inheritance from + * parents). Up to one `from=` and any number of `merge=` + * parents may be given. `from=` defaults to the current branch + * tip; if BRANCH has no tip yet, the commit becomes a root. + * + * Each `commit NAME` directive also creates a lightweight tag + * `refs/tags/NAME` so tests can `git rev-parse NAME`. + * + * This helper trusts its caller; malformed input results in fast-import + * errors. That is fine because test scripts feed it tightly controlled + * input. + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "test-tool.h" +#include "git-compat-util.h" +#include "alias.h" +#include "run-command.h" +#include "setup.h" +#include "strbuf.h" +#include "strmap.h" +#include "strvec.h" + +static int next_mark = 1; + +static int resolve_mark(struct strintmap *names, const char *name) +{ + int n = strintmap_get(names, name); + if (!n) { + n = next_mark++; + strintmap_set(names, name, n); + } + return n; +} + +static void emit_data(FILE *out, const char *data, size_t len) +{ + fprintf(out, "data %"PRIuMAX"\n", (uintmax_t)len); + fwrite(data, 1, len, out); + fputc('\n', out); +} + +static void emit_blob(FILE *out, struct strintmap *names, + int argc, const char **argv) +{ + struct strbuf content = STRBUF_INIT; + int n = resolve_mark(names, argv[1]); + int i; + + for (i = 2; i < argc; i++) { + strbuf_addstr(&content, argv[i]); + strbuf_addch(&content, '\n'); + } + + fprintf(out, "blob\nmark :%d\n", n); + emit_data(out, content.buf, content.len); + strbuf_release(&content); +} + +static void emit_tag(FILE *out, const char *name, int mark) +{ + fprintf(out, "reset refs/tags/%s\nfrom :%d\n\n", name, mark); +} + +static void emit_commit(FILE *out, struct strintmap *names, + int argc, const char **argv, int seq) +{ + int n = resolve_mark(names, argv[1]); + const char *branch = argv[2]; + const char *subject = argv[3]; + const char *rest; + int i; + + fprintf(out, "commit refs/heads/%s\nmark :%d\n", branch, n); + fprintf(out, "author A %d +0000\n", 1700000000 + seq); + fprintf(out, "committer A %d +0000\n", 1700000000 + seq); + emit_data(out, subject, strlen(subject)); + + /* + * fast-import requires `from` and `merge` to precede all file + * operations; emit them first regardless of argv ordering. + */ + for (i = 4; i < argc; i++) { + if (skip_prefix(argv[i], "from=", &rest)) + fprintf(out, "from :%d\n", resolve_mark(names, rest)); + else if (skip_prefix(argv[i], "merge=", &rest)) + fprintf(out, "merge :%d\n", resolve_mark(names, rest)); + } + + /* + * The PATH=BLOB list is the entire tree; wipe whatever the + * implicit parent contributed before re-applying it. + */ + fprintf(out, "deleteall\n"); + for (i = 4; i < argc; i++) { + const char *eq; + size_t key_len; + char *path; + + if (skip_prefix(argv[i], "from=", &rest) || + skip_prefix(argv[i], "merge=", &rest)) + continue; + eq = strchr(argv[i], '='); + if (!eq) + die("bad commit spec '%s'", argv[i]); + key_len = eq - argv[i]; + path = xmemdupz(argv[i], key_len); + fprintf(out, "M 100644 :%d %s\n", + resolve_mark(names, eq + 1), path); + free(path); + } + + fputc('\n', out); + emit_tag(out, argv[1], n); +} + +int cmd__historian(int argc, const char **argv UNUSED) +{ + struct child_process fi = CHILD_PROCESS_INIT; + struct strintmap names = STRINTMAP_INIT; + struct strbuf line = STRBUF_INIT; + int seq = 0; + int ret = 0; + FILE *fi_in; + + if (argc != 1) + die("usage: test-tool historian = 2 && !strcmp(a[0], "blob")) + emit_blob(fi_in, &names, n, a); + else if (n >= 4 && !strcmp(a[0], "commit")) + emit_commit(fi_in, &names, n, a, seq++); + else + die("unknown directive: %s", a[0]); + + free(a); + } + + if (fclose(fi_in)) + die_errno("close fast-import stdin"); + if (finish_command(&fi)) + ret = 1; + + strbuf_release(&line); + strintmap_clear(&names); + return ret; +} diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c index a7abc618b3887e..28bde98ce1b76f 100644 --- a/t/helper/test-tool.c +++ b/t/helper/test-tool.c @@ -39,6 +39,7 @@ static struct test_cmd cmds[] = { { "hashmap", cmd__hashmap }, { "hash-speed", cmd__hash_speed }, { "hexdump", cmd__hexdump }, + { "historian", cmd__historian }, { "json-writer", cmd__json_writer }, { "lazy-init-name-hash", cmd__lazy_init_name_hash }, { "match-trees", cmd__match_trees }, diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h index 7f150fa1eb9ad2..78cec8594aac1f 100644 --- a/t/helper/test-tool.h +++ b/t/helper/test-tool.h @@ -32,6 +32,7 @@ int cmd__getcwd(int argc, const char **argv); int cmd__hashmap(int argc, const char **argv); int cmd__hash_speed(int argc, const char **argv); int cmd__hexdump(int argc, const char **argv); +int cmd__historian(int argc, const char **argv); int cmd__json_writer(int argc, const char **argv); int cmd__lazy_init_name_hash(int argc, const char **argv); int cmd__match_trees(int argc, const char **argv); diff --git a/t/meson.build b/t/meson.build index 7528e5cda5fef0..25b0119d4360eb 100644 --- a/t/meson.build +++ b/t/meson.build @@ -397,6 +397,7 @@ integration_tests = [ 't3450-history.sh', 't3451-history-reword.sh', 't3452-history-split.sh', + 't3454-history-merges.sh', 't3500-cherry.sh', 't3501-revert-cherry-pick.sh', 't3502-cherry-pick-merge.sh', diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh index de7b357685db4a..d103f866a2b99a 100755 --- a/t/t3451-history-reword.sh +++ b/t/t3451-history-reword.sh @@ -201,12 +201,21 @@ test_expect_success 'can reword a merge commit' ' git switch - && git merge theirs && - # It is not possible to replay merge commits embedded in the - # history (yet). - test_must_fail git -c core.editor=false history reword HEAD~ 2>err && - test_grep "replaying merge commits is not supported yet" err && + # Reword a non-merge commit whose descendants include the + # merge: replay carries the merge through. + reword_with_message HEAD~ <<-EOF && + ours reworded + EOF + expect_graph <<-EOF && + * Merge tag ${SQ}theirs${SQ} + |\\ + | * theirs + * | ours reworded + |/ + * base + EOF - # But it is possible to reword a merge commit directly. + # And reword a merge commit directly. reword_with_message HEAD <<-EOF && Reworded merge commit EOF @@ -214,7 +223,7 @@ test_expect_success 'can reword a merge commit' ' * Reworded merge commit |\ | * theirs - * | ours + * | ours reworded |/ * base EOF diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh index 8ed0cebb500296..ad6309f98b13c0 100755 --- a/t/t3452-history-split.sh +++ b/t/t3452-history-split.sh @@ -36,7 +36,7 @@ expect_tree_entries () { test_cmp expect actual } -test_expect_success 'refuses to work with merge commits' ' +test_expect_success 'refuses to split a merge commit' ' test_when_finished "rm -rf repo" && git init repo && ( @@ -49,9 +49,7 @@ test_expect_success 'refuses to work with merge commits' ' git switch - && git merge theirs && test_must_fail git history split HEAD 2>err && - test_grep "cannot split up merge commit" err && - test_must_fail git history split HEAD~ 2>err && - test_grep "replaying merge commits is not supported yet" err + test_grep "cannot split up merge commit" err ) ' diff --git a/t/t3454-history-merges.sh b/t/t3454-history-merges.sh new file mode 100755 index 00000000000000..7a164a579ad44b --- /dev/null +++ b/t/t3454-history-merges.sh @@ -0,0 +1,307 @@ +#!/bin/sh + +test_description='git history reword across merge commits + +Exercises the merge-replay path in `git history reword` using the +`test-tool historian` test fixture builder so each scenario is +described in a small declarative input rather than a sprawling +sequence of plumbing commands. The interesting cases are: + + * a clean merge with each side touching unrelated files; + * a non-trivial merge whose conflicting line was resolved by hand + (textually) and whose resolution must be preserved through the + replay; + * a non-trivial merge with a manual *semantic* edit (an additional + change outside the conflict region) that must also be preserved. +' + +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME + +. ./test-lib.sh + +# Replace the commit's message via a fake editor and run reword. +reword_to () { + new_msg="$1" + target="$2" + write_script fake-editor.sh <<-EOF && + echo "$new_msg" >"\$1" + EOF + test_set_editor "$(pwd)/fake-editor.sh" && + git history reword "$target" && + rm fake-editor.sh +} + +build_clean_merge () { + test-tool historian <<-\EOF + # Setup: + # A (a) --- C (a, h) ----+--- M (a, g, h) + # \ / + # +-- B (a, g) ------+ + # + # Topic touches `g` only; main touches `h` only. The auto-merge + # at M is clean. + blob a "shared content" + blob g guarded + blob h host + commit A main "A" a=a + commit B topic "B (introduces g)" from=A a=a g=g + commit C main "C (introduces h)" a=a h=h + commit M main "Merge topic" merge=B a=a g=g h=h + EOF +} + +test_expect_success 'clean merge: both sides touch unrelated files' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + build_clean_merge && + + reword_to "AA" A && + + # The merge is still a 2-parent merge with the same subject + # and tree (clean replay leaves content unchanged). + test_cmp_rev HEAD^{tree} M^{tree} && + + echo "Merge topic" >expect-subject && + git log -1 --format=%s HEAD >subject && + test_cmp expect-subject subject && + + git rev-list --merges HEAD~..HEAD >merges && + test_line_count = 1 merges + ) +' + +build_textual_resolution () { + test-tool historian <<-\EOF + # Both sides change the same line of `a`; the user resolved with + # their own combined text, recorded directly as the merge tree. + blob a_v1 line1 line2 line3 + blob a_main line1 line2-main line3 + blob a_topic line1 line2-topic line3 + blob a_resolution line1 line2-merged-by-hand line3 + commit A main "A" a=a_v1 + commit B topic "B (line2 on topic)" from=A a=a_topic + commit C main "C (line2 on main)" a=a_main + commit M main "Merge topic" merge=B a=a_resolution + EOF +} + +test_expect_success 'non-trivial merge: textual manual resolution is preserved' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + build_textual_resolution && + + reword_to "AA" A && + + git show HEAD:a >after && + test_write_lines line1 line2-merged-by-hand line3 >expect && + test_cmp expect after + ) +' + +build_semantic_edit () { + test-tool historian <<-\EOF + # Topic and main conflict on line2 of `a`. The user's resolution + # at M not only picks combined text on line2 but ALSO touches + # line5 (a "semantic" edit outside any conflict region) -- this + # kind of edit is invisible to a naive pick-one-side strategy and + # must be preserved by replay. + blob a_v1 line1 line2 line3 line4 line5 + blob a_main line1 line2-main line3 line4 line5 + blob a_topic line1 line2-topic line3 line4 line5 + blob a_resolution line1 line2-merged line3 line4 line5-touched + commit A main "A" a=a_v1 + commit B topic "B (line2 on topic)" from=A a=a_topic + commit C main "C (line2 on main)" a=a_main + commit M main "Merge topic" merge=B a=a_resolution + EOF +} + +test_expect_success 'non-trivial merge: semantic edit outside conflict region is preserved' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + build_semantic_edit && + + reword_to "AA" A && + + git show HEAD:a >after && + test_write_lines line1 line2-merged line3 line4 line5-touched \ + >expect && + test_cmp expect after + ) +' + +build_octopus () { + test-tool historian <<-\EOF + blob a "x" + commit A main "A" a=a + commit B b1 "B" from=A a=a + commit C b2 "C" from=A a=a + commit D b3 "D" from=A a=a + commit O main "octopus" merge=B merge=C merge=D a=a + EOF +} + +test_expect_success 'octopus merge in the rewrite path is rejected' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + build_octopus && + + test_must_fail git -c core.editor=true history reword \ + --dry-run A 2>err && + test_grep "octopus" err + ) +' + +build_off_spine_first_parent () { + test-tool historian <<-\EOF + # Setup an "evil merge" topology where the rewrite path crosses + # a 2-parent merge whose _first_ parent sits off the rewrite + # range: + # + # side -- O (a=v0) + # \ + # M (parent1=O, parent2=R, a=v0, s=top) + # / + # A (a=v0) -- R (a=v0) -- T (a=v0, s=top) + # | + # reword target + # + # The walk for `history reword A` excludes A and its ancestors, + # so O is off-walk-not-BOTTOM. Replaying M correctly requires M's + # first parent to remain at O (preserve, not replant). + blob v0 line1 line2 line3 + blob top "marker" + commit X side "X" v0=v0 + commit O side "O" v0=v0 + commit A main "A" from=X v0=v0 + commit R main "R" v0=v0 + commit M main "Merge side into main" from=O merge=R v0=v0 s=top + commit T main "T" v0=v0 s=top + EOF +} + +# A descendant merge whose first parent sits off the rewrite range +# is a topology that any reasonable replay of merges has to handle +# correctly: the off-walk parent must be preserved verbatim, while +# the in-walk parent is rewritten. Without that, the replayed merge +# would silently graft itself onto a different ancestry than the +# author chose, which is far worse than a loud failure. +test_expect_success 'merge whose first parent sits off the rewrite path keeps that parent' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + build_off_spine_first_parent && + + reword_to "AA" A && + + # The replayed M (now HEAD~) is still a 2-parent merge. + # Its first parent is the original O (preserved, off-walk), + # its second parent is the rewritten R (walked, in the + # map). T was rebased on top of M, so HEAD = T. + git rev-list --parents -1 HEAD~ >parents && + new_p1=$(awk "{print \$2}" parents) && + new_p2=$(awk "{print \$3}" parents) && + + # First parent is preserved verbatim. + test_cmp_rev O $new_p1 && + + # Second parent is the rewritten R: a fresh commit whose + # subject is still "R" but whose OID differs from the + # original (because its parent A is now reworded). + echo R >expect && + git log -1 --format=%s $new_p2 >actual && + test_cmp expect actual && + ! test_cmp_rev R $new_p2 && + + # T was rebased on top of the new M, and its tree still + # contains the s=top marker introduced in the original M. + echo "marker" >expect && + git show HEAD:s >actual && + test_cmp expect actual + ) +' + +build_function_rename () { + test-tool historian <<-\EOF + # Topic renames harry() -> hermione() (defs.h plus caller1). main + # adds caller2 calling harry(); the original merge M manually + # renames caller2 to hermione(). The "newer" base on a side branch + # contains caller2 AND a brand-new caller3 calling harry(); + # replaying onto `newer` therefore introduces caller3 into the + # merged tree. + blob defs_harry "void harry(void);" + blob defs_hermione "void hermione(void);" + blob harry_call "harry();" + blob hermione_call "hermione();" + commit A main "A" defs.h=defs_harry caller1=harry_call + commit B topic "B (rename)" from=A defs.h=defs_hermione caller1=hermione_call + commit C main "C (caller2 calls harry)" defs.h=defs_harry caller1=harry_call caller2=harry_call + commit M main "Merge topic" merge=B defs.h=defs_hermione caller1=hermione_call caller2=hermione_call + commit NEW newer "newer base with caller3" from=A defs.h=defs_harry caller1=harry_call caller2=harry_call caller3=harry_call + EOF +} + +# This case checks two things at once. First, the manual semantic +# edit in M (renaming caller2) must be preserved when we replay onto +# a different base; that is the case `git history` and `git replay` +# need to handle correctly, even though nothing in the conflict +# markers tells us about it. Second, a file that only enters the +# tree via the rewritten parents (caller3, present on the `newer` +# base) is _not_ renamed by the replay. The replay propagates the +# textual diffs the user actually made in M; it does _not_ infer +# the user's symbol-level intent ("rename every caller of harry"). +# This is a known and intentional limitation. Symbol-aware +# refactoring is out of scope here, just as it is for plain rebase. +test_expect_success 'preserves manual rename of pre-existing caller; does not extrapolate to new files' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + build_function_rename && + + # Replay (C, B, M) onto the newer base. A `main..M` style + # range across two unrelated branches is awkward; spin up a + # temp branch as the spine and use --advance. + git branch tmp main && + git replay --ref-action=print --onto NEW A..tmp >result && + new_tip=$(cut -f 3 -d " " result) && + + # defs.h and caller1 came from B (clean cherry-pick of the + # rename commit) and must reflect the rename. + echo "void hermione(void);" >expect && + git show $new_tip:defs.h >actual && + test_cmp expect actual && + + echo "hermione();" >expect && + git show $new_tip:caller1 >actual && + test_cmp expect actual && + + # caller2 existed in the original M; its manual rename to + # hermione() is the semantic edit the replay must preserve. + echo "hermione();" >expect && + git show $new_tip:caller2 >actual && + test_cmp expect actual && + + # caller3 only exists on the newer base, so it was brought + # in by N (the auto-merge of the rewritten parents). The + # replay has no way to know the user intended to rename + # every caller; caller3 keeps harry(). The resulting tree + # is therefore _not_ symbol-correct and needs a follow-up + # edit. This is the documented limitation. + echo "harry();" >expect && + git show $new_tip:caller3 >actual && + test_cmp expect actual + ) +' + +test_done diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh index 3353bc4a4dc6ed..368b1b0f9a670a 100755 --- a/t/t3650-replay-basics.sh +++ b/t/t3650-replay-basics.sh @@ -103,10 +103,48 @@ test_expect_success 'cannot advance target ... ordering would be ill-defined' ' test_cmp expect actual ' -test_expect_success 'replaying merge commits is not supported yet' ' - echo "fatal: replaying merge commits is not supported yet!" >expect && - test_must_fail git replay --advance=main main..topic-with-merge 2>actual && - test_cmp expect actual +test_expect_success 'using replay to rebase a 2-parent merge' ' + # main..topic-with-merge contains a 2-parent merge (P) introduced + # via test_merge. Use --ref-action=print so this test does not + # mutate state for subsequent tests in this file. + git replay --ref-action=print --onto main main..topic-with-merge >result && + test_line_count = 1 result && + + new_tip=$(cut -f 3 -d " " result) && + + # Result is still a 2-parent merge. + git cat-file -p $new_tip >cat && + grep -c "^parent " cat >count && + echo 2 >expect && + test_cmp expect count && + + # Merge subject is preserved. + echo P >expect && + git log -1 --format=%s $new_tip >actual && + test_cmp expect actual && + + # The replayed merge sits on top of main: walking back via the + # first-parent chain reaches main. + git merge-base --is-ancestor main $new_tip +' + +test_expect_success 'replaying an octopus merge is rejected' ' + # Build an octopus side-branch so the rest of the test state stays + # untouched. + test_when_finished "git update-ref -d refs/heads/octopus-tip" && + octopus_tip=$(git commit-tree -p topic4 -p topic1 -p topic3 \ + -m "octopus" $(git rev-parse topic4^{tree})) && + git update-ref refs/heads/octopus-tip "$octopus_tip" && + + test_must_fail git replay --ref-action=print --onto main \ + topic4..octopus-tip 2>actual && + test_grep "octopus merges" actual +' + +test_expect_success 'reverting a merge commit is rejected' ' + test_must_fail git replay --ref-action=print --revert=topic-with-merge \ + topic4..topic-with-merge 2>actual && + test_grep "reverting merge commits" actual ' test_expect_success 'using replay to rebase two branches, one on top of other' '