Skip to main content

grit_lib/
merge_file.rs

1//! Three-way file merge — the engine behind `grit merge-file`.
2//!
3//! Performs a line-level three-way merge of *base*, *ours*, and *theirs*
4//! using the Myers diff algorithm (via the `similar` crate).
5//!
6//! # Algorithm
7//!
8//! 1. Split each file into lines, preserving line endings.
9//! 2. Annotate each base line with its diff status in `ours` and `theirs`.
10//! 3. For each non-Unchanged position, expand the region to fully cover every
11//!    overlapping Changed op from either side, then classify as
12//!    OnlyOurs / OnlyTheirs / Conflict based on which sides are changed.
13//! 4. Emit each hunk: unchanged → base; single-side → that side's content;
14//!    two-side → conflict markers (or resolved via `favor`).
15//!
16//! Pure insertions (ops with empty old_range) are attached to the adjacent
17//! base position and emitted as OnlyOurs / OnlyTheirs hunks.
18
19use crate::error::Result;
20use similar::{Algorithm, DiffOp, DiffTag};
21
22/// How conflict regions should be resolved.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum MergeFavor {
25    /// Leave conflict markers in the output (default).
26    #[default]
27    None,
28    /// For conflicts, keep our version.
29    Ours,
30    /// For conflicts, keep their version.
31    Theirs,
32    /// For conflicts, concatenate both versions.
33    Union,
34}
35
36/// Conflict-marker output style.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum ConflictStyle {
39    /// Standard two-section markers (`<<<<<<<`, `=======`, `>>>>>>>`).
40    #[default]
41    Merge,
42    /// Three-section markers including the base section.
43    Diff3,
44    /// Zealous diff3 (same as Diff3 for our purposes).
45    ZealousDiff3,
46}
47
48/// Input and options for a three-way merge.
49pub struct MergeInput<'a> {
50    /// Base (ancestor) content.
51    pub base: &'a [u8],
52    /// Our version of the file.
53    pub ours: &'a [u8],
54    /// Their version of the file.
55    pub theirs: &'a [u8],
56    /// Label for the ours conflict marker line.
57    pub label_ours: &'a str,
58    /// Label for the base conflict marker line (diff3 only).
59    pub label_base: &'a str,
60    /// Label for the theirs conflict marker line.
61    pub label_theirs: &'a str,
62    /// Conflict resolution strategy.
63    pub favor: MergeFavor,
64    /// Conflict marker style.
65    pub style: ConflictStyle,
66    /// Width of conflict markers in characters (0 → use default of 7).
67    pub marker_size: usize,
68    /// Diff algorithm.
69    pub diff_algorithm: Option<String>,
70    /// Ignore all whitespace when comparing lines (`-w`).
71    pub ignore_all_space: bool,
72    /// Ignore changes in amount of whitespace (`-b`).
73    pub ignore_space_change: bool,
74    /// Ignore whitespace at end of line.
75    pub ignore_space_at_eol: bool,
76    /// Ignore CR at end of line.
77    pub ignore_cr_at_eol: bool,
78}
79
80/// Result of a three-way merge.
81pub struct MergeOutput {
82    /// Merged file content.
83    pub content: Vec<u8>,
84    /// Number of conflict regions (0 = clean merge).
85    pub conflicts: usize,
86}
87
88/// Perform a three-way line-level merge.
89///
90/// # Errors
91///
92/// Currently infallible but returns `Result` for future extension.
93pub fn merge(input: &MergeInput<'_>) -> Result<MergeOutput> {
94    let base_lines = split_lines(input.base);
95    let ours_lines = split_lines(input.ours);
96    let theirs_lines = split_lines(input.theirs);
97    let ws_mode = WhitespaceMode {
98        ignore_all_space: input.ignore_all_space,
99        ignore_space_change: input.ignore_space_change,
100        ignore_space_at_eol: input.ignore_space_at_eol,
101        ignore_cr_at_eol: input.ignore_cr_at_eol,
102    };
103    // Ignore trailing `\n` / `\r\n` / `\r` when diffing (git `xdl_recmatch`), so clean merges
104    // match `git merge-file` when only the final newline differs (t6403).
105    let base_compare_lines = lines_for_merge_diff_compare(&base_lines, &ws_mode);
106    let ours_compare_lines = lines_for_merge_diff_compare(&ours_lines, &ws_mode);
107    let theirs_compare_lines = lines_for_merge_diff_compare(&theirs_lines, &ws_mode);
108
109    let algo = input
110        .diff_algorithm
111        .as_deref()
112        .map(|name| match name.to_lowercase().as_str() {
113            "histogram" | "patience" => similar::Algorithm::Patience,
114            _ => similar::Algorithm::Myers,
115        })
116        .unwrap_or(similar::Algorithm::Myers);
117    let ours_ops = similar::capture_diff_slices(algo, &base_compare_lines, &ours_compare_lines);
118    let theirs_ops = similar::capture_diff_slices(algo, &base_compare_lines, &theirs_compare_lines);
119
120    let mut hunks = compute_hunks(
121        &base_lines,
122        &ours_lines,
123        &theirs_lines,
124        &ours_ops,
125        &theirs_ops,
126        &ws_mode,
127    );
128    // Zealous diff3 (`adjust_zealous_hunks`) assumes the raw `compute_hunks` sequence.
129    if !matches!(input.style, ConflictStyle::ZealousDiff3) {
130        hunks = merge_adjacent_replace_and_trailing_insert_conflicts(hunks);
131        hunks = merge_adjacent_one_sided_line_changes_to_conflict(hunks);
132    }
133    // Match `git merge-file` (`XDL_MERGE_ZEALOUS_ALNUM`): refine then simplify conflicts.
134    if !matches!(
135        input.style,
136        ConflictStyle::Diff3 | ConflictStyle::ZealousDiff3
137    ) {
138        hunks = refine_conflicts_by_subdiff(hunks, algo, &ws_mode);
139        hunks = simplify_non_conflicts_between_conflicts(hunks, true);
140    }
141    // Git keeps adjacent conflict regions separate when identical lines appear
142    // between them (e.g. t4200-rerere); do not merge Conflict+gap+Conflict.
143    hunks = coalesce_nearby_conflicts(hunks, 3, false);
144    if matches!(input.style, ConflictStyle::ZealousDiff3) {
145        hunks = adjust_zealous_hunks(hunks);
146    }
147
148    let marker = if input.marker_size == 0 {
149        7
150    } else {
151        input.marker_size
152    };
153
154    let mut content: Vec<u8> = Vec::new();
155    let mut conflicts = 0usize;
156    let mut ours_line_pos = 0usize;
157    let mut theirs_line_pos = 0usize;
158
159    for (idx, hunk) in hunks.iter().enumerate() {
160        match hunk {
161            Hunk::Unchanged(lines) => {
162                append_lines(&mut content, lines);
163                ours_line_pos += lines.len();
164                theirs_line_pos += lines.len();
165            }
166            Hunk::OnlyOurs { ours, .. } => {
167                append_lines(&mut content, ours);
168                ours_line_pos += ours.len();
169            }
170            Hunk::OnlyTheirs { theirs, .. } => {
171                append_lines(&mut content, theirs);
172                theirs_line_pos += theirs.len();
173            }
174            Hunk::Conflict { base, ours, theirs } => {
175                let conflict_ours_start = ours_line_pos;
176                let conflict_theirs_start = theirs_line_pos;
177                match input.favor {
178                    MergeFavor::Ours => {
179                        append_lines(&mut content, ours);
180                        ours_line_pos += ours.len();
181                    }
182                    MergeFavor::Theirs => {
183                        append_lines(&mut content, theirs);
184                        theirs_line_pos += theirs.len();
185                    }
186                    MergeFavor::Union => {
187                        append_lines(&mut content, ours);
188                        // If the ours portion doesn't end with \n and theirs is
189                        // non-empty, insert a newline so both sections appear as
190                        // separate lines (matches git's missing-LF handling).
191                        if !theirs.is_empty()
192                            && !ours.is_empty()
193                            && !ours.last().map(|l| l.ends_with(b"\n")).unwrap_or(false)
194                        {
195                            content.push(b'\n');
196                        }
197                        append_lines(&mut content, theirs);
198                        ours_line_pos += ours.len();
199                        theirs_line_pos += theirs.len();
200                    }
201                    MergeFavor::None => {
202                        conflicts += 1;
203                        if matches!(input.style, ConflictStyle::ZealousDiff3) {
204                            let (mut prefix_len, mut suffix_len) =
205                                common_prefix_suffix(ours, theirs);
206
207                            if prefix_len > 0
208                                && idx > 0
209                                && hunk_output_lines(&hunks[idx - 1])
210                                    .map(|lines| lines_end_with(lines, &ours[..prefix_len]))
211                                    .unwrap_or(false)
212                            {
213                                prefix_len = 0;
214                            }
215
216                            if suffix_len > 0
217                                && idx + 1 < hunks.len()
218                                && hunk_output_lines(&hunks[idx + 1])
219                                    .map(|lines| {
220                                        lines_start_with(lines, &ours[ours.len() - suffix_len..])
221                                    })
222                                    .unwrap_or(false)
223                            {
224                                suffix_len = 0;
225                            }
226
227                            if prefix_len > 0 {
228                                append_lines(&mut content, &ours[..prefix_len]);
229                            }
230                            let i1 = conflict_ours_start + prefix_len;
231                            let i2 = conflict_theirs_start + prefix_len;
232                            let needs_cr = conflict_markers_need_cr_at(
233                                &ours_lines,
234                                &theirs_lines,
235                                &base_lines,
236                                i1,
237                                i2,
238                            );
239                            emit_conflict(
240                                &mut content,
241                                base,
242                                &ours[prefix_len..ours.len() - suffix_len],
243                                &theirs[prefix_len..theirs.len() - suffix_len],
244                                input.label_ours,
245                                input.label_base,
246                                input.label_theirs,
247                                input.style,
248                                marker,
249                                needs_cr,
250                            );
251                            if suffix_len > 0 {
252                                append_lines(&mut content, &ours[ours.len() - suffix_len..]);
253                            }
254                        } else if matches!(input.style, ConflictStyle::Merge) {
255                            let (prefix_len, suffix_len) =
256                                common_prefix_suffix_merge_lines(ours, theirs);
257                            let pre = &ours[..prefix_len];
258                            let suf_start = ours.len().saturating_sub(suffix_len);
259                            let o_mid = &ours[prefix_len..suf_start];
260                            let t_mid =
261                                &theirs[prefix_len..theirs.len().saturating_sub(suffix_len)];
262                            let suf = &ours[suf_start..];
263                            append_lines(&mut content, pre);
264                            let i1 = conflict_ours_start + prefix_len;
265                            let i2 = conflict_theirs_start + prefix_len;
266                            let needs_cr = conflict_markers_need_cr_at(
267                                &ours_lines,
268                                &theirs_lines,
269                                &base_lines,
270                                i1,
271                                i2,
272                            );
273                            emit_conflict(
274                                &mut content,
275                                base,
276                                o_mid,
277                                t_mid,
278                                input.label_ours,
279                                input.label_base,
280                                input.label_theirs,
281                                input.style,
282                                marker,
283                                needs_cr,
284                            );
285                            append_lines(&mut content, suf);
286                        } else {
287                            let needs_cr = conflict_markers_need_cr_at(
288                                &ours_lines,
289                                &theirs_lines,
290                                &base_lines,
291                                conflict_ours_start,
292                                conflict_theirs_start,
293                            );
294                            emit_conflict(
295                                &mut content,
296                                base,
297                                ours,
298                                theirs,
299                                input.label_ours,
300                                input.label_base,
301                                input.label_theirs,
302                                input.style,
303                                marker,
304                                needs_cr,
305                            );
306                        }
307                        ours_line_pos += ours.len();
308                        theirs_line_pos += theirs.len();
309                    }
310                }
311            }
312        }
313    }
314
315    Ok(MergeOutput { content, conflicts })
316}
317
318fn append_lines(out: &mut Vec<u8>, lines: &[Vec<u8>]) {
319    // If previous content ended without a newline and this hunk adds more
320    // content, insert a newline separator to avoid merging lines together.
321    if !out.is_empty() && !out.ends_with(b"\n") && !lines.is_empty() {
322        out.push(b'\n');
323    }
324    for line in lines {
325        out.extend_from_slice(line);
326    }
327}
328
329fn common_prefix_suffix(ours: &[Vec<u8>], theirs: &[Vec<u8>]) -> (usize, usize) {
330    let mut prefix = 0usize;
331    while prefix < ours.len() && prefix < theirs.len() && ours[prefix] == theirs[prefix] {
332        prefix += 1;
333    }
334
335    let mut suffix = 0usize;
336    while suffix < ours.len().saturating_sub(prefix)
337        && suffix < theirs.len().saturating_sub(prefix)
338        && ours[ours.len() - 1 - suffix] == theirs[theirs.len() - 1 - suffix]
339    {
340        suffix += 1;
341    }
342
343    (prefix, suffix)
344}
345
346fn hunk_output_lines(hunk: &Hunk) -> Option<&[Vec<u8>]> {
347    match hunk {
348        Hunk::Unchanged(lines) => Some(lines),
349        Hunk::OnlyOurs { ours, .. } => Some(ours),
350        Hunk::OnlyTheirs { theirs, .. } => Some(theirs),
351        Hunk::Conflict { .. } => None,
352    }
353}
354
355fn lines_end_with(lines: &[Vec<u8>], suffix: &[Vec<u8>]) -> bool {
356    if suffix.is_empty() || suffix.len() > lines.len() {
357        return false;
358    }
359    lines[lines.len() - suffix.len()..] == *suffix
360}
361
362fn lines_start_with(lines: &[Vec<u8>], prefix: &[Vec<u8>]) -> bool {
363    if prefix.is_empty() || prefix.len() > lines.len() {
364        return false;
365    }
366    lines[..prefix.len()] == *prefix
367}
368
369/// Emit conflict markers into `out`.
370#[allow(clippy::too_many_arguments)]
371fn emit_conflict(
372    out: &mut Vec<u8>,
373    base: &[Vec<u8>],
374    ours: &[Vec<u8>],
375    theirs: &[Vec<u8>],
376    label_ours: &str,
377    label_base: &str,
378    label_theirs: &str,
379    style: ConflictStyle,
380    marker: usize,
381    needs_cr: bool,
382) {
383    let open = "<".repeat(marker);
384    let eq = "=".repeat(marker);
385    let close = ">".repeat(marker);
386    let marker_eol: &[u8] = if needs_cr { b"\r\n" } else { b"\n" };
387
388    // Ensure ours section starts on a new line if the previous content didn't
389    // end with one.
390    if !out.is_empty() && !out.ends_with(b"\n") && !out.ends_with(b"\r\n") {
391        out.extend_from_slice(marker_eol);
392    }
393
394    write_conflict_marker_line(out, &open, Some(label_ours), marker_eol);
395    for line in ours {
396        out.extend_from_slice(line);
397    }
398    // Ensure separator starts on its own line.
399    if out.last().copied() != Some(b'\n') {
400        out.extend_from_slice(marker_eol);
401    }
402    match style {
403        ConflictStyle::Diff3 | ConflictStyle::ZealousDiff3 => {
404            let pipe = "|".repeat(marker);
405            write_conflict_marker_line(out, &pipe, Some(label_base), marker_eol);
406            for line in base {
407                out.extend_from_slice(line);
408            }
409            if out.last().copied() != Some(b'\n') {
410                out.extend_from_slice(marker_eol);
411            }
412            write_conflict_marker_line(out, &eq, None, marker_eol);
413        }
414        ConflictStyle::Merge => {
415            write_conflict_marker_line(out, &eq, None, marker_eol);
416        }
417    }
418    for line in theirs {
419        out.extend_from_slice(line);
420    }
421    if out.last().copied() != Some(b'\n') {
422        out.extend_from_slice(marker_eol);
423    }
424    write_conflict_marker_line(out, &close, Some(label_theirs), marker_eol);
425}
426
427fn write_conflict_marker_line(out: &mut Vec<u8>, marker: &str, label: Option<&str>, eol: &[u8]) {
428    out.extend_from_slice(marker.as_bytes());
429    // `git rerere` recognizes `<<<<<<< ` / `>>>>>>> ` only when there is a space after the marker
430    // run, even if the branch label is empty (`ll_merge` with empty names — `try_replay_merge`).
431    let open_or_close = marker.starts_with('<') || marker.starts_with('>');
432    let has_label = label.is_some_and(|l| !l.is_empty());
433    // Diff3 `|||||||` lines use a space before the base label so they match Git's conflict format
434    // (and `print_sanitized_conflicted_diff` in t4108 can strip the label).
435    if open_or_close || has_label {
436        out.push(b' ');
437    }
438    if let Some(label) = label {
439        if !label.is_empty() {
440            out.extend_from_slice(label.as_bytes());
441        }
442    }
443    out.extend_from_slice(eol);
444}
445
446/// Strip trailing `\n`, `\r\n`, or lone `\r` from a logical line (content kept for output).
447fn strip_line_terminator(line: &[u8]) -> &[u8] {
448    if line.ends_with(b"\r\n") {
449        return &line[..line.len() - 2];
450    }
451    if line.ends_with(b"\n") {
452        return &line[..line.len() - 1];
453    }
454    if line.ends_with(b"\r") {
455        return &line[..line.len() - 1];
456    }
457    line
458}
459
460/// Compare two lines ignoring trailing newline / CR conventions (matches git `xdl_recmatch` for EOL).
461fn merge_line_equal(a: &[u8], b: &[u8]) -> bool {
462    strip_line_terminator(a) == strip_line_terminator(b)
463}
464
465fn common_prefix_suffix_merge_lines(ours: &[Vec<u8>], theirs: &[Vec<u8>]) -> (usize, usize) {
466    let mut prefix = 0usize;
467    while prefix < ours.len()
468        && prefix < theirs.len()
469        && merge_line_equal(&ours[prefix], &theirs[prefix])
470    {
471        prefix += 1;
472    }
473
474    let mut suffix = 0usize;
475    while suffix < ours.len().saturating_sub(prefix)
476        && suffix < theirs.len().saturating_sub(prefix)
477        && merge_line_equal(
478            &ours[ours.len() - 1 - suffix],
479            &theirs[theirs.len() - 1 - suffix],
480        )
481    {
482        suffix += 1;
483    }
484
485    (prefix, suffix)
486}
487
488/// Git `is_eol_crlf` for a single logical line in our representation.
489fn is_eol_crlf_line(line: &[u8]) -> i8 {
490    if line.ends_with(b"\r\n") {
491        return 1;
492    }
493    if line.ends_with(b"\n") {
494        return 0;
495    }
496    -1
497}
498
499/// Git `is_cr_needed` using indices into the full postimages / ancestor (see `xmerge.c`).
500fn conflict_markers_need_cr_at(
501    ours_lines: &[Vec<u8>],
502    theirs_lines: &[Vec<u8>],
503    base_lines: &[Vec<u8>],
504    i1: usize,
505    i2: usize,
506) -> bool {
507    let mut needs = if i1 > 0 {
508        is_eol_crlf_line(ours_lines[i1 - 1].as_slice())
509    } else {
510        -1
511    };
512    if needs > 0 {
513        let t0 = if i2 > 0 {
514            is_eol_crlf_line(theirs_lines[i2 - 1].as_slice())
515        } else {
516            -1
517        };
518        if t0 >= 0 {
519            needs = t0;
520        }
521    }
522    if needs > 0 && !base_lines.is_empty() {
523        needs = is_eol_crlf_line(base_lines[0].as_slice());
524    }
525    needs > 0
526}
527
528fn line_contains_alnum(line: &[u8]) -> bool {
529    strip_line_terminator(line)
530        .iter()
531        .any(|b| b.is_ascii_alphanumeric())
532}
533
534/// `true` when git `xdl_simplify_non_conflicts` would **not** merge `Conflict + gap + Conflict`.
535///
536/// Git merges the outer conflicts when the gap is "small enough" unless `simplify_if_no_alnum`
537/// (`XDL_MERGE_ZEALOUS < level`) requires keeping gaps that are long **and** alphanumeric.
538fn gap_blocks_merge_between_conflicts(gap: &[Vec<u8>], simplify_if_no_alnum: bool) -> bool {
539    if gap.len() <= 3 {
540        return false;
541    }
542    !simplify_if_no_alnum || gap.iter().any(|l| line_contains_alnum(l.as_slice()))
543}
544
545fn merge_two_adjacent_conflicts(left: &Hunk, right: &Hunk) -> Option<Hunk> {
546    let Hunk::Conflict {
547        base: b1,
548        ours: o1,
549        theirs: t1,
550    } = left
551    else {
552        return None;
553    };
554    let Hunk::Conflict {
555        base: b2,
556        ours: o2,
557        theirs: t2,
558    } = right
559    else {
560        return None;
561    };
562    let mut merged_base = b1.clone();
563    merged_base.extend(b2.iter().cloned());
564    let mut merged_ours = o1.clone();
565    merged_ours.extend(o2.iter().cloned());
566    let mut merged_theirs = t1.clone();
567    merged_theirs.extend(t2.iter().cloned());
568    Some(Hunk::Conflict {
569        base: merged_base,
570        ours: merged_ours,
571        theirs: merged_theirs,
572    })
573}
574
575/// Git `xdl_simplify_non_conflicts` for our hunk list.
576fn simplify_non_conflicts_between_conflicts(
577    hunks: Vec<Hunk>,
578    simplify_if_no_alnum: bool,
579) -> Vec<Hunk> {
580    let mut out: Vec<Hunk> = Vec::with_capacity(hunks.len());
581    let mut i = 0usize;
582    while i < hunks.len() {
583        let Hunk::Conflict { .. } = &hunks[i] else {
584            out.push(hunks[i].clone());
585            i += 1;
586            continue;
587        };
588        let mut merged = hunks[i].clone();
589        let mut j = i;
590        loop {
591            let Some(Hunk::Unchanged(gap)) = hunks.get(j + 1) else {
592                break;
593            };
594            let Some(Hunk::Conflict { .. }) = hunks.get(j + 2) else {
595                break;
596            };
597            if gap_blocks_merge_between_conflicts(gap, simplify_if_no_alnum) {
598                break;
599            }
600            let Some(m) = merge_two_adjacent_conflicts(&merged, &hunks[j + 2]) else {
601                break;
602            };
603            merged = m;
604            j += 2;
605        }
606        out.push(merged);
607        i = j + 1;
608    }
609    out
610}
611
612/// Split a conflict into sub-hunks by diffing the two sides (git `xdl_refine_conflicts`).
613fn refine_conflicts_by_subdiff(
614    hunks: Vec<Hunk>,
615    algo: Algorithm,
616    ws_mode: &WhitespaceMode,
617) -> Vec<Hunk> {
618    let mut out = Vec::with_capacity(hunks.len());
619    for h in hunks {
620        let Hunk::Conflict { base, ours, theirs } = h else {
621            out.push(h);
622            continue;
623        };
624        if ours.is_empty() || theirs.is_empty() {
625            out.push(Hunk::Conflict { base, ours, theirs });
626            continue;
627        }
628        let o_cmp = normalize_lines_for_compare(&ours, ws_mode);
629        let t_cmp = normalize_lines_for_compare(&theirs, ws_mode);
630        let sub_ops = similar::capture_diff_slices(algo, &o_cmp, &t_cmp);
631        if sub_ops.len() == 1 && sub_ops[0].tag() == DiffTag::Equal {
632            out.push(Hunk::Unchanged(ours));
633            continue;
634        }
635        for op in &sub_ops {
636            let old = op.old_range();
637            let new_ = op.new_range();
638            match op.tag() {
639                DiffTag::Equal => {
640                    if !old.is_empty() {
641                        out.push(Hunk::Unchanged(ours[old.start..old.end].to_vec()));
642                    }
643                }
644                DiffTag::Delete => {
645                    if !old.is_empty() {
646                        out.push(Hunk::OnlyOurs {
647                            base: Vec::new(),
648                            ours: ours[old.start..old.end].to_vec(),
649                        });
650                    }
651                }
652                DiffTag::Insert => {
653                    if !new_.is_empty() {
654                        out.push(Hunk::OnlyTheirs {
655                            base: Vec::new(),
656                            theirs: theirs[new_.start..new_.end].to_vec(),
657                        });
658                    }
659                }
660                DiffTag::Replace => {
661                    out.push(Hunk::Conflict {
662                        base: Vec::new(),
663                        ours: ours[old.start..old.end].to_vec(),
664                        theirs: theirs[new_.start..new_.end].to_vec(),
665                    });
666                }
667            }
668        }
669    }
670    out
671}
672
673/// A classified merge region (owns its lines).
674#[derive(Debug, Clone)]
675enum Hunk {
676    /// Lines unchanged by both sides (base content).
677    Unchanged(Vec<Vec<u8>>),
678    /// Lines changed only by ours.
679    OnlyOurs {
680        /// Base lines for the changed region (empty for pure insertions).
681        base: Vec<Vec<u8>>,
682        /// Output lines from ours.
683        ours: Vec<Vec<u8>>,
684    },
685    /// Lines changed only by theirs.
686    OnlyTheirs {
687        /// Base lines for the changed region (empty for pure insertions).
688        base: Vec<Vec<u8>>,
689        /// Output lines from theirs.
690        theirs: Vec<Vec<u8>>,
691    },
692    /// Lines changed by both sides — a conflict.
693    Conflict {
694        base: Vec<Vec<u8>>,
695        ours: Vec<Vec<u8>>,
696        theirs: Vec<Vec<u8>>,
697    },
698}
699
700/// Compute the merge hunks from two diff sequences against the same base.
701///
702/// Uses a position-by-position scan with op-based expansion to ensure that
703/// changed regions spanning multiple lines are not split mid-op.
704fn compute_hunks(
705    base: &[Vec<u8>],
706    ours: &[Vec<u8>],
707    theirs: &[Vec<u8>],
708    ours_ops: &[DiffOp],
709    theirs_ops: &[DiffOp],
710    ws_mode: &WhitespaceMode,
711) -> Vec<Hunk> {
712    let ours_changed = changed_mask(ours_ops, base.len());
713    let theirs_changed = changed_mask(theirs_ops, base.len());
714
715    let ours_inserts = collect_inserts(ours_ops, base.len());
716    let theirs_inserts = collect_inserts(theirs_ops, base.len());
717
718    let mut hunks: Vec<Hunk> = Vec::new();
719    let mut pos = 0usize;
720
721    while pos <= base.len() {
722        // Emit pure insertions before this base position.
723        emit_inserts_at(
724            pos,
725            &ours_inserts,
726            &theirs_inserts,
727            ours,
728            theirs,
729            &mut hunks,
730        );
731
732        if pos == base.len() {
733            break;
734        }
735
736        let o = ours_changed[pos];
737        let t = theirs_changed[pos];
738
739        if !o && !t {
740            // Unchanged run. Stop before a position that has pending insertions
741            // on either side so that insertions are emitted in-order at the
742            // correct base position.
743            let mut end = pos + 1;
744            while end < base.len()
745                && !ours_changed[end]
746                && !theirs_changed[end]
747                && ours_inserts[end].is_empty()
748                && theirs_inserts[end].is_empty()
749            {
750                end += 1;
751            }
752            let terminator_only_diff = base.len() == ours.len()
753                && (pos..end).all(|i| merge_line_equal(&base[i], &ours[i]))
754                && (pos..end).any(|i| base[i] != ours[i]);
755            let unchanged_lines = if ws_mode.ignore_all_space
756                || ws_mode.ignore_space_change
757                || ws_mode.ignore_space_at_eol
758                || ws_mode.ignore_cr_at_eol
759                || terminator_only_diff
760            {
761                &ours[pos..end]
762            } else {
763                &base[pos..end]
764            };
765            hunks.push(Hunk::Unchanged(unchanged_lines.to_vec()));
766            pos = end;
767            continue;
768        }
769
770        // Changed region: expand end until all overlapping Changed ops from
771        // either side are fully consumed.  Repeat until stable.
772        let mut end = pos + 1;
773        loop {
774            let new_end = furthest_changed_op_end(ours_ops, pos, end)
775                .max(furthest_changed_op_end(theirs_ops, pos, end));
776            if new_end <= end {
777                break;
778            }
779            end = new_end;
780        }
781
782        // Classify the full range [pos..end).
783        let any_ours = (pos..end).any(|p| ours_changed[p]);
784        let any_theirs = (pos..end).any(|p| theirs_changed[p]);
785
786        match (any_ours, any_theirs) {
787            (true, false) => {
788                let c = collect_new_lines(ours_ops, ours, pos, end);
789                hunks.push(Hunk::OnlyOurs {
790                    base: base[pos..end].to_vec(),
791                    ours: c,
792                });
793            }
794            (false, true) => {
795                let c = collect_new_lines(theirs_ops, theirs, pos, end);
796                hunks.push(Hunk::OnlyTheirs {
797                    base: base[pos..end].to_vec(),
798                    theirs: c,
799                });
800            }
801            (true, true) => {
802                let o = collect_new_lines(ours_ops, ours, pos, end);
803                let t = collect_new_lines(theirs_ops, theirs, pos, end);
804                // Git reports a conflict when both sides touch the same base region, even if the
805                // resulting lines are textually identical (see `t4108-apply-threeway`).
806                hunks.push(Hunk::Conflict {
807                    base: base[pos..end].to_vec(),
808                    ours: o,
809                    theirs: t,
810                });
811            }
812            (false, false) => {
813                // Should not happen, but treat as unchanged.
814                hunks.push(Hunk::Unchanged(base[pos..end].to_vec()));
815            }
816        }
817
818        pos = end;
819    }
820
821    hunks
822}
823
824/// Build a boolean mask: `mask[i]` is `true` if base line `i` is covered by
825/// a non-Equal op.
826fn changed_mask(ops: &[DiffOp], base_len: usize) -> Vec<bool> {
827    let mut mask = vec![false; base_len];
828    for op in ops {
829        if op.tag() == DiffTag::Equal {
830            continue;
831        }
832        for p in op.old_range() {
833            if p < base_len {
834                mask[p] = true;
835            }
836        }
837    }
838    mask
839}
840
841/// Collect pure insertions (ops with empty old_range) at each base position.
842///
843/// Returns a `Vec` of length `base_len + 1`; entry `i` holds all
844/// `(new_start, new_end)` ranges inserted before base line `i`.
845fn collect_inserts(ops: &[DiffOp], base_len: usize) -> Vec<Vec<(usize, usize)>> {
846    let mut result: Vec<Vec<(usize, usize)>> = vec![Vec::new(); base_len + 1];
847    for op in ops {
848        let old = op.old_range();
849        let new_ = op.new_range();
850        if old.is_empty() && !new_.is_empty() {
851            let pos = old.start.min(base_len);
852            result[pos].push((new_.start, new_.end));
853        }
854    }
855    result
856}
857
858/// Emit hunks for pure insertions at base position `pos`.
859fn emit_inserts_at(
860    pos: usize,
861    ours_inserts: &[Vec<(usize, usize)>],
862    theirs_inserts: &[Vec<(usize, usize)>],
863    ours: &[Vec<u8>],
864    theirs: &[Vec<u8>],
865    hunks: &mut Vec<Hunk>,
866) {
867    let o_ins = &ours_inserts[pos];
868    let t_ins = &theirs_inserts[pos];
869
870    let has_ours = !o_ins.is_empty();
871    let has_theirs = !t_ins.is_empty();
872
873    if has_ours && has_theirs {
874        // Both sides insert at the same position. If they share a common
875        // prefix, emit that prefix as unchanged and only conflict on the
876        // diverging remainder. This preserves superset insertions.
877        let o_lines: Vec<Vec<u8>> = o_ins
878            .iter()
879            .flat_map(|&(s, e)| ours[s..e].to_vec())
880            .collect();
881        let t_lines: Vec<Vec<u8>> = t_ins
882            .iter()
883            .flat_map(|&(s, e)| theirs[s..e].to_vec())
884            .collect();
885
886        let mut common_len = 0usize;
887        while common_len < o_lines.len()
888            && common_len < t_lines.len()
889            && o_lines[common_len] == t_lines[common_len]
890        {
891            common_len += 1;
892        }
893
894        if common_len > 0 {
895            hunks.push(Hunk::Unchanged(o_lines[..common_len].to_vec()));
896        }
897
898        let ours_tail = o_lines[common_len..].to_vec();
899        let theirs_tail = t_lines[common_len..].to_vec();
900
901        let ours_has_extra = !ours_tail.is_empty();
902        let theirs_has_extra = !theirs_tail.is_empty();
903
904        if ours_has_extra && theirs_has_extra {
905            hunks.push(Hunk::Conflict {
906                base: Vec::new(),
907                ours: ours_tail,
908                theirs: theirs_tail,
909            });
910        } else if ours_has_extra {
911            hunks.push(Hunk::OnlyOurs {
912                base: Vec::new(),
913                ours: ours_tail,
914            });
915        } else if theirs_has_extra {
916            hunks.push(Hunk::OnlyTheirs {
917                base: Vec::new(),
918                theirs: theirs_tail,
919            });
920        }
921    } else if has_ours {
922        for &(ns, ne) in o_ins {
923            let lines: Vec<Vec<u8>> = ours[ns..ne].to_vec();
924            if !lines.is_empty() {
925                hunks.push(Hunk::OnlyOurs {
926                    base: Vec::new(),
927                    ours: lines,
928                });
929            }
930        }
931    } else if has_theirs {
932        for &(ns, ne) in t_ins {
933            let lines: Vec<Vec<u8>> = theirs[ns..ne].to_vec();
934            if !lines.is_empty() {
935                hunks.push(Hunk::OnlyTheirs {
936                    base: Vec::new(),
937                    theirs: lines,
938                });
939            }
940        }
941    }
942}
943
944fn adjust_zealous_hunks(hunks: Vec<Hunk>) -> Vec<Hunk> {
945    let mut out: Vec<Hunk> = Vec::new();
946    let mut i = 0usize;
947
948    while i < hunks.len() {
949        let mut consumed = 1usize;
950        let mut transformed: Option<Vec<Hunk>> = None;
951
952        let (pre_insert, mid_idx) = match &hunks[i] {
953            Hunk::OnlyTheirs { base, theirs } if base.is_empty() => {
954                (Some(theirs.as_slice()), i + 1)
955            }
956            _ => (None, i),
957        };
958
959        if let Some(Hunk::OnlyOurs { base, ours }) = hunks.get(mid_idx) {
960            if !base.is_empty() {
961                let post_insert = match hunks.get(mid_idx + 1) {
962                    Some(Hunk::OnlyTheirs { base, theirs }) if base.is_empty() => {
963                        Some(theirs.as_slice())
964                    }
965                    _ => None,
966                };
967
968                let mut prefix_len = 0usize;
969                if let Some(pre) = pre_insert {
970                    if !pre.is_empty() && ours.starts_with(pre) {
971                        prefix_len = pre.len();
972                    }
973                }
974
975                let mut suffix_len = 0usize;
976                if let Some(post) = post_insert {
977                    if !post.is_empty() && ours[prefix_len..].ends_with(post) {
978                        suffix_len = post.len();
979                    }
980                }
981
982                if prefix_len > 0 || suffix_len > 0 {
983                    consumed = if pre_insert.is_some() {
984                        if post_insert.is_some() {
985                            3
986                        } else {
987                            2
988                        }
989                    } else if post_insert.is_some() {
990                        2
991                    } else {
992                        1
993                    };
994
995                    let mut replacement: Vec<Hunk> = Vec::new();
996                    if prefix_len > 0 {
997                        replacement.push(Hunk::Unchanged(ours[..prefix_len].to_vec()));
998                    }
999                    replacement.push(Hunk::Conflict {
1000                        base: base.clone(),
1001                        ours: ours[prefix_len..ours.len() - suffix_len].to_vec(),
1002                        theirs: base.clone(),
1003                    });
1004                    if suffix_len > 0 {
1005                        replacement.push(Hunk::Unchanged(ours[ours.len() - suffix_len..].to_vec()));
1006                    }
1007                    transformed = Some(replacement);
1008                }
1009            }
1010        }
1011
1012        if let Some(replacement) = transformed {
1013            for h in replacement {
1014                push_hunk_with_unchanged_merge(&mut out, h);
1015            }
1016            i += consumed;
1017            continue;
1018        }
1019
1020        push_hunk_with_unchanged_merge(&mut out, hunks[i].clone());
1021        i += 1;
1022    }
1023
1024    out
1025}
1026
1027/// If one side replaces base lines and the other appends an insertion immediately after
1028/// that base span, `compute_hunks` emits two hunks (`Only*` + trailing insert). Git treats
1029/// the combined region as one conflict (e.g. base `hello\n`, ours adds `hello\n`, theirs
1030/// replaces with `remove-conflict\n`).
1031/// When both sides edit **adjacent** base lines but each line is changed on only one side, Git's
1032/// merge reports a single conflict spanning both lines (`t4108-apply-threeway`).
1033fn merge_adjacent_one_sided_line_changes_to_conflict(hunks: Vec<Hunk>) -> Vec<Hunk> {
1034    let mut out: Vec<Hunk> = Vec::with_capacity(hunks.len());
1035    let mut i = 0usize;
1036    while i < hunks.len() {
1037        let merged = match (&hunks[i], hunks.get(i + 1)) {
1038            (
1039                Hunk::OnlyTheirs {
1040                    base: b1,
1041                    theirs: t1,
1042                },
1043                Some(Hunk::OnlyOurs { base: b2, ours: o2 }),
1044            ) if b1.len() == 1 && b2.len() == 1 && t1.len() == 1 && o2.len() == 1 => {
1045                Some(Hunk::Conflict {
1046                    base: vec![b1[0].clone(), b2[0].clone()],
1047                    ours: vec![b1[0].clone(), o2[0].clone()],
1048                    theirs: vec![t1[0].clone(), b2[0].clone()],
1049                })
1050            }
1051            (
1052                Hunk::OnlyOurs { base: b1, ours: o1 },
1053                Some(Hunk::OnlyTheirs {
1054                    base: b2,
1055                    theirs: t2,
1056                }),
1057            ) if b1.len() == 1 && b2.len() == 1 && o1.len() == 1 && t2.len() == 1 => {
1058                Some(Hunk::Conflict {
1059                    base: vec![b1[0].clone(), b2[0].clone()],
1060                    ours: vec![o1[0].clone(), b2[0].clone()],
1061                    theirs: vec![b1[0].clone(), t2[0].clone()],
1062                })
1063            }
1064            _ => None,
1065        };
1066        if let Some(h) = merged {
1067            out.push(h);
1068            i += 2;
1069        } else {
1070            out.push(hunks[i].clone());
1071            i += 1;
1072        }
1073    }
1074    out
1075}
1076
1077fn merge_adjacent_replace_and_trailing_insert_conflicts(hunks: Vec<Hunk>) -> Vec<Hunk> {
1078    let mut out: Vec<Hunk> = Vec::with_capacity(hunks.len());
1079    let mut i = 0usize;
1080    while i < hunks.len() {
1081        let merged = match (&hunks[i], hunks.get(i + 1)) {
1082            (Hunk::OnlyTheirs { base, theirs }, Some(Hunk::OnlyOurs { base: bo, ours: o }))
1083                if !base.is_empty() && bo.is_empty() && !o.is_empty() && !theirs.is_empty() =>
1084            {
1085                Some(Hunk::Conflict {
1086                    base: base.clone(),
1087                    ours: o.clone(),
1088                    theirs: theirs.clone(),
1089                })
1090            }
1091            (
1092                Hunk::OnlyOurs { base, ours },
1093                Some(Hunk::OnlyTheirs {
1094                    base: bt,
1095                    theirs: t,
1096                }),
1097            ) if !base.is_empty() && bt.is_empty() && !t.is_empty() && !ours.is_empty() => {
1098                Some(Hunk::Conflict {
1099                    base: base.clone(),
1100                    ours: ours.clone(),
1101                    theirs: t.clone(),
1102                })
1103            }
1104            _ => None,
1105        };
1106        if let Some(h) = merged {
1107            out.push(h);
1108            i += 2;
1109        } else {
1110            out.push(hunks[i].clone());
1111            i += 1;
1112        }
1113    }
1114    out
1115}
1116
1117fn coalesce_nearby_conflicts(hunks: Vec<Hunk>, max_gap_lines: usize, enable: bool) -> Vec<Hunk> {
1118    if !enable {
1119        return hunks;
1120    }
1121    let mut out: Vec<Hunk> = Vec::new();
1122    let mut i = 0usize;
1123
1124    while i < hunks.len() {
1125        let Some(Hunk::Conflict { base, ours, theirs }) = hunks.get(i) else {
1126            out.push(hunks[i].clone());
1127            i += 1;
1128            continue;
1129        };
1130
1131        let mut merged_base = base.clone();
1132        let mut merged_ours = ours.clone();
1133        let mut merged_theirs = theirs.clone();
1134        let mut j = i;
1135
1136        loop {
1137            let Some(Hunk::Unchanged(gap)) = hunks.get(j + 1) else {
1138                break;
1139            };
1140            let Some(Hunk::Conflict {
1141                base: next_base,
1142                ours: next_ours,
1143                theirs: next_theirs,
1144            }) = hunks.get(j + 2)
1145            else {
1146                break;
1147            };
1148            if gap.len() > max_gap_lines {
1149                break;
1150            }
1151
1152            merged_base.extend(gap.iter().cloned());
1153            merged_base.extend(next_base.iter().cloned());
1154            merged_ours.extend(gap.iter().cloned());
1155            merged_ours.extend(next_ours.iter().cloned());
1156            merged_theirs.extend(gap.iter().cloned());
1157            merged_theirs.extend(next_theirs.iter().cloned());
1158            j += 2;
1159        }
1160
1161        out.push(Hunk::Conflict {
1162            base: merged_base,
1163            ours: merged_ours,
1164            theirs: merged_theirs,
1165        });
1166        i = j + 1;
1167    }
1168
1169    out
1170}
1171
1172fn push_hunk_with_unchanged_merge(out: &mut Vec<Hunk>, hunk: Hunk) {
1173    match hunk {
1174        Hunk::Unchanged(mut lines) => {
1175            if let Some(Hunk::Unchanged(prev)) = out.last_mut() {
1176                prev.append(&mut lines);
1177            } else if !lines.is_empty() {
1178                out.push(Hunk::Unchanged(lines));
1179            }
1180        }
1181        other => out.push(other),
1182    }
1183}
1184
1185/// Return the maximum `old_range().end` among all Changed ops that overlap
1186/// with `[run_start..current_end)`.  Returns `current_end` if nothing
1187/// extends further.
1188fn furthest_changed_op_end(ops: &[DiffOp], run_start: usize, current_end: usize) -> usize {
1189    let mut max = current_end;
1190    for op in ops {
1191        if op.tag() == DiffTag::Equal {
1192            continue;
1193        }
1194        let old = op.old_range();
1195        if old.start < current_end && old.end > run_start && old.end > max {
1196            max = old.end;
1197        }
1198    }
1199    max
1200}
1201
1202/// Collect new (output) lines for base range `[base_start..base_end)`.
1203///
1204/// For each op whose `old_range` overlaps the range:
1205/// - Equal ops contribute their corresponding new-side lines.
1206/// - Changed ops contribute their full replacement.
1207fn collect_new_lines(
1208    ops: &[DiffOp],
1209    new: &[Vec<u8>],
1210    base_start: usize,
1211    base_end: usize,
1212) -> Vec<Vec<u8>> {
1213    let mut lines = Vec::new();
1214    for op in ops {
1215        let old = op.old_range();
1216        let new_ = op.new_range();
1217        if old.is_empty() {
1218            continue; // pure insertion, handled separately
1219        }
1220        if old.end <= base_start || old.start >= base_end {
1221            continue; // no overlap
1222        }
1223        if op.tag() == DiffTag::Equal {
1224            let overlap_start = base_start.max(old.start);
1225            let overlap_end = base_end.min(old.end);
1226            let offset = overlap_start - old.start;
1227            let len = overlap_end - overlap_start;
1228            for i in offset..offset + len {
1229                if new_.start + i < new_.end {
1230                    lines.push(new[new_.start + i].clone());
1231                }
1232            }
1233        } else {
1234            for i in new_.clone() {
1235                lines.push(new[i].clone());
1236            }
1237        }
1238    }
1239    lines
1240}
1241
1242/// Run Myers diff on two line slices.
1243fn _diff_ops(old: &[Vec<u8>], new: &[Vec<u8>]) -> Vec<DiffOp> {
1244    similar::capture_diff_slices(Algorithm::Myers, old, new)
1245}
1246
1247/// Split a byte slice into lines, each including its trailing `\n` if present.
1248fn split_lines(data: &[u8]) -> Vec<Vec<u8>> {
1249    if data.is_empty() {
1250        return Vec::new();
1251    }
1252    let mut lines = Vec::new();
1253    let mut start = 0;
1254    for i in 0..data.len() {
1255        if data[i] == b'\n' {
1256            lines.push(data[start..=i].to_vec());
1257            start = i + 1;
1258        }
1259    }
1260    if start < data.len() {
1261        lines.push(data[start..].to_vec());
1262    }
1263    lines
1264}
1265
1266#[derive(Clone, Copy, Debug, Default)]
1267struct WhitespaceMode {
1268    ignore_all_space: bool,
1269    ignore_space_change: bool,
1270    ignore_space_at_eol: bool,
1271    ignore_cr_at_eol: bool,
1272}
1273
1274fn normalize_lines_for_compare(lines: &[Vec<u8>], mode: &WhitespaceMode) -> Vec<Vec<u8>> {
1275    lines
1276        .iter()
1277        .map(|line| normalize_line_for_compare(line, mode))
1278        .collect()
1279}
1280
1281fn lines_for_merge_diff_compare(lines: &[Vec<u8>], mode: &WhitespaceMode) -> Vec<Vec<u8>> {
1282    lines
1283        .iter()
1284        .map(|line| normalize_line_for_compare(strip_line_terminator(line), mode))
1285        .collect()
1286}
1287
1288fn normalize_line_for_compare(line: &[u8], mode: &WhitespaceMode) -> Vec<u8> {
1289    let mut bytes = line.to_vec();
1290
1291    if mode.ignore_cr_at_eol && bytes.ends_with(b"\r\n") {
1292        let len = bytes.len();
1293        bytes.remove(len - 2);
1294    }
1295
1296    if mode.ignore_all_space {
1297        return bytes
1298            .into_iter()
1299            .filter(|b| !b.is_ascii_whitespace())
1300            .collect();
1301    }
1302
1303    if mode.ignore_space_change {
1304        let mut out = Vec::with_capacity(bytes.len());
1305        let mut in_ws = false;
1306        for ch in bytes {
1307            if ch.is_ascii_whitespace() {
1308                if !in_ws {
1309                    out.push(b' ');
1310                    in_ws = true;
1311                }
1312            } else {
1313                out.push(ch);
1314                in_ws = false;
1315            }
1316        }
1317        while out.last().is_some_and(|b| b.is_ascii_whitespace()) {
1318            out.pop();
1319        }
1320        return out;
1321    }
1322
1323    if mode.ignore_space_at_eol {
1324        if bytes.last().copied() == Some(b'\n') {
1325            let mut body = bytes[..bytes.len() - 1].to_vec();
1326            while body.last().is_some_and(|b| b.is_ascii_whitespace()) {
1327                body.pop();
1328            }
1329            body.push(b'\n');
1330            bytes = body;
1331        } else {
1332            while bytes.last().is_some_and(|b| b.is_ascii_whitespace()) {
1333                bytes.pop();
1334            }
1335        }
1336    }
1337
1338    bytes
1339}
1340
1341/// Returns `true` if the byte slice appears to be binary content.
1342///
1343/// Uses the same heuristic as git: any NUL byte means binary.
1344#[must_use]
1345pub fn is_binary(data: &[u8]) -> bool {
1346    data.contains(&0u8)
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351    use super::*;
1352
1353    fn do_merge(base: &str, ours: &str, theirs: &str) -> (String, usize) {
1354        let input = MergeInput {
1355            base: base.as_bytes(),
1356            ours: ours.as_bytes(),
1357            theirs: theirs.as_bytes(),
1358            label_ours: "ours",
1359            label_base: "base",
1360            label_theirs: "theirs",
1361            favor: MergeFavor::None,
1362            style: ConflictStyle::Merge,
1363            marker_size: 7,
1364            diff_algorithm: None,
1365            ignore_all_space: false,
1366            ignore_space_change: false,
1367            ignore_space_at_eol: false,
1368            ignore_cr_at_eol: false,
1369        };
1370        let out = merge(&input).unwrap();
1371        (String::from_utf8(out.content).unwrap(), out.conflicts)
1372    }
1373
1374    #[test]
1375    fn no_changes() {
1376        let t = "line1\nline2\nline3\n";
1377        let (r, c) = do_merge(t, t, t);
1378        assert_eq!(r, t);
1379        assert_eq!(c, 0);
1380    }
1381
1382    #[test]
1383    fn only_ours() {
1384        let (r, c) = do_merge("a\nb\nc\n", "a\nB\nc\n", "a\nb\nc\n");
1385        assert_eq!(r, "a\nB\nc\n");
1386        assert_eq!(c, 0);
1387    }
1388
1389    #[test]
1390    fn only_theirs() {
1391        let (r, c) = do_merge("a\nb\nc\n", "a\nb\nc\n", "a\nB\nc\n");
1392        assert_eq!(r, "a\nB\nc\n");
1393        assert_eq!(c, 0);
1394    }
1395
1396    #[test]
1397    fn conflict() {
1398        let (r, c) = do_merge("a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
1399        assert_eq!(c, 1);
1400        assert!(r.contains("<<<<<<< ours\nX\n=======\nY\n>>>>>>> theirs"));
1401    }
1402
1403    #[test]
1404    fn t4108_style_overlapping_edits_conflict() {
1405        let base = "1\n2\n3\n4\n5\n6\n7\n";
1406        let ours = "1\ntwo\n3\n4\n5\nsix\n7\n";
1407        let theirs = "1\n2\n3\n4\nfive\n6\n7\n";
1408        let (_r, c) = do_merge(base, ours, theirs);
1409        assert!(
1410            c >= 1,
1411            "expected at least one conflict region (Git merge reports CONFLICT for this shape)"
1412        );
1413    }
1414
1415    #[test]
1416    fn conflict_delete_vs_change() {
1417        // ours deletes lines 1-2, theirs changes line 1 → conflict
1418        let base = "a\nb\nc\n";
1419        let ours = "c\n"; // deleted a and b
1420        let theirs = "A\nb\nc\n"; // changed a → A
1421        let (r, c) = do_merge(base, ours, theirs);
1422        assert_eq!(c, 1, "expected conflict, got: {r:?}");
1423    }
1424
1425    #[test]
1426    fn conflict_replace_vs_insert_after_same_line() {
1427        // Base one line; ours duplicates it; theirs replaces it — Git reports content conflict.
1428        let base = "hello\n";
1429        let ours = "hello\nhello\n";
1430        let theirs = "remove-conflict\n";
1431        let (r, c) = do_merge(base, ours, theirs);
1432        assert_eq!(c, 1, "expected conflict, got: {r:?}");
1433    }
1434
1435    #[test]
1436    fn favor_ours() {
1437        let input = MergeInput {
1438            base: b"a\nb\nc\n",
1439            ours: b"a\nX\nc\n",
1440            theirs: b"a\nY\nc\n",
1441            label_ours: "o",
1442            label_base: "b",
1443            label_theirs: "t",
1444            favor: MergeFavor::Ours,
1445            style: ConflictStyle::Merge,
1446            marker_size: 7,
1447            diff_algorithm: None,
1448            ignore_all_space: false,
1449            ignore_space_change: false,
1450            ignore_space_at_eol: false,
1451            ignore_cr_at_eol: false,
1452        };
1453        let out = merge(&input).unwrap();
1454        assert_eq!(out.content, b"a\nX\nc\n");
1455        assert_eq!(out.conflicts, 0);
1456    }
1457
1458    #[test]
1459    fn union_missing_lf() {
1460        let input = MergeInput {
1461            base: b"line1\nline2\nline3",
1462            ours: b"line1\nline2\nline3x",
1463            theirs: b"line1\nline2\nline3y",
1464            label_ours: "o",
1465            label_base: "b",
1466            label_theirs: "t",
1467            favor: MergeFavor::Union,
1468            style: ConflictStyle::Merge,
1469            marker_size: 7,
1470            diff_algorithm: None,
1471            ignore_all_space: false,
1472            ignore_space_change: false,
1473            ignore_space_at_eol: false,
1474            ignore_cr_at_eol: false,
1475        };
1476        let out = merge(&input).unwrap();
1477        // union: line3x\nline3y (newline inserted between no-LF lines)
1478        assert_eq!(out.content, b"line1\nline2\nline3x\nline3y");
1479    }
1480
1481    #[test]
1482    fn zdiff3_interesting_conflict_shape() {
1483        let input = MergeInput {
1484            base: b"1\n2\n3\n4\n5\n6\n7\n8\n9\n",
1485            ours: b"1\n2\n3\n4\nA\nB\nC\nD\nE\nF\nG\nH\nI\nJ\n7\n8\n9\n",
1486            theirs: b"1\n2\n3\n4\nA\nB\nC\n5\n6\nG\nH\nI\nJ\n7\n8\n9\n",
1487            label_ours: "HEAD",
1488            label_base: "base",
1489            label_theirs: "right^0",
1490            favor: MergeFavor::None,
1491            style: ConflictStyle::ZealousDiff3,
1492            marker_size: 7,
1493            diff_algorithm: None,
1494            ignore_all_space: false,
1495            ignore_space_change: false,
1496            ignore_space_at_eol: false,
1497            ignore_cr_at_eol: false,
1498        };
1499        let out = merge(&input).unwrap();
1500        let rendered = String::from_utf8(out.content).unwrap();
1501        assert_eq!(out.conflicts, 1, "{rendered}");
1502        assert!(rendered.contains("<<<<<<< HEAD\nD\nE\nF\n"), "{rendered}");
1503    }
1504
1505    #[test]
1506    fn preserves_shared_and_superset_insertions() {
1507        let input = MergeInput {
1508            base: b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n",
1509            ours: b"1\n2\n3\n4\n5\n5.5\n6\n7\n8\n9\n10\n",
1510            theirs: b"1\n2\n3\n4\n5\n5.5\n6\n7\n8\n9\n10\n10.5\n",
1511            label_ours: "ours",
1512            label_base: "base",
1513            label_theirs: "theirs",
1514            favor: MergeFavor::None,
1515            style: ConflictStyle::Merge,
1516            marker_size: 7,
1517            diff_algorithm: None,
1518            ignore_all_space: false,
1519            ignore_space_change: false,
1520            ignore_space_at_eol: false,
1521            ignore_cr_at_eol: false,
1522        };
1523        let base_lines = split_lines(input.base);
1524        let ours_lines = split_lines(input.ours);
1525        let theirs_lines = split_lines(input.theirs);
1526        let ws_mode = WhitespaceMode::default();
1527        let base_compare_lines = normalize_lines_for_compare(&base_lines, &ws_mode);
1528        let ours_compare_lines = normalize_lines_for_compare(&ours_lines, &ws_mode);
1529        let theirs_compare_lines = normalize_lines_for_compare(&theirs_lines, &ws_mode);
1530        let ours_ops = similar::capture_diff_slices(
1531            Algorithm::Myers,
1532            &base_compare_lines,
1533            &ours_compare_lines,
1534        );
1535        let theirs_ops = similar::capture_diff_slices(
1536            Algorithm::Myers,
1537            &base_compare_lines,
1538            &theirs_compare_lines,
1539        );
1540        let hunks = compute_hunks(
1541            &base_lines,
1542            &ours_lines,
1543            &theirs_lines,
1544            &ours_ops,
1545            &theirs_ops,
1546            &ws_mode,
1547        );
1548        assert_eq!(
1549            hunks.len(),
1550            4,
1551            "expected unchanged shared insertion and theirs-only tail insertion"
1552        );
1553        let out = merge(&input).unwrap();
1554        let rendered = String::from_utf8(out.content).unwrap();
1555        assert_eq!(out.conflicts, 0, "{rendered}");
1556        assert_eq!(
1557            rendered, "1\n2\n3\n4\n5\n5.5\n6\n7\n8\n9\n10\n10.5\n",
1558            "{rendered}"
1559        );
1560    }
1561}