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' '