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}