Conflict when one branch relabels what the other deleted
Deletion-wins is a valid pushout, so a relabel inside a deleted
subtree merged cleanly and the edit silently vanished — d3j accepted
merges that git and mergiraf both flag as delete/modify conflicts.
Found via the mergiraf comparison (docs/notes). The new relabel-delete
pair rule surfaces it; deletes are per-node, so the same-node check
covers relabels anywhere inside the deleted subtree.
The move-loses-edit scenario pins the same path reached through a
move: reordering demotes a crossing anchor into delete+insert, so an
edit to the moved node now conflicts instead of vanishing. Once move
detection lands it should merge cleanly instead.
Assisted-by: Claude Fable 5 via Claude Code
diff --git a/src/merge.rs b/src/merge.rs
index f0c49f2..30c70f2 100644
--- a/src/merge.rs
+++ b/src/merge.rs
@@ -696,6 +696,66 @@ mod tests {
Ok(())
}
+ #[test]
+ fn relabel_under_a_deleted_node_conflicts() -> Result<(), Error> {
+ // A deletes fn b outright; B edits inside its body. Without
+ // the rule, deletion wins as a valid pushout and B's edit
+ // silently vanishes.
+ let conflicts = assert_conflicts(
+ "rust",
+ "fn a() { x(); }\nfn b() { y(); }",
+ "fn a() { x(); }",
+ "fn a() { x(); }\nfn b() { z(); }",
+ "relabel-delete",
+ )?;
+ // The conflict carries B's relabeled span.
+ let b = parse("fn a() { x(); }\nfn b() { z(); }", "rust")?;
+ let witnessed = conflicts.iter().any(|c| {
+ c.rule == "relabel-delete"
+ && c.span_b
+ .clone()
+ .and_then(|span| b.source_slice(span))
+ .is_some_and(|text| text == "z")
+ });
+ assert!(witnessed);
+ Ok(())
+ }
+
+ #[test]
+ fn relabel_under_a_deleted_node_conflicts_reversed() -> Result<(), Error> {
+ // Mirrored: A edits the body B deletes.
+ assert_conflicts(
+ "rust",
+ "fn a() { x(); }\nfn b() { y(); }",
+ "fn a() { x(); }\nfn b() { z(); }",
+ "fn a() { x(); }",
+ "relabel-delete",
+ )?;
+ Ok(())
+ }
+
+ #[test]
+ fn relabel_and_delete_in_different_functions_merge() -> Result<(), Error> {
+ // The rule must not fire when the relabel is outside the
+ // deleted subtree: A deletes fn b, B renames a call in fn a.
+ assert_merges(
+ "rust",
+ "fn a() { x(); }\nfn b() { y(); }",
+ "fn a() { x(); }",
+ "fn a() { z(); }\nfn b() { y(); }",
+ "fn a() { z(); }",
+ )?;
+ // And mirrored: A renames while B deletes, exercising the
+ // rule's other arm.
+ assert_merges(
+ "rust",
+ "fn a() { x(); }\nfn b() { y(); }",
+ "fn a() { z(); }\nfn b() { y(); }",
+ "fn a() { x(); }",
+ "fn a() { z(); }",
+ )
+ }
+
#[test]
fn insert_and_delete_in_different_functions_merge() -> Result<(), Error> {
assert_merges(
diff --git a/src/rules.rs b/src/rules.rs
index c205fd1..73d9a15 100644
--- a/src/rules.rs
+++ b/src/rules.rs
@@ -21,7 +21,7 @@ pub(crate) struct Ctx<'t> {
/// A conflict rule over one (edit-from-A, edit-from-B) pair.
type PairRule = fn(&Edit, &Edit, &Ctx) -> Option<Conflict>;
-const PAIR_RULES: &[PairRule] = &[relabel_relabel, insert_delete];
+const PAIR_RULES: &[PairRule] = &[relabel_relabel, relabel_delete, insert_delete];
/// Runs the pairwise rules over every cross-branch edit pair and the
/// aggregate rules over the whole scripts, deduplicating identical
@@ -68,6 +68,30 @@ fn relabel_relabel(ea: &Edit, eb: &Edit, ctx: &Ctx) -> Option<Conflict> {
})
}
+/// relabel-delete: one branch relabeled an O node the other branch
+/// deleted (git's delete/modify). Deletion-wins is a valid pushout,
+/// so without this rule the merge is clean and the relabel silently
+/// vanishes with the deleted subtree — surface it instead. Deletes
+/// are per-node, so a relabel anywhere inside a deleted subtree pairs
+/// with the deletion of that same node; no ancestry walk is needed.
+fn relabel_delete(ea: &Edit, eb: &Edit, ctx: &Ctx) -> Option<Conflict> {
+ match (ea, eb) {
+ (Edit::Relabel(x, y), Edit::Delete(deleted)) if x == deleted => Some(Conflict {
+ span_o: Some(ctx.o.span(*deleted)),
+ span_a: Some(ctx.a.span(*y)),
+ span_b: None,
+ rule: "relabel-delete",
+ }),
+ (Edit::Delete(deleted), Edit::Relabel(x, y)) if x == deleted => Some(Conflict {
+ span_o: Some(ctx.o.span(*deleted)),
+ span_a: None,
+ span_b: Some(ctx.b.span(*y)),
+ rule: "relabel-delete",
+ }),
+ _ => None,
+ }
+}
+
/// insert-delete (broken dependency): one branch inserted under an O
/// node the other branch deleted. The graft has no surviving anchor to
/// re-attach to.
diff --git a/tests/corpus/scenarios/move-loses-edit/A.rs b/tests/corpus/scenarios/move-loses-edit/A.rs
new file mode 100644
index 0000000..0cc69e0
--- /dev/null
+++ b/tests/corpus/scenarios/move-loses-edit/A.rs
@@ -0,0 +1,7 @@
+fn beta() {
+ println!("beta");
+}
+
+fn alpha() {
+ println!("alpha");
+}
diff --git a/tests/corpus/scenarios/move-loses-edit/B.rs b/tests/corpus/scenarios/move-loses-edit/B.rs
new file mode 100644
index 0000000..f94beae
--- /dev/null
+++ b/tests/corpus/scenarios/move-loses-edit/B.rs
@@ -0,0 +1,7 @@
+fn alpha() {
+ println!("alpha");
+}
+
+fn beta() {
+ println!("beta v2");
+}
diff --git a/tests/corpus/scenarios/move-loses-edit/O.rs b/tests/corpus/scenarios/move-loses-edit/O.rs
new file mode 100644
index 0000000..15e5091
--- /dev/null
+++ b/tests/corpus/scenarios/move-loses-edit/O.rs
@@ -0,0 +1,7 @@
+fn alpha() {
+ println!("alpha");
+}
+
+fn beta() {
+ println!("beta");
+}
diff --git a/tests/corpus/scenarios/move-loses-edit/expected.CONFLICT b/tests/corpus/scenarios/move-loses-edit/expected.CONFLICT
new file mode 100644
index 0000000..7031ba9
--- /dev/null
+++ b/tests/corpus/scenarios/move-loses-edit/expected.CONFLICT
@@ -0,0 +1 @@
+relabel-delete
diff --git a/tests/corpus/scenarios/relabel-delete/A.rs b/tests/corpus/scenarios/relabel-delete/A.rs
new file mode 100644
index 0000000..4364651
--- /dev/null
+++ b/tests/corpus/scenarios/relabel-delete/A.rs
@@ -0,0 +1,3 @@
+fn alpha() {
+ println!("alpha");
+}
diff --git a/tests/corpus/scenarios/relabel-delete/B.rs b/tests/corpus/scenarios/relabel-delete/B.rs
new file mode 100644
index 0000000..f94beae
--- /dev/null
+++ b/tests/corpus/scenarios/relabel-delete/B.rs
@@ -0,0 +1,7 @@
+fn alpha() {
+ println!("alpha");
+}
+
+fn beta() {
+ println!("beta v2");
+}
diff --git a/tests/corpus/scenarios/relabel-delete/O.rs b/tests/corpus/scenarios/relabel-delete/O.rs
new file mode 100644
index 0000000..15e5091
--- /dev/null
+++ b/tests/corpus/scenarios/relabel-delete/O.rs
@@ -0,0 +1,7 @@
+fn alpha() {
+ println!("alpha");
+}
+
+fn beta() {
+ println!("beta");
+}
diff --git a/tests/corpus/scenarios/relabel-delete/expected.CONFLICT b/tests/corpus/scenarios/relabel-delete/expected.CONFLICT
new file mode 100644
index 0000000..7031ba9
--- /dev/null
+++ b/tests/corpus/scenarios/relabel-delete/expected.CONFLICT
@@ -0,0 +1 @@
+relabel-delete