Skip to main content

sley_diff_merge/
blob_merge.rs

1//! Three-way blob merge (diff3 conflict markers).
2
3use crate::line_diff::{
4    canonicalize_line, myers_diff_lines, myers_diff_lines_ws, split_lines, DiffAlgorithm,
5    DiffLine, DiffOp, WsIgnore,
6};
7
8/// Whether to favour one side wholesale for textual conflicts (`-Xours` /
9/// `-Xtheirs`), or to leave conflict markers in place.
10#[derive(Clone, Copy, PartialEq, Eq, Debug)]
11pub enum MergeFavor {
12    /// Leave conflict markers in place (the default).
13    None,
14    /// On a textual conflict, take ours' content wholesale.
15    Ours,
16    /// On a textual conflict, take theirs' content wholesale.
17    Theirs,
18    /// On a textual conflict, keep BOTH sides' lines (ours then theirs) with no
19    /// markers — git's `merge=union` attribute / `--union` (`XDL_MERGE_FAVOR_UNION`).
20    Union,
21}
22
23/// Which conflict-marker style [`merge_blobs`] emits.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum ConflictStyle {
26    /// Standard two-section markers (`<<<<<<<` / `=======` / `>>>>>>>`).
27    #[default]
28    Merge,
29    /// `diff3` style: also include the common-ancestor section between `ours`
30    /// and the `=======` divider, delimited by `|||||||`.
31    Diff3,
32}
33
34/// Labels and style controlling [`merge_blobs`] conflict markers.
35#[derive(Debug, Clone, Copy)]
36pub struct MergeBlobOptions<'a> {
37    /// Label after the opening `<<<<<<<` marker (typically the local branch).
38    pub ours_label: &'a str,
39    /// Label after the closing `>>>>>>>` marker (typically the other branch).
40    pub theirs_label: &'a str,
41    /// Label after the `|||||||` marker (only used for [`ConflictStyle::Diff3`]).
42    pub base_label: &'a str,
43    /// Which marker style to emit.
44    pub style: ConflictStyle,
45    /// How to resolve a textual conflict. [`MergeFavor::Union`] keeps both sides'
46    /// lines with no markers (and a non-conflicted result); other values leave
47    /// markers (favouring ours/theirs is applied by the caller at the file level).
48    pub favor: MergeFavor,
49    /// Whitespace-insensitivity for the 3-way line matching, mirroring
50    /// `-Xignore-space-change`/`-Xignore-all-space`/`-Xignore-space-at-eol` (git's
51    /// `ll_opts.xdl_opts`). When non-empty, regions that differ only by ignored
52    /// whitespace are not conflicts, and unchanged spans emit ours' actual bytes
53    /// (xdl_merge copies the common parts from file1). Empty (the default) is the
54    /// exact, byte-for-byte merge.
55    pub ws_ignore: WsIgnore,
56    /// Number of marker bytes in `<<<<<<<` / `=======` / `>>>>>>>` lines.
57    pub marker_size: usize,
58}
59
60impl Default for MergeBlobOptions<'_> {
61    fn default() -> Self {
62        Self {
63            ours_label: "ours",
64            theirs_label: "theirs",
65            base_label: "base",
66            style: ConflictStyle::Merge,
67            favor: MergeFavor::None,
68            ws_ignore: WsIgnore::EMPTY,
69            marker_size: 7,
70        }
71    }
72}
73
74/// The outcome of a 3-way blob merge.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct MergeBlobResult {
77    /// The merged blob bytes, including any conflict markers.
78    pub content: Vec<u8>,
79    /// True when at least one region conflicted and markers were written.
80    pub conflicted: bool,
81}
82
83/// Perform a 3-way merge of three blobs using the diff3 algorithm.
84///
85/// `base` is the common ancestor; `ours` and `theirs` are the two sides. The
86/// merge diffs base→ours and base→theirs (with [`myers_diff_lines`]) and walks
87/// the base in lockstep:
88/// - regions unchanged on both sides emit the base lines unchanged;
89/// - regions changed on exactly one side take that side's lines;
90/// - regions changed on both sides emit the side lines if they are
91///   byte-identical, otherwise a conflict (and [`MergeBlobResult::conflicted`]
92///   is set).
93///
94/// An empty `base` is supported: every line is then "added on both sides", so
95/// the result is the shared content if `ours == theirs`, else a single
96/// conflict (add/add).
97pub fn merge_blobs(
98    base: &[u8],
99    ours: &[u8],
100    theirs: &[u8],
101    options: &MergeBlobOptions<'_>,
102) -> MergeBlobResult {
103    let base_lines = split_lines(base);
104    let ours_lines = split_lines(ours);
105    let theirs_lines = split_lines(theirs);
106
107    // Per-side matched (equal) base regions, paired with the corresponding side
108    // ranges, computed via Myers. Under `ws_ignore`, lines that differ only by
109    // ignored whitespace match, so whitespace-only changes are absorbed into the
110    // stable spans rather than surfacing as conflicts.
111    let ours_matches = matching_regions(&base_lines, &ours_lines, options.ws_ignore);
112    let theirs_matches = matching_regions(&base_lines, &theirs_lines, options.ws_ignore);
113
114    // Intersect the two match lists to get segments of base that are unchanged
115    // on BOTH sides, each carrying the exact aligned side indices. Between these
116    // common-stable segments lie the (potentially conflicting) changed regions.
117    let stable = common_stable_segments(&ours_matches, &theirs_matches);
118
119    let mut writer = MergeWriter::new(options);
120    // Cursors: next unconsumed line in base, ours, theirs.
121    let mut base_idx = 0usize;
122    let mut our_idx = 0usize;
123    let mut their_idx = 0usize;
124
125    for seg in &stable {
126        // Unstable (changed) region preceding this stable segment.
127        let base_region = &base_lines[base_idx..seg.base_start];
128        let our_region = &ours_lines[our_idx..seg.ours_start];
129        let their_region = &theirs_lines[their_idx..seg.theirs_start];
130        emit_region(
131            &mut writer,
132            base_region,
133            our_region,
134            their_region,
135            options.ws_ignore,
136        );
137
138        // The stable segment matched on both sides. Emit ours' actual bytes
139        // (xdl_merge copies common spans from file1): identical to base under an
140        // exact match, and ours' whitespace under `ws_ignore`.
141        writer.emit_lines(&ours_lines[seg.ours_start..seg.ours_start + seg.len]);
142
143        base_idx = seg.base_start + seg.len;
144        our_idx = seg.ours_start + seg.len;
145        their_idx = seg.theirs_start + seg.len;
146    }
147
148    // Trailing unstable region after the last stable segment (or the whole input
149    // when there are no common-stable segments).
150    emit_region(
151        &mut writer,
152        &base_lines[base_idx..],
153        &ours_lines[our_idx..],
154        &theirs_lines[their_idx..],
155        options.ws_ignore,
156    );
157
158    writer.finish()
159}
160
161/// Resolve and emit one changed region (the gap between two common-stable
162/// segments) according to diff3 rules.
163fn emit_region(
164    writer: &mut MergeWriter<'_>,
165    base_region: &[DiffLine<'_>],
166    our_region: &[DiffLine<'_>],
167    their_region: &[DiffLine<'_>],
168    ws_ignore: WsIgnore,
169) {
170    if our_region.is_empty() && their_region.is_empty() {
171        return;
172    }
173    // Under `ws_ignore`, "changed" means changed beyond ignored whitespace; with
174    // the empty default the comparison is exact byte equality.
175    let our_changed = !regions_match(our_region, base_region, ws_ignore);
176    let their_changed = !regions_match(their_region, base_region, ws_ignore);
177    match (our_changed, their_changed) {
178        (false, false) => writer.emit_lines(our_region),
179        (true, false) => writer.emit_lines(our_region),
180        (false, true) => writer.emit_lines(their_region),
181        (true, true) => {
182            if regions_match(our_region, their_region, ws_ignore) {
183                // Both sides made the same change (up to ignored whitespace): no
184                // conflict. xdl_merge keeps ours' bytes.
185                writer.emit_lines(our_region);
186            } else {
187                writer.emit_conflict_refined(our_region, base_region, their_region);
188            }
189        }
190    }
191}
192
193/// Whether two line slices are equal, exactly when `ws_ignore` is empty and up to
194/// the active whitespace-ignore canonicalization otherwise.
195fn regions_match(a: &[DiffLine<'_>], b: &[DiffLine<'_>], ws_ignore: WsIgnore) -> bool {
196    if ws_ignore.is_empty() {
197        return a == b;
198    }
199    a.len() == b.len()
200        && a.iter().zip(b).all(|(x, y)| {
201            canonicalize_line(x.content, ws_ignore) == canonicalize_line(y.content, ws_ignore)
202        })
203}
204
205/// One unit produced by zealous conflict refinement: either context lines shared
206/// by both sides (emitted verbatim) or a minimal conflict spanning the named
207/// ours/theirs line ranges.
208enum RefineItem {
209    Context(std::ops::Range<usize>),
210    Conflict(std::ops::Range<usize>, std::ops::Range<usize>),
211}
212
213/// git's `xdl_refine_conflicts` + `xdl_simplify_non_conflicts` (level
214/// `XDL_MERGE_ZEALOUS`): re-diff the two conflicting sides against each other,
215/// factor the lines they share out of the conflict as context, and split the
216/// remainder into the minimal set of conflicting hunks — then re-merge any two
217/// conflicts separated by 3 or fewer context lines (the smaller-output rule).
218///
219/// Ranges index into `ours`/`theirs`; `Context` ranges are in ours coordinates
220/// (the shared lines are identical on both sides).
221fn refine_conflict_items(ours: &[DiffLine<'_>], theirs: &[DiffLine<'_>]) -> Vec<RefineItem> {
222    // Coalesce the ours-vs-theirs diff into alternating context (equal) and
223    // conflict (changed) runs.
224    let ops = myers_diff_lines(ours, theirs);
225    let mut raw: Vec<RefineItem> = Vec::new();
226    let mut oi = 0usize;
227    let mut ti = 0usize;
228    let mut pending: Option<(usize, usize, usize, usize)> = None; // o0,o1,t0,t1
229    for op in ops {
230        match op {
231            DiffOp::Equal(n) => {
232                if let Some((o0, o1, t0, t1)) = pending.take() {
233                    raw.push(RefineItem::Conflict(o0..o1, t0..t1));
234                }
235                raw.push(RefineItem::Context(oi..oi + n));
236                oi += n;
237                ti += n;
238            }
239            DiffOp::Delete(n) => {
240                let entry = pending.get_or_insert((oi, oi, ti, ti));
241                entry.1 = oi + n;
242                oi += n;
243            }
244            DiffOp::Insert(n) => {
245                let entry = pending.get_or_insert((oi, oi, ti, ti));
246                entry.3 = ti + n;
247                ti += n;
248            }
249        }
250    }
251    if let Some((o0, o1, t0, t1)) = pending.take() {
252        raw.push(RefineItem::Conflict(o0..o1, t0..t1));
253    }
254
255    // Merge two conflicts when the context between them is <= 3 lines: the
256    // absorbed context lines are identical on both sides, so they fold into the
257    // combined conflict's ours and theirs ranges alike.
258    let mut out: Vec<RefineItem> = Vec::new();
259    let mut idx = 0usize;
260    while idx < raw.len() {
261        match &raw[idx] {
262            RefineItem::Context(range) => {
263                let small = range.len() <= 3;
264                let prev_conflict = matches!(out.last(), Some(RefineItem::Conflict(..)));
265                let next_conflict = matches!(raw.get(idx + 1), Some(RefineItem::Conflict(..)));
266                if small && prev_conflict && next_conflict {
267                    let Some(RefineItem::Conflict(po, pt)) = out.pop() else {
268                        unreachable!()
269                    };
270                    let RefineItem::Conflict(no, nt) = &raw[idx + 1] else {
271                        unreachable!()
272                    };
273                    out.push(RefineItem::Conflict(po.start..no.end, pt.start..nt.end));
274                    idx += 2;
275                } else {
276                    out.push(RefineItem::Context(range.clone()));
277                    idx += 1;
278                }
279            }
280            RefineItem::Conflict(o, t) => {
281                out.push(RefineItem::Conflict(o.clone(), t.clone()));
282                idx += 1;
283            }
284        }
285    }
286    out
287}
288
289/// A matched (equal) region between `base` and one side: `base_start..+len`
290/// lines of base equal `side_start..+len` lines of that side.
291#[derive(Debug, Clone, Copy)]
292struct MatchRegion {
293    base_start: usize,
294    side_start: usize,
295    len: usize,
296}
297
298/// A run of base lines unchanged on *both* sides, with the aligned side starts.
299#[derive(Debug, Clone, Copy)]
300struct StableSegment {
301    base_start: usize,
302    ours_start: usize,
303    theirs_start: usize,
304    len: usize,
305}
306
307/// Compute the matched regions between base and a side using [`myers_diff_lines`].
308///
309/// Each `Equal(n)` run becomes a [`MatchRegion`]; the regions are returned in
310/// increasing base order. (Equal runs are coalesced by the diff, so adjacent
311/// regions are already maximal.)
312fn matching_regions(
313    base: &[DiffLine<'_>],
314    side: &[DiffLine<'_>],
315    ws_ignore: WsIgnore,
316) -> Vec<MatchRegion> {
317    let ops = if ws_ignore.is_empty() {
318        myers_diff_lines(base, side)
319    } else {
320        // The 3-way content merge uses the Myers line diff (git's ll-merge xdl
321        // default); the whitespace flags affect only the equality test.
322        myers_diff_lines_ws(base, side, ws_ignore, DiffAlgorithm::Myers)
323    };
324    let mut regions = Vec::new();
325    let mut base_idx = 0usize;
326    let mut side_idx = 0usize;
327    for op in ops {
328        match op {
329            DiffOp::Equal(n) => {
330                regions.push(MatchRegion {
331                    base_start: base_idx,
332                    side_start: side_idx,
333                    len: n,
334                });
335                base_idx += n;
336                side_idx += n;
337            }
338            DiffOp::Delete(n) => base_idx += n,
339            DiffOp::Insert(n) => side_idx += n,
340        }
341    }
342    regions
343}
344
345/// Intersect the ours/theirs match lists (both in base coordinates) to find the
346/// base ranges unchanged on both sides, recording the aligned side indices.
347///
348/// For each overlapping pair of base ranges `[bs, be)` the ours-side index of
349/// `bs` is `o.side_start + (bs - o.base_start)` and likewise for theirs; both
350/// map contiguously across the overlap. The returned segments are in increasing
351/// base order and never overlap.
352fn common_stable_segments(ours: &[MatchRegion], theirs: &[MatchRegion]) -> Vec<StableSegment> {
353    let mut segments = Vec::new();
354    let mut oi = 0usize;
355    let mut ti = 0usize;
356    while oi < ours.len() && ti < theirs.len() {
357        let o = ours[oi];
358        let t = theirs[ti];
359        let o_end = o.base_start + o.len;
360        let t_end = t.base_start + t.len;
361        let lo = o.base_start.max(t.base_start);
362        let hi = o_end.min(t_end);
363        if lo < hi {
364            segments.push(StableSegment {
365                base_start: lo,
366                ours_start: o.side_start + (lo - o.base_start),
367                theirs_start: t.side_start + (lo - t.base_start),
368                len: hi - lo,
369            });
370        }
371        // Advance whichever range ends first.
372        if o_end <= t_end {
373            oi += 1;
374        } else {
375            ti += 1;
376        }
377    }
378    segments
379}
380
381/// Accumulates merged output and renders conflict markers byte-for-byte like
382/// upstream git.
383struct MergeWriter<'a> {
384    out: Vec<u8>,
385    conflicted: bool,
386    options: &'a MergeBlobOptions<'a>,
387}
388
389impl<'a> MergeWriter<'a> {
390    fn new(options: &'a MergeBlobOptions<'a>) -> Self {
391        Self {
392            out: Vec::new(),
393            conflicted: false,
394            options,
395        }
396    }
397
398    /// Append raw line bytes (each line already carries its own newline, except
399    /// possibly a final no-newline line).
400    fn emit_lines(&mut self, lines: &[DiffLine<'_>]) {
401        for line in lines {
402            self.out.extend_from_slice(line.content);
403        }
404    }
405
406    /// Emit a conflict hunk. Conflict markers always begin on their own line,
407    /// so if the preceding emitted content did not end in a newline (a
408    /// no-newline-at-end side), insert one first — matching git, which prints
409    /// the "\ No newline at end of file" content followed by a newline before
410    /// the next marker.
411    fn emit_conflict(
412        &mut self,
413        ours: &[DiffLine<'_>],
414        base: &[DiffLine<'_>],
415        theirs: &[DiffLine<'_>],
416    ) {
417        // Union: keep both sides' lines (ours then theirs) with no markers, and do
418        // NOT flag a conflict — git's `XDL_MERGE_FAVOR_UNION`.
419        if self.options.favor == MergeFavor::Union {
420            self.emit_section(ours);
421            self.ensure_newline();
422            self.emit_section(theirs);
423            return;
424        }
425        self.conflicted = true;
426        self.write_marker(b'<', self.options.ours_label);
427        self.emit_section(ours);
428        if self.options.style == ConflictStyle::Diff3 {
429            self.ensure_newline();
430            self.write_marker(b'|', self.options.base_label);
431            self.emit_section(base);
432        }
433        self.ensure_newline();
434        self.write_divider();
435        self.emit_section(theirs);
436        self.ensure_newline();
437        self.write_marker(b'>', self.options.theirs_label);
438    }
439
440    /// Emit a conflict with git's zealous refinement applied. The default
441    /// (non-diff3) merge re-diffs the two sides to shrink the conflict to the
442    /// lines that genuinely differ (`xdl_refine_conflicts`); diff3-style output
443    /// keeps the conflict whole (the base section straddles it), a favored merge
444    /// resolves at a coarser granularity, and an empty side cannot be refined —
445    /// all three fall back to a single unrefined conflict hunk.
446    fn emit_conflict_refined(
447        &mut self,
448        ours: &[DiffLine<'_>],
449        base: &[DiffLine<'_>],
450        theirs: &[DiffLine<'_>],
451    ) {
452        if self.options.style == ConflictStyle::Diff3
453            || self.options.favor != MergeFavor::None
454            || ours.is_empty()
455            || theirs.is_empty()
456        {
457            self.emit_conflict(ours, base, theirs);
458            return;
459        }
460        for item in refine_conflict_items(ours, theirs) {
461            match item {
462                RefineItem::Context(range) => self.emit_lines(&ours[range]),
463                RefineItem::Conflict(o, t) => self.emit_conflict(&ours[o], &[], &theirs[t]),
464            }
465        }
466    }
467
468    /// Emit one side's lines inside a conflict, preserving their exact bytes.
469    fn emit_section(&mut self, lines: &[DiffLine<'_>]) {
470        for line in lines {
471            self.out.extend_from_slice(line.content);
472        }
473    }
474
475    /// Ensure the buffer ends with a newline before writing the next marker, so
476    /// markers always start a fresh line even after a no-newline final line.
477    fn ensure_newline(&mut self) {
478        if !self.out.is_empty() && self.out.last() != Some(&b'\n') {
479            self.out.push(b'\n');
480        }
481    }
482
483    /// Write a marker line: N copies of `ch`, then (if the label is non-empty)
484    /// a space and the label, then a newline. No trailing space for an empty
485    /// label — byte-for-byte with upstream git.
486    fn write_marker(&mut self, ch: u8, label: &str) {
487        for _ in 0..self.options.marker_size {
488            self.out.push(ch);
489        }
490        if !label.is_empty() {
491            self.out.push(b' ');
492            self.out.extend_from_slice(label.as_bytes());
493        }
494        self.out.push(b'\n');
495    }
496
497    /// Write the `=======` divider line (never labelled).
498    fn write_divider(&mut self) {
499        for _ in 0..self.options.marker_size {
500            self.out.push(b'=');
501        }
502        self.out.push(b'\n');
503    }
504
505    fn finish(self) -> MergeBlobResult {
506        MergeBlobResult {
507            content: self.out,
508            conflicted: self.conflicted,
509        }
510    }
511}