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
change npxxszzuzlttuprqorrquksrknllvvsq
commit 040610a7a018f0ff252857c6d610d264e8b691ce
author Alpha Chen <alpha@kejadlen.dev>
date
parent snsxztop
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