Adopt a reformat-only branch's layout in the merge
A branch that only changed trivia is invisible to the pushout — its
tree is isomorphic to O — so every survivor kept its O origin and
synthesis emitted O's stale formatting, silently discarding the
reformat (and, when the other branch edited, discarding it in favor
of that branch's single-line bytes; see the reformat-edit probe in
docs/notes/2026-07-14-mergiraf-comparison.md). merge() now remaps
surviving O origins to the edit-free branch's image so synthesis
emits the reformatted bytes with the other branch's edits spliced in.

Concurrent distinct reformats resolve to A's layout: span synthesis
cannot honor two reformats of the same code at once, and keeping one
beats keeping neither.

Assisted-by: Claude Fable 5 via Claude Code
change xlqxmzrusxvtowqxsxlloytuqlwqkqrt
commit 9b088bb6bb049d076e6516fd86d8d9181f57db25
author Alpha Chen <alpha@kejadlen.dev>
date
parent lyozopuq
diff --git a/src/merge.rs b/src/merge.rs
index 4079e35..6306ccd 100644
--- a/src/merge.rs
+++ b/src/merge.rs
@@ -11,7 +11,7 @@
 use std::collections::{HashMap, HashSet};
 use std::ops::Range;
 
-use crate::diff::{Matching, Shape, diff, edits};
+use crate::diff::{Edit, Matching, Shape, diff, edits};
 use crate::error::Error;
 use crate::rules;
 use crate::tree::{NodeId, Tree};
@@ -207,7 +207,9 @@ pub fn merge(o: &Tree, a: &Tree, b: &Tree) -> Result<MergeOutcome, Error> {
         f: &f,
         g: &g,
     };
-    let conflicts = rules::conflicts(&ctx, &edits(o, a, &f), &edits(o, b, &g));
+    let edits_a = edits(o, a, &f);
+    let edits_b = edits(o, b, &g);
+    let conflicts = rules::conflicts(&ctx, &edits_a, &edits_b);
     if !conflicts.is_empty() {
         return Ok(MergeOutcome::Conflicts(conflicts));
     }
@@ -223,7 +225,7 @@ pub fn merge(o: &Tree, a: &Tree, b: &Tree) -> Result<MergeOutcome, Error> {
         consumed: HashSet::new(),
     };
     let root = builder.build_survivor(o.root());
-    let merged = MergedTree {
+    let mut merged = MergedTree {
         nodes: builder.nodes,
         root,
     };
@@ -235,9 +237,49 @@ pub fn merge(o: &Tree, a: &Tree, b: &Tree) -> Result<MergeOutcome, Error> {
         return Ok(MergeOutcome::Conflicts(arity_conflicts));
     }
 
+    prefer_reformatted_origins(&mut merged, o, (a, &f, &edits_a), (b, &g, &edits_b));
+
     Ok(MergeOutcome::Merged(merged))
 }
 
+/// Adopts a reformat-only branch's layout. A branch that only changed
+/// trivia is invisible to the pushout — its tree is isomorphic to O —
+/// so every survivor keeps its O origin and synthesis emits O's stale
+/// formatting, silently discarding the reformat. Remapping surviving
+/// O origins to that branch's image makes synthesis emit the
+/// reformatted bytes instead. When both branches are edit-free, A
+/// wins if it reformatted at all: span synthesis cannot honor two
+/// different reformats of the same code at once.
+fn prefer_reformatted_origins(
+    merged: &mut MergedTree,
+    o: &Tree,
+    a: (&Tree, &Matching, &[Edit]),
+    b: (&Tree, &Matching, &[Edit]),
+) {
+    fn text(tree: &Tree) -> Option<&str> {
+        tree.source_slice(0..tree.source_len())
+    }
+    let reformatted = |(branch, _, edits): (&Tree, &Matching, &[Edit])| {
+        edits.is_empty() && text(branch) != text(o)
+    };
+    let (matching, tag): (&Matching, fn(NodeId) -> Origin) = if reformatted(a) {
+        (a.1, Origin::A)
+    } else if reformatted(b) {
+        (b.1, Origin::B)
+    } else {
+        return;
+    };
+    for node in &mut merged.nodes {
+        // An empty edit script means the matching is total, so every
+        // surviving O node has an image to adopt.
+        if let Origin::O(n) = node.origin
+            && let Some(image) = matching.image(n)
+        {
+            node.origin = tag(image);
+        }
+    }
+}
+
 /// Which branch a graft comes from, with its diff to O and to-M pull
 /// direction bundled where needed.
 #[derive(Clone, Copy)]
@@ -512,17 +554,34 @@ mod tests {
     }
 
     #[test]
-    fn reformatting_only_branches_leave_bytes_alone() -> Result<(), Error> {
-        // B reformats without changing the tree, so no relabel fires,
-        // every survivor keeps its O origin, and the output is
-        // byte-identical to O.
+    fn a_reformatting_only_branch_wins_the_bytes() -> Result<(), Error> {
+        // B reformats without changing the tree, so no relabel fires;
+        // the reformat preference adopts B's layout instead of
+        // silently discarding it with O's stale bytes.
         let o_text = "fn keep() {\n    x(y);\n}\n";
         let b_text = "fn keep(  ) {  x( y ) ;  }\n";
         let o = parse(o_text, "rust")?;
         let a = parse(o_text, "rust")?;
         let b = parse(b_text, "rust")?;
         match merge_to_text(&o, &a, &b)? {
-            MergeResult::Merged(text) => assert_eq!(text, o_text),
+            MergeResult::Merged(text) => assert_eq!(text, b_text),
+            MergeResult::Conflicts(conflicts) => panic!("unexpected conflicts: {conflicts:?}"),
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn concurrent_distinct_reformats_prefer_a() -> Result<(), Error> {
+        // Span synthesis cannot honor two reformats of the same code
+        // at once; the tie breaks to A.
+        let o_text = "fn keep() {\n    x(y);\n}\n";
+        let a_text = "fn keep() { x(y); }\n";
+        let b_text = "fn keep(  ) {  x( y ) ;  }\n";
+        let o = parse(o_text, "rust")?;
+        let a = parse(a_text, "rust")?;
+        let b = parse(b_text, "rust")?;
+        match merge_to_text(&o, &a, &b)? {
+            MergeResult::Merged(text) => assert_eq!(text, a_text),
             MergeResult::Conflicts(conflicts) => panic!("unexpected conflicts: {conflicts:?}"),
         }
         Ok(())
diff --git a/src/synth.rs b/src/synth.rs
index 5a31584..c68b1c2 100644
--- a/src/synth.rs
+++ b/src/synth.rs
@@ -269,6 +269,23 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn a_reformat_only_branch_keeps_its_layout_around_the_other_branchs_edit() -> Result<(), Error>
+    {
+        // A pretty-prints without touching the tree; B bumps a value.
+        // The reformat preference emits A's layout with B's edit
+        // spliced in, instead of B's single-line bytes.
+        let o = r#"{"name": "d3j", "version": 1, "debug": false}"#;
+        let a = "{\n  \"name\": \"d3j\",\n  \"version\": 1,\n  \"debug\": false\n}\n";
+        let b = r#"{"name": "d3j", "version": 2, "debug": false}"#;
+        let text = merge_text("json", o, a, b)?;
+        assert_eq!(
+            text,
+            "{\n  \"name\": \"d3j\",\n  \"version\": 2,\n  \"debug\": false\n}\n"
+        );
+        Ok(())
+    }
+
     #[test]
     fn doc_comments_travel_with_grafted_functions() -> Result<(), Error> {
         let o = "fn keep() {\n    x();\n}\n";