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, serde::Serialize, serde::Deserialize)]
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 = fold_adjacent_inserts_into_conflict(hunks);
132        hunks = merge_adjacent_one_sided_line_changes_to_conflict(hunks);
133    }
134    // Match `git merge-file` (`XDL_MERGE_ZEALOUS_ALNUM`): refine then simplify conflicts.
135    if !matches!(
136        input.style,
137        ConflictStyle::Diff3 | ConflictStyle::ZealousDiff3
138    ) {
139        hunks = refine_conflicts_by_subdiff(hunks, algo, &ws_mode);
140        hunks = simplify_non_conflicts_between_conflicts(hunks, true);
141    }
142    // Git keeps adjacent conflict regions separate when identical lines appear
143    // between them (e.g. t4200-rerere); do not merge Conflict+gap+Conflict.
144    hunks = coalesce_nearby_conflicts(hunks, 3, false);
145    if matches!(input.style, ConflictStyle::ZealousDiff3) {
146        hunks = adjust_zealous_hunks(hunks);
147    }
148
149    let marker = if input.marker_size == 0 {
150        7
151    } else {
152        input.marker_size
153    };
154
155    let mut content: Vec<u8> = Vec::new();
156    let mut conflicts = 0usize;
157    let mut ours_line_pos = 0usize;
158    let mut theirs_line_pos = 0usize;
159
160    for (idx, hunk) in hunks.iter().enumerate() {
161        match hunk {
162            Hunk::Unchanged(lines) => {
163                append_lines(&mut content, lines);
164                ours_line_pos += lines.len();
165                theirs_line_pos += lines.len();
166            }
167            Hunk::OnlyOurs { ours, .. } => {
168                append_lines(&mut content, ours);
169                ours_line_pos += ours.len();
170            }
171            Hunk::OnlyTheirs { theirs, .. } => {
172                append_lines(&mut content, theirs);
173                theirs_line_pos += theirs.len();
174            }
175            Hunk::Conflict { base, ours, theirs } => {
176                let conflict_ours_start = ours_line_pos;
177                let conflict_theirs_start = theirs_line_pos;
178                match input.favor {
179                    MergeFavor::Ours => {
180                        append_lines(&mut content, ours);
181                        ours_line_pos += ours.len();
182                    }
183                    MergeFavor::Theirs => {
184                        append_lines(&mut content, theirs);
185                        theirs_line_pos += theirs.len();
186                    }
187                    MergeFavor::Union => {
188                        append_lines(&mut content, ours);
189                        // If the ours portion doesn't end with \n and theirs is
190                        // non-empty, insert a newline so both sections appear as
191                        // separate lines (matches git's missing-LF handling).
192                        if !theirs.is_empty()
193                            && !ours.is_empty()
194                            && !ours.last().map(|l| l.ends_with(b"\n")).unwrap_or(false)
195                        {
196                            content.push(b'\n');
197                        }
198                        append_lines(&mut content, theirs);
199                        ours_line_pos += ours.len();
200                        theirs_line_pos += theirs.len();
201                    }
202                    MergeFavor::None => {
203                        conflicts += 1;
204                        if matches!(input.style, ConflictStyle::ZealousDiff3) {
205                            let (mut prefix_len, mut suffix_len) =
206                                common_prefix_suffix(ours, theirs);
207
208                            if prefix_len > 0
209                                && idx > 0
210                                && hunk_output_lines(&hunks[idx - 1])
211                                    .map(|lines| lines_end_with(lines, &ours[..prefix_len]))
212                                    .unwrap_or(false)
213                            {
214                                prefix_len = 0;
215                            }
216
217                            if suffix_len > 0
218                                && idx + 1 < hunks.len()
219                                && hunk_output_lines(&hunks[idx + 1])
220                                    .map(|lines| {
221                                        lines_start_with(lines, &ours[ours.len() - suffix_len..])
222                                    })
223                                    .unwrap_or(false)
224                            {
225                                suffix_len = 0;
226                            }
227
228                            if prefix_len > 0 {
229                                append_lines(&mut content, &ours[..prefix_len]);
230                            }
231                            let i1 = conflict_ours_start + prefix_len;
232                            let i2 = conflict_theirs_start + prefix_len;
233                            let needs_cr = conflict_markers_need_cr_at(
234                                &ours_lines,
235                                &theirs_lines,
236                                &base_lines,
237                                i1,
238                                i2,
239                            );
240                            emit_conflict(
241                                &mut content,
242                                base,
243                                &ours[prefix_len..ours.len() - suffix_len],
244                                &theirs[prefix_len..theirs.len() - suffix_len],
245                                input.label_ours,
246                                input.label_base,
247                                input.label_theirs,
248                                input.style,
249                                marker,
250                                needs_cr,
251                            );
252                            if suffix_len > 0 {
253                                append_lines(&mut content, &ours[ours.len() - suffix_len..]);
254                            }
255                        } else if matches!(input.style, ConflictStyle::Merge) {
256                            let (prefix_len, suffix_len) =
257                                common_prefix_suffix_merge_lines(ours, theirs, &ws_mode);
258                            let pre = &ours[..prefix_len];
259                            let suf_start = ours.len().saturating_sub(suffix_len);
260                            let o_mid = &ours[prefix_len..suf_start];
261                            let t_mid =
262                                &theirs[prefix_len..theirs.len().saturating_sub(suffix_len)];
263                            let suf = &ours[suf_start..];
264                            append_lines(&mut content, pre);
265                            let i1 = conflict_ours_start + prefix_len;
266                            let i2 = conflict_theirs_start + prefix_len;
267                            let needs_cr = conflict_markers_need_cr_at(
268                                &ours_lines,
269                                &theirs_lines,
270                                &base_lines,
271                                i1,
272                                i2,
273                            );
274                            emit_conflict(
275                                &mut content,
276                                base,
277                                o_mid,
278                                t_mid,
279                                input.label_ours,
280                                input.label_base,
281                                input.label_theirs,
282                                input.style,
283                                marker,
284                                needs_cr,
285                            );
286                            append_lines(&mut content, suf);
287                        } else {
288                            let needs_cr = conflict_markers_need_cr_at(
289                                &ours_lines,
290                                &theirs_lines,
291                                &base_lines,
292                                conflict_ours_start,
293                                conflict_theirs_start,
294                            );
295                            emit_conflict(
296                                &mut content,
297                                base,
298                                ours,
299                                theirs,
300                                input.label_ours,
301                                input.label_base,
302                                input.label_theirs,
303                                input.style,
304                                marker,
305                                needs_cr,
306                            );
307                        }
308                        ours_line_pos += ours.len();
309                        theirs_line_pos += theirs.len();
310                    }
311                }
312            }
313        }
314    }
315
316    Ok(MergeOutput { content, conflicts })
317}
318
319fn append_lines(out: &mut Vec<u8>, lines: &[Vec<u8>]) {
320    // If previous content ended without a newline and this hunk adds more
321    // content, insert a newline separator to avoid merging lines together.
322    if !out.is_empty() && !out.ends_with(b"\n") && !lines.is_empty() {
323        out.push(b'\n');
324    }
325    for line in lines {
326        out.extend_from_slice(line);
327    }
328}
329
330fn common_prefix_suffix(ours: &[Vec<u8>], theirs: &[Vec<u8>]) -> (usize, usize) {
331    let mut prefix = 0usize;
332    while prefix < ours.len() && prefix < theirs.len() && ours[prefix] == theirs[prefix] {
333        prefix += 1;
334    }
335
336    let mut suffix = 0usize;
337    while suffix < ours.len().saturating_sub(prefix)
338        && suffix < theirs.len().saturating_sub(prefix)
339        && ours[ours.len() - 1 - suffix] == theirs[theirs.len() - 1 - suffix]
340    {
341        suffix += 1;
342    }
343
344    (prefix, suffix)
345}
346
347fn hunk_output_lines(hunk: &Hunk) -> Option<&[Vec<u8>]> {
348    match hunk {
349        Hunk::Unchanged(lines) => Some(lines),
350        Hunk::OnlyOurs { ours, .. } => Some(ours),
351        Hunk::OnlyTheirs { theirs, .. } => Some(theirs),
352        Hunk::Conflict { .. } => None,
353    }
354}
355
356fn lines_end_with(lines: &[Vec<u8>], suffix: &[Vec<u8>]) -> bool {
357    if suffix.is_empty() || suffix.len() > lines.len() {
358        return false;
359    }
360    lines[lines.len() - suffix.len()..] == *suffix
361}
362
363fn lines_start_with(lines: &[Vec<u8>], prefix: &[Vec<u8>]) -> bool {
364    if prefix.is_empty() || prefix.len() > lines.len() {
365        return false;
366    }
367    lines[..prefix.len()] == *prefix
368}
369
370/// Emit conflict markers into `out`.
371#[allow(clippy::too_many_arguments)]
372fn emit_conflict(
373    out: &mut Vec<u8>,
374    base: &[Vec<u8>],
375    ours: &[Vec<u8>],
376    theirs: &[Vec<u8>],
377    label_ours: &str,
378    label_base: &str,
379    label_theirs: &str,
380    style: ConflictStyle,
381    marker: usize,
382    needs_cr: bool,
383) {
384    let open = "<".repeat(marker);
385    let eq = "=".repeat(marker);
386    let close = ">".repeat(marker);
387    let marker_eol: &[u8] = if needs_cr { b"\r\n" } else { b"\n" };
388
389    // Ensure ours section starts on a new line if the previous content didn't
390    // end with one.
391    if !out.is_empty() && !out.ends_with(b"\n") && !out.ends_with(b"\r\n") {
392        out.extend_from_slice(marker_eol);
393    }
394
395    write_conflict_marker_line(out, &open, Some(label_ours), marker_eol);
396    for line in ours {
397        out.extend_from_slice(line);
398    }
399    // Ensure separator starts on its own line.
400    if out.last().copied() != Some(b'\n') {
401        out.extend_from_slice(marker_eol);
402    }
403    match style {
404        ConflictStyle::Diff3 | ConflictStyle::ZealousDiff3 => {
405            let pipe = "|".repeat(marker);
406            write_conflict_marker_line(out, &pipe, Some(label_base), marker_eol);
407            for line in base {
408                out.extend_from_slice(line);
409            }
410            if out.last().copied() != Some(b'\n') {
411                out.extend_from_slice(marker_eol);
412            }
413            write_conflict_marker_line(out, &eq, None, marker_eol);
414        }
415        ConflictStyle::Merge => {
416            write_conflict_marker_line(out, &eq, None, marker_eol);
417        }
418    }
419    for line in theirs {
420        out.extend_from_slice(line);
421    }
422    if out.last().copied() != Some(b'\n') {
423        out.extend_from_slice(marker_eol);
424    }
425    write_conflict_marker_line(out, &close, Some(label_theirs), marker_eol);
426}
427
428fn write_conflict_marker_line(out: &mut Vec<u8>, marker: &str, label: Option<&str>, eol: &[u8]) {
429    out.extend_from_slice(marker.as_bytes());
430    // `git rerere` recognizes `<<<<<<< ` / `>>>>>>> ` only when there is a space after the marker
431    // run, even if the branch label is empty (`ll_merge` with empty names — `try_replay_merge`).
432    let open_or_close = marker.starts_with('<') || marker.starts_with('>');
433    let has_label = label.is_some_and(|l| !l.is_empty());
434    // Diff3 `|||||||` lines use a space before the base label so they match Git's conflict format
435    // (and `print_sanitized_conflicted_diff` in t4108 can strip the label).
436    if open_or_close || has_label {
437        out.push(b' ');
438    }
439    if let Some(label) = label {
440        if !label.is_empty() {
441            out.extend_from_slice(label.as_bytes());
442        }
443    }
444    out.extend_from_slice(eol);
445}
446
447/// Strip trailing `\n`, `\r\n`, or lone `\r` from a logical line (content kept for output).
448fn strip_line_terminator(line: &[u8]) -> &[u8] {
449    if line.ends_with(b"\r\n") {
450        return &line[..line.len() - 2];
451    }
452    if line.ends_with(b"\n") {
453        return &line[..line.len() - 1];
454    }
455    if line.ends_with(b"\r") {
456        return &line[..line.len() - 1];
457    }
458    line
459}
460
461/// Line body used for merge comparison: drop the record-separating `\n`, but keep a `\r` that
462/// precedes `\n` unless [`WhitespaceMode::ignore_cr_at_eol`] (git record hash / `xdl_recmatch`).
463fn line_content_for_merge_compare(line: &[u8], mode: &WhitespaceMode) -> Vec<u8> {
464    let (body, from_crlf_record) = if line.ends_with(b"\r\n") {
465        (&line[..line.len() - 1], true)
466    } else if line.ends_with(b"\n") {
467        (&line[..line.len() - 1], false)
468    } else {
469        // Incomplete final line: keep a lone trailing `\r` (git does not treat it like `\r\n`).
470        (line, false)
471    };
472    let mut bytes = body.to_vec();
473    if mode.ignore_cr_at_eol && from_crlf_record && bytes.last() == Some(&b'\r') {
474        bytes.pop();
475    }
476    normalize_line_for_compare(&bytes, mode)
477}
478
479/// Compare two lines for merge equality (git `xdl_recmatch` / `xdl_merge_cmp_lines`).
480fn merge_line_equal(a: &[u8], b: &[u8], mode: &WhitespaceMode) -> bool {
481    line_content_for_merge_compare(a, mode) == line_content_for_merge_compare(b, mode)
482}
483
484/// Compare two line sequences line-by-line for merge equality.
485/// Mirrors git `xdl_merge_cmp_lines`, used to auto-resolve a both-sides-changed region when the
486/// two postimages are textually identical.
487fn merge_lines_equal(a: &[Vec<u8>], b: &[Vec<u8>], mode: &WhitespaceMode) -> bool {
488    a.len() == b.len()
489        && a.iter()
490            .zip(b.iter())
491            .all(|(x, y)| merge_line_equal(x, y, mode))
492}
493
494fn common_prefix_suffix_merge_lines(
495    ours: &[Vec<u8>],
496    theirs: &[Vec<u8>],
497    mode: &WhitespaceMode,
498) -> (usize, usize) {
499    let mut prefix = 0usize;
500    while prefix < ours.len()
501        && prefix < theirs.len()
502        && merge_line_equal(&ours[prefix], &theirs[prefix], mode)
503    {
504        prefix += 1;
505    }
506
507    let mut suffix = 0usize;
508    while suffix < ours.len().saturating_sub(prefix)
509        && suffix < theirs.len().saturating_sub(prefix)
510        && merge_line_equal(
511            &ours[ours.len() - 1 - suffix],
512            &theirs[theirs.len() - 1 - suffix],
513            mode,
514        )
515    {
516        suffix += 1;
517    }
518
519    (prefix, suffix)
520}
521
522/// Git `is_eol_crlf` for a single logical line in our representation.
523fn is_eol_crlf_line(line: &[u8]) -> i8 {
524    if line.ends_with(b"\r\n") {
525        return 1;
526    }
527    if line.ends_with(b"\n") {
528        return 0;
529    }
530    -1
531}
532
533/// Git `is_cr_needed` using indices into the full postimages / ancestor (see `xmerge.c`).
534fn conflict_markers_need_cr_at(
535    ours_lines: &[Vec<u8>],
536    theirs_lines: &[Vec<u8>],
537    base_lines: &[Vec<u8>],
538    i1: usize,
539    i2: usize,
540) -> bool {
541    let mut needs = if i1 > 0 {
542        is_eol_crlf_line(ours_lines[i1 - 1].as_slice())
543    } else {
544        -1
545    };
546    if needs > 0 {
547        let t0 = if i2 > 0 {
548            is_eol_crlf_line(theirs_lines[i2 - 1].as_slice())
549        } else {
550            -1
551        };
552        if t0 >= 0 {
553            needs = t0;
554        }
555    }
556    if needs > 0 && !base_lines.is_empty() {
557        needs = is_eol_crlf_line(base_lines[0].as_slice());
558    }
559    needs > 0
560}
561
562fn line_contains_alnum(line: &[u8]) -> bool {
563    strip_line_terminator(line)
564        .iter()
565        .any(|b| b.is_ascii_alphanumeric())
566}
567
568/// `true` when git `xdl_simplify_non_conflicts` would **not** merge `Conflict + gap + Conflict`.
569///
570/// Git merges the outer conflicts when the gap is "small enough" unless `simplify_if_no_alnum`
571/// (`XDL_MERGE_ZEALOUS < level`) requires keeping gaps that are long **and** alphanumeric.
572fn gap_blocks_merge_between_conflicts(gap: &[Vec<u8>], simplify_if_no_alnum: bool) -> bool {
573    if gap.len() <= 3 {
574        return false;
575    }
576    !simplify_if_no_alnum || gap.iter().any(|l| line_contains_alnum(l.as_slice()))
577}
578
579fn merge_two_adjacent_conflicts(left: &Hunk, gap: &[Vec<u8>], right: &Hunk) -> Option<Hunk> {
580    let Hunk::Conflict {
581        base: b1,
582        ours: o1,
583        theirs: t1,
584    } = left
585    else {
586        return None;
587    };
588    let Hunk::Conflict {
589        base: b2,
590        ours: o2,
591        theirs: t2,
592    } = right
593    else {
594        return None;
595    };
596    // Git's xdl_simplify_non_conflicts absorbs the common gap between the two conflicts back
597    // into *both* sides of the merged conflict (the gap lines are identical on ours/theirs).
598    let mut merged_base = b1.clone();
599    merged_base.extend(gap.iter().cloned());
600    merged_base.extend(b2.iter().cloned());
601    let mut merged_ours = o1.clone();
602    merged_ours.extend(gap.iter().cloned());
603    merged_ours.extend(o2.iter().cloned());
604    let mut merged_theirs = t1.clone();
605    merged_theirs.extend(gap.iter().cloned());
606    merged_theirs.extend(t2.iter().cloned());
607    Some(Hunk::Conflict {
608        base: merged_base,
609        ours: merged_ours,
610        theirs: merged_theirs,
611    })
612}
613
614/// Git `xdl_simplify_non_conflicts` for our hunk list.
615fn simplify_non_conflicts_between_conflicts(
616    hunks: Vec<Hunk>,
617    simplify_if_no_alnum: bool,
618) -> Vec<Hunk> {
619    let mut out: Vec<Hunk> = Vec::with_capacity(hunks.len());
620    let mut i = 0usize;
621    while i < hunks.len() {
622        let Hunk::Conflict { .. } = &hunks[i] else {
623            out.push(hunks[i].clone());
624            i += 1;
625            continue;
626        };
627        let mut merged = hunks[i].clone();
628        let mut j = i;
629        while let (Some(Hunk::Unchanged(gap)), Some(next @ Hunk::Conflict { .. })) =
630            (hunks.get(j + 1), hunks.get(j + 2))
631        {
632            if gap_blocks_merge_between_conflicts(gap, simplify_if_no_alnum) {
633                break;
634            }
635            let Some(m) = merge_two_adjacent_conflicts(&merged, gap, next) else {
636                break;
637            };
638            merged = m;
639            j += 2;
640        }
641        out.push(merged);
642        i = j + 1;
643    }
644    out
645}
646
647/// Split a conflict into sub-hunks by diffing the two sides (git `xdl_refine_conflicts`).
648fn refine_conflicts_by_subdiff(
649    hunks: Vec<Hunk>,
650    algo: Algorithm,
651    ws_mode: &WhitespaceMode,
652) -> Vec<Hunk> {
653    let mut out = Vec::with_capacity(hunks.len());
654    for h in hunks {
655        let Hunk::Conflict { base, ours, theirs } = h else {
656            out.push(h);
657            continue;
658        };
659        if ours.is_empty() || theirs.is_empty() {
660            out.push(Hunk::Conflict { base, ours, theirs });
661            continue;
662        }
663        let o_cmp = normalize_lines_for_compare(&ours, ws_mode);
664        let t_cmp = normalize_lines_for_compare(&theirs, ws_mode);
665        let sub_ops = similar::capture_diff_slices(algo, &o_cmp, &t_cmp);
666        if sub_ops.len() == 1 && sub_ops[0].tag() == DiffTag::Equal {
667            out.push(Hunk::Unchanged(ours));
668            continue;
669        }
670        for op in &sub_ops {
671            let old = op.old_range();
672            let new_ = op.new_range();
673            match op.tag() {
674                DiffTag::Equal => {
675                    if !old.is_empty() {
676                        out.push(Hunk::Unchanged(ours[old.start..old.end].to_vec()));
677                    }
678                }
679                DiffTag::Delete => {
680                    // Lines present only on ours within an overlapping (both-changed) region
681                    // stay a conflict with an empty theirs side (git's xdl_refine_conflicts
682                    // re-emits these as conflict hunks, not clean resolutions).
683                    if !old.is_empty() {
684                        out.push(Hunk::Conflict {
685                            base: Vec::new(),
686                            ours: ours[old.start..old.end].to_vec(),
687                            theirs: Vec::new(),
688                        });
689                    }
690                }
691                DiffTag::Insert => {
692                    if !new_.is_empty() {
693                        out.push(Hunk::Conflict {
694                            base: Vec::new(),
695                            ours: Vec::new(),
696                            theirs: theirs[new_.start..new_.end].to_vec(),
697                        });
698                    }
699                }
700                DiffTag::Replace => {
701                    out.push(Hunk::Conflict {
702                        base: Vec::new(),
703                        ours: ours[old.start..old.end].to_vec(),
704                        theirs: theirs[new_.start..new_.end].to_vec(),
705                    });
706                }
707            }
708        }
709    }
710    out
711}
712
713/// A classified merge region (owns its lines).
714#[derive(Debug, Clone)]
715enum Hunk {
716    /// Lines unchanged by both sides (base content).
717    Unchanged(Vec<Vec<u8>>),
718    /// Lines changed only by ours.
719    OnlyOurs {
720        /// Base lines for the changed region (empty for pure insertions).
721        base: Vec<Vec<u8>>,
722        /// Output lines from ours.
723        ours: Vec<Vec<u8>>,
724    },
725    /// Lines changed only by theirs.
726    OnlyTheirs {
727        /// Base lines for the changed region (empty for pure insertions).
728        base: Vec<Vec<u8>>,
729        /// Output lines from theirs.
730        theirs: Vec<Vec<u8>>,
731    },
732    /// Lines changed by both sides — a conflict.
733    Conflict {
734        base: Vec<Vec<u8>>,
735        ours: Vec<Vec<u8>>,
736        theirs: Vec<Vec<u8>>,
737    },
738}
739
740/// Compute the merge hunks from two diff sequences against the same base.
741///
742/// Uses a position-by-position scan with op-based expansion to ensure that
743/// changed regions spanning multiple lines are not split mid-op.
744fn compute_hunks(
745    base: &[Vec<u8>],
746    ours: &[Vec<u8>],
747    theirs: &[Vec<u8>],
748    ours_ops: &[DiffOp],
749    theirs_ops: &[DiffOp],
750    ws_mode: &WhitespaceMode,
751) -> Vec<Hunk> {
752    let ours_changed = changed_mask(ours_ops, base.len());
753    let theirs_changed = changed_mask(theirs_ops, base.len());
754
755    let ours_inserts = collect_inserts(ours_ops, base.len());
756    let theirs_inserts = collect_inserts(theirs_ops, base.len());
757
758    let mut hunks: Vec<Hunk> = Vec::new();
759    let mut pos = 0usize;
760
761    while pos <= base.len() {
762        // Emit pure insertions before this base position.
763        emit_inserts_at(
764            pos,
765            &ours_inserts,
766            &theirs_inserts,
767            ours,
768            theirs,
769            ws_mode,
770            &mut hunks,
771        );
772
773        if pos == base.len() {
774            break;
775        }
776
777        let o = ours_changed[pos];
778        let t = theirs_changed[pos];
779
780        if !o && !t {
781            // Unchanged run. Stop before a position that has pending insertions
782            // on either side so that insertions are emitted in-order at the
783            // correct base position.
784            let mut end = pos + 1;
785            while end < base.len()
786                && !ours_changed[end]
787                && !theirs_changed[end]
788                && ours_inserts[end].is_empty()
789                && theirs_inserts[end].is_empty()
790            {
791                end += 1;
792            }
793            let terminator_only_diff = base.len() == ours.len()
794                && (pos..end).all(|i| merge_line_equal(&base[i], &ours[i], ws_mode))
795                && (pos..end).any(|i| base[i] != ours[i]);
796            let unchanged_lines = if ws_mode.ignore_all_space
797                || ws_mode.ignore_space_change
798                || ws_mode.ignore_space_at_eol
799                || ws_mode.ignore_cr_at_eol
800                || terminator_only_diff
801            {
802                &ours[pos..end]
803            } else {
804                &base[pos..end]
805            };
806            hunks.push(Hunk::Unchanged(unchanged_lines.to_vec()));
807            pos = end;
808            continue;
809        }
810
811        // Changed region: expand end until all overlapping Changed ops from
812        // either side are fully consumed.  Repeat until stable.
813        //
814        // A region is also extended across *contiguous* changed base lines from EITHER side:
815        // when ours changes one base line and theirs changes the very next base line (with no
816        // unchanged base line between), Git's `xdl_merge` treats them as a single overlapping
817        // region and reports a conflict. Without this, interleaved deletions (e.g. base `a b c d e`,
818        // ours `a c e`, theirs `b d`) would be split into alternating one-sided hunks that each
819        // "resolve" cleanly, producing an empty merge where Git conflicts (t7201 8).
820        let mut end = pos + 1;
821        loop {
822            let mut new_end = furthest_changed_op_end(ours_ops, pos, end)
823                .max(furthest_changed_op_end(theirs_ops, pos, end));
824            while new_end < base.len() && (ours_changed[new_end] || theirs_changed[new_end]) {
825                new_end += 1;
826            }
827            if new_end <= end {
828                break;
829            }
830            end = new_end;
831        }
832
833        // Classify the full range [pos..end).
834        let any_ours = (pos..end).any(|p| ours_changed[p]);
835        let any_theirs = (pos..end).any(|p| theirs_changed[p]);
836
837        match (any_ours, any_theirs) {
838            (true, false) => {
839                let c = collect_new_lines(ours_ops, ours, pos, end);
840                hunks.push(Hunk::OnlyOurs {
841                    base: base[pos..end].to_vec(),
842                    ours: c,
843                });
844            }
845            (false, true) => {
846                let c = collect_new_lines(theirs_ops, theirs, pos, end);
847                hunks.push(Hunk::OnlyTheirs {
848                    base: base[pos..end].to_vec(),
849                    theirs: c,
850                });
851            }
852            (true, true) => {
853                let o = collect_new_lines(ours_ops, ours, pos, end);
854                let t = collect_new_lines(theirs_ops, theirs, pos, end);
855                // Git's `xdl_merge` auto-resolves a both-sides-changed region when both sides
856                // produce textually identical content (`xdl_merge_cmp_lines` over the two
857                // postimages): the change is taken once instead of conflicting. Only a genuine
858                // textual difference becomes a conflict (see t3424 `--empty=keep`, where a commit
859                // whose change is already present in upstream merges cleanly and becomes empty).
860                if merge_lines_equal(&o, &t, ws_mode) {
861                    hunks.push(Hunk::OnlyOurs {
862                        base: base[pos..end].to_vec(),
863                        ours: o,
864                    });
865                } else {
866                    hunks.push(Hunk::Conflict {
867                        base: base[pos..end].to_vec(),
868                        ours: o,
869                        theirs: t,
870                    });
871                }
872            }
873            (false, false) => {
874                // Should not happen, but treat as unchanged.
875                hunks.push(Hunk::Unchanged(base[pos..end].to_vec()));
876            }
877        }
878
879        pos = end;
880    }
881
882    hunks
883}
884
885/// Build a boolean mask: `mask[i]` is `true` if base line `i` is covered by
886/// a non-Equal op.
887fn changed_mask(ops: &[DiffOp], base_len: usize) -> Vec<bool> {
888    let mut mask = vec![false; base_len];
889    for op in ops {
890        if op.tag() == DiffTag::Equal {
891            continue;
892        }
893        for p in op.old_range() {
894            if p < base_len {
895                mask[p] = true;
896            }
897        }
898    }
899    mask
900}
901
902/// Collect pure insertions (ops with empty old_range) at each base position.
903///
904/// Returns a `Vec` of length `base_len + 1`; entry `i` holds all
905/// `(new_start, new_end)` ranges inserted before base line `i`.
906fn collect_inserts(ops: &[DiffOp], base_len: usize) -> Vec<Vec<(usize, usize)>> {
907    let mut result: Vec<Vec<(usize, usize)>> = vec![Vec::new(); base_len + 1];
908    for op in ops {
909        let old = op.old_range();
910        let new_ = op.new_range();
911        if old.is_empty() && !new_.is_empty() {
912            let pos = old.start.min(base_len);
913            result[pos].push((new_.start, new_.end));
914        }
915    }
916    result
917}
918
919/// Emit hunks for pure insertions at base position `pos`.
920fn emit_inserts_at(
921    pos: usize,
922    ours_inserts: &[Vec<(usize, usize)>],
923    theirs_inserts: &[Vec<(usize, usize)>],
924    ours: &[Vec<u8>],
925    theirs: &[Vec<u8>],
926    ws_mode: &WhitespaceMode,
927    hunks: &mut Vec<Hunk>,
928) {
929    let o_ins = &ours_inserts[pos];
930    let t_ins = &theirs_inserts[pos];
931
932    let has_ours = !o_ins.is_empty();
933    let has_theirs = !t_ins.is_empty();
934
935    if has_ours && has_theirs {
936        // Both sides insert at the same base position (empty base region). Git's
937        // `xdl_merge` treats this as a single overlapping change region and only
938        // auto-resolves when the two insertions are textually identical; any
939        // difference is a conflict. It still factors out a shared leading and
940        // trailing run as unchanged context (emitted outside the conflict
941        // markers), so we extract a common prefix *and* suffix and conflict on
942        // whatever remains -- even when one side's remainder is empty (a
943        // "superset" insertion still conflicts; see t4108/t4038).
944        let o_lines: Vec<Vec<u8>> = o_ins
945            .iter()
946            .flat_map(|&(s, e)| ours[s..e].to_vec())
947            .collect();
948        let t_lines: Vec<Vec<u8>> = t_ins
949            .iter()
950            .flat_map(|&(s, e)| theirs[s..e].to_vec())
951            .collect();
952
953        let mut common_prefix = 0usize;
954        while common_prefix < o_lines.len()
955            && common_prefix < t_lines.len()
956            && o_lines[common_prefix] == t_lines[common_prefix]
957        {
958            common_prefix += 1;
959        }
960
961        let mut common_suffix = 0usize;
962        while common_suffix < o_lines.len() - common_prefix
963            && common_suffix < t_lines.len() - common_prefix
964            && o_lines[o_lines.len() - 1 - common_suffix]
965                == t_lines[t_lines.len() - 1 - common_suffix]
966        {
967            common_suffix += 1;
968        }
969
970        if common_prefix > 0 {
971            hunks.push(Hunk::Unchanged(o_lines[..common_prefix].to_vec()));
972        }
973
974        let ours_tail = o_lines[common_prefix..o_lines.len() - common_suffix].to_vec();
975        let theirs_tail = t_lines[common_prefix..t_lines.len() - common_suffix].to_vec();
976
977        if !ours_tail.is_empty() || !theirs_tail.is_empty() {
978            // Identical insertions on both sides auto-resolve to a single copy (git
979            // `xdl_merge`); only a genuine textual difference conflicts.
980            if merge_lines_equal(&ours_tail, &theirs_tail, ws_mode) {
981                hunks.push(Hunk::OnlyOurs {
982                    base: Vec::new(),
983                    ours: ours_tail,
984                });
985            } else {
986                hunks.push(Hunk::Conflict {
987                    base: Vec::new(),
988                    ours: ours_tail,
989                    theirs: theirs_tail,
990                });
991            }
992        }
993
994        if common_suffix > 0 {
995            hunks.push(Hunk::Unchanged(
996                o_lines[o_lines.len() - common_suffix..].to_vec(),
997            ));
998        }
999    } else if has_ours {
1000        for &(ns, ne) in o_ins {
1001            let lines: Vec<Vec<u8>> = ours[ns..ne].to_vec();
1002            if !lines.is_empty() {
1003                hunks.push(Hunk::OnlyOurs {
1004                    base: Vec::new(),
1005                    ours: lines,
1006                });
1007            }
1008        }
1009    } else if has_theirs {
1010        for &(ns, ne) in t_ins {
1011            let lines: Vec<Vec<u8>> = theirs[ns..ne].to_vec();
1012            if !lines.is_empty() {
1013                hunks.push(Hunk::OnlyTheirs {
1014                    base: Vec::new(),
1015                    theirs: lines,
1016                });
1017            }
1018        }
1019    }
1020}
1021
1022fn adjust_zealous_hunks(hunks: Vec<Hunk>) -> Vec<Hunk> {
1023    let mut out: Vec<Hunk> = Vec::new();
1024    let mut i = 0usize;
1025
1026    while i < hunks.len() {
1027        let mut consumed = 1usize;
1028        let mut transformed: Option<Vec<Hunk>> = None;
1029
1030        let (pre_insert, mid_idx) = match &hunks[i] {
1031            Hunk::OnlyTheirs { base, theirs } if base.is_empty() => {
1032                (Some(theirs.as_slice()), i + 1)
1033            }
1034            _ => (None, i),
1035        };
1036
1037        if let Some(Hunk::OnlyOurs { base, ours }) = hunks.get(mid_idx) {
1038            if !base.is_empty() {
1039                let post_insert = match hunks.get(mid_idx + 1) {
1040                    Some(Hunk::OnlyTheirs { base, theirs }) if base.is_empty() => {
1041                        Some(theirs.as_slice())
1042                    }
1043                    _ => None,
1044                };
1045
1046                let mut prefix_len = 0usize;
1047                if let Some(pre) = pre_insert {
1048                    if !pre.is_empty() && ours.starts_with(pre) {
1049                        prefix_len = pre.len();
1050                    }
1051                }
1052
1053                let mut suffix_len = 0usize;
1054                if let Some(post) = post_insert {
1055                    if !post.is_empty() && ours[prefix_len..].ends_with(post) {
1056                        suffix_len = post.len();
1057                    }
1058                }
1059
1060                if prefix_len > 0 || suffix_len > 0 {
1061                    consumed = if pre_insert.is_some() {
1062                        if post_insert.is_some() {
1063                            3
1064                        } else {
1065                            2
1066                        }
1067                    } else if post_insert.is_some() {
1068                        2
1069                    } else {
1070                        1
1071                    };
1072
1073                    let mut replacement: Vec<Hunk> = Vec::new();
1074                    if prefix_len > 0 {
1075                        replacement.push(Hunk::Unchanged(ours[..prefix_len].to_vec()));
1076                    }
1077                    replacement.push(Hunk::Conflict {
1078                        base: base.clone(),
1079                        ours: ours[prefix_len..ours.len() - suffix_len].to_vec(),
1080                        theirs: base.clone(),
1081                    });
1082                    if suffix_len > 0 {
1083                        replacement.push(Hunk::Unchanged(ours[ours.len() - suffix_len..].to_vec()));
1084                    }
1085                    transformed = Some(replacement);
1086                }
1087            }
1088        }
1089
1090        if let Some(replacement) = transformed {
1091            for h in replacement {
1092                push_hunk_with_unchanged_merge(&mut out, h);
1093            }
1094            i += consumed;
1095            continue;
1096        }
1097
1098        push_hunk_with_unchanged_merge(&mut out, hunks[i].clone());
1099        i += 1;
1100    }
1101
1102    out
1103}
1104
1105/// If one side replaces base lines and the other appends an insertion immediately after
1106/// that base span, `compute_hunks` emits two hunks (`Only*` + trailing insert). Git treats
1107/// the combined region as one conflict (e.g. base `hello\n`, ours adds `hello\n`, theirs
1108/// replaces with `remove-conflict\n`).
1109/// When both sides edit **adjacent** base lines but each line is changed on only one side, Git's
1110/// merge reports a single conflict spanning both lines (`t4108-apply-threeway`).
1111fn merge_adjacent_one_sided_line_changes_to_conflict(hunks: Vec<Hunk>) -> Vec<Hunk> {
1112    let mut out: Vec<Hunk> = Vec::with_capacity(hunks.len());
1113    let mut i = 0usize;
1114    while i < hunks.len() {
1115        let merged = match (&hunks[i], hunks.get(i + 1)) {
1116            (
1117                Hunk::OnlyTheirs {
1118                    base: b1,
1119                    theirs: t1,
1120                },
1121                Some(Hunk::OnlyOurs { base: b2, ours: o2 }),
1122            ) if b1.len() == 1 && b2.len() == 1 && t1.len() == 1 && o2.len() == 1 => {
1123                Some(Hunk::Conflict {
1124                    base: vec![b1[0].clone(), b2[0].clone()],
1125                    ours: vec![b1[0].clone(), o2[0].clone()],
1126                    theirs: vec![t1[0].clone(), b2[0].clone()],
1127                })
1128            }
1129            (
1130                Hunk::OnlyOurs { base: b1, ours: o1 },
1131                Some(Hunk::OnlyTheirs {
1132                    base: b2,
1133                    theirs: t2,
1134                }),
1135            ) if b1.len() == 1 && b2.len() == 1 && o1.len() == 1 && t2.len() == 1 => {
1136                Some(Hunk::Conflict {
1137                    base: vec![b1[0].clone(), b2[0].clone()],
1138                    ours: vec![o1[0].clone(), b2[0].clone()],
1139                    theirs: vec![b1[0].clone(), t2[0].clone()],
1140                })
1141            }
1142            _ => None,
1143        };
1144        if let Some(h) = merged {
1145            out.push(h);
1146            i += 2;
1147        } else {
1148            out.push(hunks[i].clone());
1149            i += 1;
1150        }
1151    }
1152    out
1153}
1154
1155fn merge_adjacent_replace_and_trailing_insert_conflicts(hunks: Vec<Hunk>) -> Vec<Hunk> {
1156    let mut out: Vec<Hunk> = Vec::with_capacity(hunks.len());
1157    let mut i = 0usize;
1158    while i < hunks.len() {
1159        let merged = match (&hunks[i], hunks.get(i + 1)) {
1160            (Hunk::OnlyTheirs { base, theirs }, Some(Hunk::OnlyOurs { base: bo, ours: o }))
1161                if !base.is_empty() && bo.is_empty() && !o.is_empty() && !theirs.is_empty() =>
1162            {
1163                Some(Hunk::Conflict {
1164                    base: base.clone(),
1165                    ours: o.clone(),
1166                    theirs: theirs.clone(),
1167                })
1168            }
1169            (
1170                Hunk::OnlyOurs { base, ours },
1171                Some(Hunk::OnlyTheirs {
1172                    base: bt,
1173                    theirs: t,
1174                }),
1175            ) if !base.is_empty() && bt.is_empty() && !t.is_empty() && !ours.is_empty() => {
1176                let mut conflict_theirs = base.clone();
1177                conflict_theirs.extend(t.iter().cloned());
1178                Some(Hunk::Conflict {
1179                    base: base.clone(),
1180                    ours: ours.clone(),
1181                    theirs: conflict_theirs,
1182                })
1183            }
1184            // A pure insertion by one side immediately *before* a base line the other side
1185            // modifies. `compute_hunks` emits the leading insert (`Only*` with empty base) then
1186            // the modify (`Only*` with non-empty base) as two separate hunks, but git's
1187            // `xdl_merge` attaches the insertion to the adjacent change region and reports a
1188            // single conflict (e.g. base `d`, ours `c\nd`, theirs `e`). Mirror that here.
1189            (Hunk::OnlyOurs { base: bo, ours }, Some(Hunk::OnlyTheirs { base: bt, theirs }))
1190                if bo.is_empty() && !bt.is_empty() && !ours.is_empty() && !theirs.is_empty() =>
1191            {
1192                let mut conflict_ours = ours.clone();
1193                conflict_ours.extend(bt.iter().cloned());
1194                Some(Hunk::Conflict {
1195                    base: bt.clone(),
1196                    ours: conflict_ours,
1197                    theirs: theirs.clone(),
1198                })
1199            }
1200            (Hunk::OnlyTheirs { base: bt, theirs }, Some(Hunk::OnlyOurs { base: bo, ours }))
1201                if bt.is_empty() && !bo.is_empty() && !theirs.is_empty() && !ours.is_empty() =>
1202            {
1203                let mut conflict_theirs = theirs.clone();
1204                conflict_theirs.extend(bo.iter().cloned());
1205                Some(Hunk::Conflict {
1206                    base: bo.clone(),
1207                    ours: ours.clone(),
1208                    theirs: conflict_theirs,
1209                })
1210            }
1211            _ => None,
1212        };
1213        if let Some(h) = merged {
1214            out.push(h);
1215            i += 2;
1216        } else {
1217            out.push(hunks[i].clone());
1218            i += 1;
1219        }
1220    }
1221    out
1222}
1223
1224/// Fold a one-sided pure insertion (`Only* { base: [] }`) that sits *immediately
1225/// adjacent* to a `Conflict` into that conflict's matching side.
1226///
1227/// Git's `xdl_merge` attaches an insertion to a neighbouring change region when
1228/// they touch (`xdl_append_merge`: `i1 <= m->i1 + m->chg1`). After
1229/// `merge_adjacent_replace_and_trailing_insert_conflicts` has turned a
1230/// leading-insert + modify into a `Conflict`, a *trailing* one-sided insert that
1231/// directly follows the conflict (empty base ⇒ no intervening base line) is part
1232/// of the same region and must be absorbed. This is what makes a previously
1233/// committed conflict (whose `>>>>>>>` marker line is inserted right after the
1234/// contested base line) re-conflict correctly — e.g. t4200 "rerere with inner
1235/// conflict markers", where base `bar`, ours `<<<\nfoo\n===\nbar\n>>>`,
1236/// theirs `baz` must yield a single conflict containing all of `ours` vs `baz`.
1237fn fold_adjacent_inserts_into_conflict(hunks: Vec<Hunk>) -> Vec<Hunk> {
1238    let mut out: Vec<Hunk> = Vec::with_capacity(hunks.len());
1239    for hunk in hunks {
1240        match hunk {
1241            // A pure insertion directly following a conflict joins that conflict's
1242            // matching side.
1243            Hunk::OnlyOurs { ref base, ref ours } if base.is_empty() => {
1244                if let Some(Hunk::Conflict { ours: c_ours, .. }) = out.last_mut() {
1245                    c_ours.extend(ours.iter().cloned());
1246                    continue;
1247                }
1248                out.push(hunk);
1249            }
1250            Hunk::OnlyTheirs {
1251                ref base,
1252                ref theirs,
1253            } if base.is_empty() => {
1254                if let Some(Hunk::Conflict {
1255                    theirs: c_theirs, ..
1256                }) = out.last_mut()
1257                {
1258                    c_theirs.extend(theirs.iter().cloned());
1259                    continue;
1260                }
1261                out.push(hunk);
1262            }
1263            other => out.push(other),
1264        }
1265    }
1266    out
1267}
1268
1269fn coalesce_nearby_conflicts(hunks: Vec<Hunk>, max_gap_lines: usize, enable: bool) -> Vec<Hunk> {
1270    if !enable {
1271        return hunks;
1272    }
1273    let mut out: Vec<Hunk> = Vec::new();
1274    let mut i = 0usize;
1275
1276    while i < hunks.len() {
1277        let Some(Hunk::Conflict { base, ours, theirs }) = hunks.get(i) else {
1278            out.push(hunks[i].clone());
1279            i += 1;
1280            continue;
1281        };
1282
1283        let mut merged_base = base.clone();
1284        let mut merged_ours = ours.clone();
1285        let mut merged_theirs = theirs.clone();
1286        let mut j = i;
1287
1288        while let (
1289            Some(Hunk::Unchanged(gap)),
1290            Some(Hunk::Conflict {
1291                base: next_base,
1292                ours: next_ours,
1293                theirs: next_theirs,
1294            }),
1295        ) = (hunks.get(j + 1), hunks.get(j + 2))
1296        {
1297            if gap.len() > max_gap_lines {
1298                break;
1299            }
1300
1301            merged_base.extend(gap.iter().cloned());
1302            merged_base.extend(next_base.iter().cloned());
1303            merged_ours.extend(gap.iter().cloned());
1304            merged_ours.extend(next_ours.iter().cloned());
1305            merged_theirs.extend(gap.iter().cloned());
1306            merged_theirs.extend(next_theirs.iter().cloned());
1307            j += 2;
1308        }
1309
1310        out.push(Hunk::Conflict {
1311            base: merged_base,
1312            ours: merged_ours,
1313            theirs: merged_theirs,
1314        });
1315        i = j + 1;
1316    }
1317
1318    out
1319}
1320
1321fn push_hunk_with_unchanged_merge(out: &mut Vec<Hunk>, hunk: Hunk) {
1322    match hunk {
1323        Hunk::Unchanged(mut lines) => {
1324            if let Some(Hunk::Unchanged(prev)) = out.last_mut() {
1325                prev.append(&mut lines);
1326            } else if !lines.is_empty() {
1327                out.push(Hunk::Unchanged(lines));
1328            }
1329        }
1330        other => out.push(other),
1331    }
1332}
1333
1334/// Return the maximum `old_range().end` among all Changed ops that overlap
1335/// with `[run_start..current_end)`.  Returns `current_end` if nothing
1336/// extends further.
1337fn furthest_changed_op_end(ops: &[DiffOp], run_start: usize, current_end: usize) -> usize {
1338    let mut max = current_end;
1339    for op in ops {
1340        if op.tag() == DiffTag::Equal {
1341            continue;
1342        }
1343        let old = op.old_range();
1344        if old.start < current_end && old.end > run_start && old.end > max {
1345            max = old.end;
1346        }
1347    }
1348    max
1349}
1350
1351/// Collect new (output) lines for base range `[base_start..base_end)`.
1352///
1353/// For each op whose `old_range` overlaps the range:
1354/// - Equal ops contribute their corresponding new-side lines.
1355/// - Changed ops contribute their full replacement.
1356fn collect_new_lines(
1357    ops: &[DiffOp],
1358    new: &[Vec<u8>],
1359    base_start: usize,
1360    base_end: usize,
1361) -> Vec<Vec<u8>> {
1362    let mut lines = Vec::new();
1363    for op in ops {
1364        let old = op.old_range();
1365        let new_ = op.new_range();
1366        if old.is_empty() {
1367            continue; // pure insertion, handled separately
1368        }
1369        if old.end <= base_start || old.start >= base_end {
1370            continue; // no overlap
1371        }
1372        if op.tag() == DiffTag::Equal {
1373            let overlap_start = base_start.max(old.start);
1374            let overlap_end = base_end.min(old.end);
1375            let offset = overlap_start - old.start;
1376            let len = overlap_end - overlap_start;
1377            for i in offset..offset + len {
1378                if new_.start + i < new_.end {
1379                    lines.push(new[new_.start + i].clone());
1380                }
1381            }
1382        } else {
1383            for i in new_.clone() {
1384                lines.push(new[i].clone());
1385            }
1386        }
1387    }
1388    lines
1389}
1390
1391/// Run Myers diff on two line slices.
1392fn _diff_ops(old: &[Vec<u8>], new: &[Vec<u8>]) -> Vec<DiffOp> {
1393    similar::capture_diff_slices(Algorithm::Myers, old, new)
1394}
1395
1396/// Split a byte slice into lines, each including its trailing `\n` if present.
1397fn split_lines(data: &[u8]) -> Vec<Vec<u8>> {
1398    if data.is_empty() {
1399        return Vec::new();
1400    }
1401    let mut lines = Vec::new();
1402    let mut start = 0;
1403    for i in 0..data.len() {
1404        if data[i] == b'\n' {
1405            lines.push(data[start..=i].to_vec());
1406            start = i + 1;
1407        }
1408    }
1409    if start < data.len() {
1410        lines.push(data[start..].to_vec());
1411    }
1412    lines
1413}
1414
1415#[derive(Clone, Copy, Debug, Default)]
1416struct WhitespaceMode {
1417    ignore_all_space: bool,
1418    ignore_space_change: bool,
1419    ignore_space_at_eol: bool,
1420    ignore_cr_at_eol: bool,
1421}
1422
1423fn normalize_lines_for_compare(lines: &[Vec<u8>], mode: &WhitespaceMode) -> Vec<Vec<u8>> {
1424    lines
1425        .iter()
1426        .map(|line| line_content_for_merge_compare(line, mode))
1427        .collect()
1428}
1429
1430fn lines_for_merge_diff_compare(lines: &[Vec<u8>], mode: &WhitespaceMode) -> Vec<Vec<u8>> {
1431    lines
1432        .iter()
1433        .map(|line| line_content_for_merge_compare(line, mode))
1434        .collect()
1435}
1436
1437fn normalize_line_for_compare(line: &[u8], mode: &WhitespaceMode) -> Vec<u8> {
1438    let mut bytes = line.to_vec();
1439
1440    if mode.ignore_all_space {
1441        return bytes
1442            .into_iter()
1443            .filter(|b| !b.is_ascii_whitespace())
1444            .collect();
1445    }
1446
1447    if mode.ignore_space_change {
1448        let mut out = Vec::with_capacity(bytes.len());
1449        let mut in_ws = false;
1450        for ch in bytes {
1451            if ch.is_ascii_whitespace() {
1452                if !in_ws {
1453                    out.push(b' ');
1454                    in_ws = true;
1455                }
1456            } else {
1457                out.push(ch);
1458                in_ws = false;
1459            }
1460        }
1461        while out.last().is_some_and(|b| b.is_ascii_whitespace()) {
1462            out.pop();
1463        }
1464        return out;
1465    }
1466
1467    if mode.ignore_space_at_eol {
1468        if bytes.last().copied() == Some(b'\n') {
1469            let mut body = bytes[..bytes.len() - 1].to_vec();
1470            while body.last().is_some_and(|b| b.is_ascii_whitespace()) {
1471                body.pop();
1472            }
1473            body.push(b'\n');
1474            bytes = body;
1475        } else {
1476            while bytes.last().is_some_and(|b| b.is_ascii_whitespace()) {
1477                bytes.pop();
1478            }
1479        }
1480    }
1481
1482    bytes
1483}
1484
1485/// Returns `true` if the byte slice appears to be binary content.
1486///
1487/// Uses the same heuristic as git: any NUL byte means binary.
1488#[must_use]
1489pub fn is_binary(data: &[u8]) -> bool {
1490    data.contains(&0u8)
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495    use super::*;
1496
1497    fn do_merge(base: &str, ours: &str, theirs: &str) -> (String, usize) {
1498        let input = MergeInput {
1499            base: base.as_bytes(),
1500            ours: ours.as_bytes(),
1501            theirs: theirs.as_bytes(),
1502            label_ours: "ours",
1503            label_base: "base",
1504            label_theirs: "theirs",
1505            favor: MergeFavor::None,
1506            style: ConflictStyle::Merge,
1507            marker_size: 7,
1508            diff_algorithm: None,
1509            ignore_all_space: false,
1510            ignore_space_change: false,
1511            ignore_space_at_eol: false,
1512            ignore_cr_at_eol: false,
1513        };
1514        let out = merge(&input).unwrap();
1515        (String::from_utf8(out.content).unwrap(), out.conflicts)
1516    }
1517
1518    #[test]
1519    fn no_changes() {
1520        let t = "line1\nline2\nline3\n";
1521        let (r, c) = do_merge(t, t, t);
1522        assert_eq!(r, t);
1523        assert_eq!(c, 0);
1524    }
1525
1526    #[test]
1527    fn only_ours() {
1528        let (r, c) = do_merge("a\nb\nc\n", "a\nB\nc\n", "a\nb\nc\n");
1529        assert_eq!(r, "a\nB\nc\n");
1530        assert_eq!(c, 0);
1531    }
1532
1533    #[test]
1534    fn only_theirs() {
1535        let (r, c) = do_merge("a\nb\nc\n", "a\nb\nc\n", "a\nB\nc\n");
1536        assert_eq!(r, "a\nB\nc\n");
1537        assert_eq!(c, 0);
1538    }
1539
1540    #[test]
1541    fn conflict() {
1542        let (r, c) = do_merge("a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n");
1543        assert_eq!(c, 1);
1544        assert!(r.contains("<<<<<<< ours\nX\n=======\nY\n>>>>>>> theirs"));
1545    }
1546
1547    #[test]
1548    fn t4108_style_overlapping_edits_conflict() {
1549        let base = "1\n2\n3\n4\n5\n6\n7\n";
1550        let ours = "1\ntwo\n3\n4\n5\nsix\n7\n";
1551        let theirs = "1\n2\n3\n4\nfive\n6\n7\n";
1552        let (_r, c) = do_merge(base, ours, theirs);
1553        assert!(
1554            c >= 1,
1555            "expected at least one conflict region (Git merge reports CONFLICT for this shape)"
1556        );
1557    }
1558
1559    #[test]
1560    fn conflict_delete_vs_change() {
1561        // ours deletes lines 1-2, theirs changes line 1 → conflict
1562        let base = "a\nb\nc\n";
1563        let ours = "c\n"; // deleted a and b
1564        let theirs = "A\nb\nc\n"; // changed a → A
1565        let (r, c) = do_merge(base, ours, theirs);
1566        assert_eq!(c, 1, "expected conflict, got: {r:?}");
1567    }
1568
1569    #[test]
1570    fn conflict_replace_vs_insert_after_same_line() {
1571        // Base one line; ours duplicates it; theirs replaces it — Git reports content conflict.
1572        let base = "hello\n";
1573        let ours = "hello\nhello\n";
1574        let theirs = "remove-conflict\n";
1575        let (r, c) = do_merge(base, ours, theirs);
1576        assert_eq!(c, 1, "expected conflict, got: {r:?}");
1577    }
1578
1579    #[test]
1580    fn conflict_insert_before_modified_line() {
1581        // Base one line; ours inserts a new line *before* it (keeping the base line); theirs
1582        // modifies that base line. Git's `xdl_merge` attaches the leading insertion to the
1583        // adjacent change region and reports a content conflict (t3407 `--abort after
1584        // --continue`: base `d`, ours `c\nd`, theirs `e`).
1585        let base = "d\n";
1586        let ours = "c\nd\n";
1587        let theirs = "e\n";
1588        let (r, c) = do_merge(base, ours, theirs);
1589        assert_eq!(c, 1, "expected conflict, got: {r:?}");
1590        // The ours side of the conflict carries both the inserted and the kept base line.
1591        assert!(r.contains("c\nd\n"), "ours side should keep c\\nd: {r}");
1592        assert!(r.contains("e\n"), "theirs side should be e: {r}");
1593    }
1594
1595    #[test]
1596    fn conflict_insert_before_modified_line_symmetric() {
1597        // Symmetric case: theirs inserts before the base line, ours modifies it.
1598        let base = "d\n";
1599        let ours = "e\n";
1600        let theirs = "c\nd\n";
1601        let (r, c) = do_merge(base, ours, theirs);
1602        assert_eq!(c, 1, "expected conflict, got: {r:?}");
1603    }
1604
1605    #[test]
1606    fn clean_insert_separated_from_modified_line() {
1607        // An insertion separated by unchanged context from the other side's modification must
1608        // still merge cleanly (git only conflicts when the insertion is *adjacent* to the change).
1609        let base = "a\nb\nc\n";
1610        let ours = "x\na\nb\nc\n"; // insert x at the top
1611        let theirs = "a\nb\nC\n"; // modify the last line, two lines away
1612        let (r, c) = do_merge(base, ours, theirs);
1613        assert_eq!(c, 0, "expected clean merge, got: {r:?}");
1614    }
1615
1616    #[test]
1617    fn favor_ours() {
1618        let input = MergeInput {
1619            base: b"a\nb\nc\n",
1620            ours: b"a\nX\nc\n",
1621            theirs: b"a\nY\nc\n",
1622            label_ours: "o",
1623            label_base: "b",
1624            label_theirs: "t",
1625            favor: MergeFavor::Ours,
1626            style: ConflictStyle::Merge,
1627            marker_size: 7,
1628            diff_algorithm: None,
1629            ignore_all_space: false,
1630            ignore_space_change: false,
1631            ignore_space_at_eol: false,
1632            ignore_cr_at_eol: false,
1633        };
1634        let out = merge(&input).unwrap();
1635        assert_eq!(out.content, b"a\nX\nc\n");
1636        assert_eq!(out.conflicts, 0);
1637    }
1638
1639    #[test]
1640    fn union_missing_lf() {
1641        let input = MergeInput {
1642            base: b"line1\nline2\nline3",
1643            ours: b"line1\nline2\nline3x",
1644            theirs: b"line1\nline2\nline3y",
1645            label_ours: "o",
1646            label_base: "b",
1647            label_theirs: "t",
1648            favor: MergeFavor::Union,
1649            style: ConflictStyle::Merge,
1650            marker_size: 7,
1651            diff_algorithm: None,
1652            ignore_all_space: false,
1653            ignore_space_change: false,
1654            ignore_space_at_eol: false,
1655            ignore_cr_at_eol: false,
1656        };
1657        let out = merge(&input).unwrap();
1658        // union: line3x\nline3y (newline inserted between no-LF lines)
1659        assert_eq!(out.content, b"line1\nline2\nline3x\nline3y");
1660    }
1661
1662    #[test]
1663    fn zdiff3_interesting_conflict_shape() {
1664        let input = MergeInput {
1665            base: b"1\n2\n3\n4\n5\n6\n7\n8\n9\n",
1666            ours: b"1\n2\n3\n4\nA\nB\nC\nD\nE\nF\nG\nH\nI\nJ\n7\n8\n9\n",
1667            theirs: b"1\n2\n3\n4\nA\nB\nC\n5\n6\nG\nH\nI\nJ\n7\n8\n9\n",
1668            label_ours: "HEAD",
1669            label_base: "base",
1670            label_theirs: "right^0",
1671            favor: MergeFavor::None,
1672            style: ConflictStyle::ZealousDiff3,
1673            marker_size: 7,
1674            diff_algorithm: None,
1675            ignore_all_space: false,
1676            ignore_space_change: false,
1677            ignore_space_at_eol: false,
1678            ignore_cr_at_eol: false,
1679        };
1680        let out = merge(&input).unwrap();
1681        let rendered = String::from_utf8(out.content).unwrap();
1682        assert_eq!(out.conflicts, 1, "{rendered}");
1683        assert!(rendered.contains("<<<<<<< HEAD\nD\nE\nF\n"), "{rendered}");
1684    }
1685
1686    #[test]
1687    fn is_binary_detects_nul_byte() {
1688        assert!(is_binary(b"text\0more"));
1689        assert!(!is_binary(b"just text\n"));
1690    }
1691
1692    #[test]
1693    fn diff3_style_emits_base_section() {
1694        let input = MergeInput {
1695            base: b"a\nb\nc\n",
1696            ours: b"a\nOURS\nc\n",
1697            theirs: b"a\nTHEIRS\nc\n",
1698            label_ours: "ours",
1699            label_base: "base",
1700            label_theirs: "theirs",
1701            favor: MergeFavor::None,
1702            style: ConflictStyle::Diff3,
1703            marker_size: 7,
1704            diff_algorithm: None,
1705            ignore_all_space: false,
1706            ignore_space_change: false,
1707            ignore_space_at_eol: false,
1708            ignore_cr_at_eol: false,
1709        };
1710        let out = merge(&input).unwrap();
1711        let s = String::from_utf8(out.content).unwrap();
1712        assert!(s.contains("||||||| base"));
1713    }
1714
1715    #[test]
1716    fn ignore_space_change_merges_space_only_edits() {
1717        let input = MergeInput {
1718            base: b"a  b\n",
1719            ours: b"a b\n",
1720            theirs: b"a  b\nline\n",
1721            label_ours: "ours",
1722            label_base: "base",
1723            label_theirs: "theirs",
1724            favor: MergeFavor::None,
1725            style: ConflictStyle::Merge,
1726            marker_size: 7,
1727            diff_algorithm: None,
1728            ignore_all_space: false,
1729            ignore_space_change: true,
1730            ignore_space_at_eol: false,
1731            ignore_cr_at_eol: false,
1732        };
1733        let out = merge(&input).unwrap();
1734        assert_eq!(out.conflicts, 0);
1735    }
1736
1737    #[test]
1738    fn ignore_all_space_can_clean_merge() {
1739        let input = MergeInput {
1740            base: b"a  \nb\n",
1741            ours: b"a\nb\n",
1742            theirs: b"a\nb changed\n",
1743            label_ours: "ours",
1744            label_base: "base",
1745            label_theirs: "theirs",
1746            favor: MergeFavor::None,
1747            style: ConflictStyle::Merge,
1748            marker_size: 7,
1749            diff_algorithm: None,
1750            ignore_all_space: true,
1751            ignore_space_change: false,
1752            ignore_space_at_eol: false,
1753            ignore_cr_at_eol: false,
1754        };
1755        let out = merge(&input).unwrap();
1756        assert_eq!(out.conflicts, 0);
1757    }
1758
1759    #[test]
1760    fn custom_marker_size_shortens_conflict_markers() {
1761        let input = MergeInput {
1762            base: b"a\nb\nc\n",
1763            ours: b"a\nX\nc\n",
1764            theirs: b"a\nY\nc\n",
1765            label_ours: "ours",
1766            label_base: "base",
1767            label_theirs: "theirs",
1768            favor: MergeFavor::None,
1769            style: ConflictStyle::Merge,
1770            marker_size: 5,
1771            diff_algorithm: None,
1772            ignore_all_space: false,
1773            ignore_space_change: false,
1774            ignore_space_at_eol: false,
1775            ignore_cr_at_eol: false,
1776        };
1777        let out = merge(&input).unwrap();
1778        let s = String::from_utf8(out.content).unwrap();
1779        assert!(s.contains("<<<<< ours"));
1780        assert!(!s.contains("<<<<<<< ours"));
1781    }
1782
1783    #[test]
1784    fn zealous_diff3_prefix_suppressed_when_carried_by_previous_hunk() {
1785        let input = MergeInput {
1786            base: b"keep\nbase\nline\n",
1787            ours: b"keep\nours\nline\n",
1788            theirs: b"keep\ntheirs\nline\n",
1789            label_ours: "ours",
1790            label_base: "base",
1791            label_theirs: "theirs",
1792            favor: MergeFavor::None,
1793            style: ConflictStyle::ZealousDiff3,
1794            marker_size: 7,
1795            diff_algorithm: None,
1796            ignore_all_space: false,
1797            ignore_space_change: false,
1798            ignore_space_at_eol: false,
1799            ignore_cr_at_eol: false,
1800        };
1801        let out = merge(&input).unwrap();
1802        assert_eq!(out.conflicts, 1);
1803        let s = String::from_utf8(out.content).unwrap();
1804        assert!(s.contains("<<<<<<< ours"));
1805    }
1806
1807    #[test]
1808    fn histogram_diff_algorithm_runs() {
1809        let input = MergeInput {
1810            base: b"a\nb\nc\n",
1811            ours: b"a\nB\nc\n",
1812            theirs: b"a\nb\nc\n",
1813            label_ours: "ours",
1814            label_base: "base",
1815            label_theirs: "theirs",
1816            favor: MergeFavor::None,
1817            style: ConflictStyle::Merge,
1818            marker_size: 7,
1819            diff_algorithm: Some("histogram".to_string()),
1820            ignore_all_space: false,
1821            ignore_space_change: false,
1822            ignore_space_at_eol: false,
1823            ignore_cr_at_eol: false,
1824        };
1825        let out = merge(&input).unwrap();
1826        assert_eq!(out.content, b"a\nB\nc\n");
1827    }
1828
1829    #[test]
1830    fn patience_diff_algorithm_runs() {
1831        let input = MergeInput {
1832            base: b"x\ny\n",
1833            ours: b"x\nY\n",
1834            theirs: b"x\ny\n",
1835            label_ours: "ours",
1836            label_base: "base",
1837            label_theirs: "theirs",
1838            favor: MergeFavor::None,
1839            style: ConflictStyle::Merge,
1840            marker_size: 7,
1841            diff_algorithm: Some("patience".to_string()),
1842            ignore_all_space: false,
1843            ignore_space_change: false,
1844            ignore_space_at_eol: false,
1845            ignore_cr_at_eol: false,
1846        };
1847        let out = merge(&input).unwrap();
1848        assert_eq!(out.content, b"x\nY\n");
1849    }
1850
1851    #[test]
1852    fn ignore_space_at_eol_treats_trailing_whitespace_as_equal() {
1853        let input = MergeInput {
1854            base: b"line \n",
1855            ours: b"line\n",
1856            theirs: b"line  \n",
1857            label_ours: "ours",
1858            label_base: "base",
1859            label_theirs: "theirs",
1860            favor: MergeFavor::None,
1861            style: ConflictStyle::Merge,
1862            marker_size: 7,
1863            diff_algorithm: None,
1864            ignore_all_space: false,
1865            ignore_space_change: false,
1866            ignore_space_at_eol: true,
1867            ignore_cr_at_eol: false,
1868        };
1869        let out = merge(&input).unwrap();
1870        assert_eq!(out.conflicts, 0);
1871    }
1872
1873    #[cfg_attr(
1874        not(feature = "full-tests"),
1875        ignore = "starts system git or is heavy; run with --features full-tests"
1876    )]
1877    #[test]
1878    fn add_add_without_base_performs_content_merge() {
1879        let input = MergeInput {
1880            base: b"",
1881            ours: b"ours\n",
1882            theirs: b"theirs\n",
1883            label_ours: "ours",
1884            label_base: "base",
1885            label_theirs: "theirs",
1886            favor: MergeFavor::None,
1887            style: ConflictStyle::Merge,
1888            marker_size: 7,
1889            diff_algorithm: None,
1890            ignore_all_space: false,
1891            ignore_space_change: false,
1892            ignore_space_at_eol: false,
1893            ignore_cr_at_eol: false,
1894        };
1895        let out = merge(&input).unwrap();
1896        assert_eq!(out.conflicts, 1);
1897        let s = String::from_utf8(out.content).unwrap();
1898        assert!(s.contains("<<<<<<< ours"));
1899    }
1900
1901    #[test]
1902    fn crlf_inputs_use_cr_lf_in_conflict_markers() {
1903        let input = MergeInput {
1904            base: b"a\r\nb\r\n",
1905            ours: b"a\r\nOURS\r\n",
1906            theirs: b"a\r\nTHEIRS\r\n",
1907            label_ours: "ours",
1908            label_base: "base",
1909            label_theirs: "theirs",
1910            favor: MergeFavor::None,
1911            style: ConflictStyle::Merge,
1912            marker_size: 7,
1913            diff_algorithm: None,
1914            ignore_all_space: false,
1915            ignore_space_change: false,
1916            ignore_space_at_eol: false,
1917            ignore_cr_at_eol: false,
1918        };
1919        let out = merge(&input).unwrap();
1920        let s = String::from_utf8(out.content).unwrap();
1921        assert_eq!(out.conflicts, 1);
1922        assert!(s.contains("<<<<<<< ours\r\n"));
1923    }
1924
1925    #[test]
1926    fn append_lines_separates_when_buffer_lacks_trailing_newline() {
1927        let input = MergeInput {
1928            base: b"no-final-newline",
1929            ours: b"no-final-newline\nours-line\n",
1930            theirs: b"no-final-newline",
1931            label_ours: "ours",
1932            label_base: "base",
1933            label_theirs: "theirs",
1934            favor: MergeFavor::None,
1935            style: ConflictStyle::Merge,
1936            marker_size: 7,
1937            diff_algorithm: None,
1938            ignore_all_space: false,
1939            ignore_space_change: false,
1940            ignore_space_at_eol: false,
1941            ignore_cr_at_eol: false,
1942        };
1943        let out = merge(&input).unwrap();
1944        assert_eq!(out.content, b"no-final-newline\nours-line\n");
1945    }
1946
1947    #[test]
1948    fn ignore_cr_at_eol_normalizes_cr_only_ends() {
1949        let input = MergeInput {
1950            base: b"a\r\nb\r\n",
1951            ours: b"a\r\nB\r\n",
1952            theirs: b"a\r\nb\r\n",
1953            label_ours: "ours",
1954            label_base: "base",
1955            label_theirs: "theirs",
1956            favor: MergeFavor::None,
1957            style: ConflictStyle::Merge,
1958            marker_size: 7,
1959            diff_algorithm: None,
1960            ignore_all_space: false,
1961            ignore_space_change: false,
1962            ignore_space_at_eol: false,
1963            ignore_cr_at_eol: true,
1964        };
1965        let out = merge(&input).unwrap();
1966        assert_eq!(out.conflicts, 0);
1967    }
1968
1969    #[test]
1970    fn unknown_diff_algorithm_falls_back_to_myers() {
1971        let input = MergeInput {
1972            base: b"a\n",
1973            ours: b"b\n",
1974            theirs: b"a\n",
1975            label_ours: "ours",
1976            label_base: "base",
1977            label_theirs: "theirs",
1978            favor: MergeFavor::None,
1979            style: ConflictStyle::Merge,
1980            marker_size: 7,
1981            diff_algorithm: Some("unknown-algo".to_string()),
1982            ignore_all_space: false,
1983            ignore_space_change: false,
1984            ignore_space_at_eol: false,
1985            ignore_cr_at_eol: false,
1986        };
1987        let out = merge(&input).unwrap();
1988        assert_eq!(out.content, b"b\n");
1989    }
1990
1991    #[test]
1992    fn refine_splits_large_conflict_regions() {
1993        let base = b"alpha beta gamma\n";
1994        let ours = b"alpha BETA gamma\n";
1995        let theirs = b"alpha beta GAMMA\n";
1996        let input = MergeInput {
1997            base,
1998            ours,
1999            theirs,
2000            label_ours: "ours",
2001            label_base: "base",
2002            label_theirs: "theirs",
2003            favor: MergeFavor::None,
2004            style: ConflictStyle::Merge,
2005            marker_size: 7,
2006            diff_algorithm: None,
2007            ignore_all_space: false,
2008            ignore_space_change: false,
2009            ignore_space_at_eol: false,
2010            ignore_cr_at_eol: false,
2011        };
2012        let out = merge(&input).unwrap();
2013        assert!(out.conflicts >= 1);
2014    }
2015
2016    #[test]
2017    fn merge_style_conflict_uses_shared_prefix_outside_markers() {
2018        let input = MergeInput {
2019            base: b"pre\nmid\npost\n",
2020            ours: b"pre\nOURS\npost\n",
2021            theirs: b"pre\nTHEIRS\npost\n",
2022            label_ours: "ours",
2023            label_base: "base",
2024            label_theirs: "theirs",
2025            favor: MergeFavor::None,
2026            style: ConflictStyle::Merge,
2027            marker_size: 7,
2028            diff_algorithm: None,
2029            ignore_all_space: false,
2030            ignore_space_change: false,
2031            ignore_space_at_eol: false,
2032            ignore_cr_at_eol: false,
2033        };
2034        let out = merge(&input).unwrap();
2035        let s = String::from_utf8(out.content).unwrap();
2036        assert_eq!(out.conflicts, 1);
2037        assert!(s.starts_with("pre\n"));
2038        assert!(s.ends_with("post\n"));
2039    }
2040
2041    #[test]
2042    fn marker_size_zero_defaults_to_seven() {
2043        let input = MergeInput {
2044            base: b"a\nb\nc\n",
2045            ours: b"a\nX\nc\n",
2046            theirs: b"a\nY\nc\n",
2047            label_ours: "ours",
2048            label_base: "base",
2049            label_theirs: "theirs",
2050            favor: MergeFavor::None,
2051            style: ConflictStyle::Merge,
2052            marker_size: 0,
2053            diff_algorithm: None,
2054            ignore_all_space: false,
2055            ignore_space_change: false,
2056            ignore_space_at_eol: false,
2057            ignore_cr_at_eol: false,
2058        };
2059        let out = merge(&input).unwrap();
2060        let s = String::from_utf8(out.content).unwrap();
2061        assert!(s.contains("<<<<<<< ours"));
2062    }
2063
2064    #[test]
2065    fn favor_theirs_resolves_without_markers() {
2066        let input = MergeInput {
2067            base: b"a\nb\nc\n",
2068            ours: b"a\nX\nc\n",
2069            theirs: b"a\nY\nc\n",
2070            label_ours: "ours",
2071            label_base: "base",
2072            label_theirs: "theirs",
2073            favor: MergeFavor::Theirs,
2074            style: ConflictStyle::Merge,
2075            marker_size: 7,
2076            diff_algorithm: None,
2077            ignore_all_space: false,
2078            ignore_space_change: false,
2079            ignore_space_at_eol: false,
2080            ignore_cr_at_eol: false,
2081        };
2082        let out = merge(&input).unwrap();
2083        assert_eq!(out.content, b"a\nY\nc\n");
2084        assert_eq!(out.conflicts, 0);
2085    }
2086
2087    #[test]
2088    fn preserves_shared_and_superset_insertions() {
2089        let input = MergeInput {
2090            base: b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n",
2091            ours: b"1\n2\n3\n4\n5\n5.5\n6\n7\n8\n9\n10\n",
2092            theirs: b"1\n2\n3\n4\n5\n5.5\n6\n7\n8\n9\n10\n10.5\n",
2093            label_ours: "ours",
2094            label_base: "base",
2095            label_theirs: "theirs",
2096            favor: MergeFavor::None,
2097            style: ConflictStyle::Merge,
2098            marker_size: 7,
2099            diff_algorithm: None,
2100            ignore_all_space: false,
2101            ignore_space_change: false,
2102            ignore_space_at_eol: false,
2103            ignore_cr_at_eol: false,
2104        };
2105        let base_lines = split_lines(input.base);
2106        let ours_lines = split_lines(input.ours);
2107        let theirs_lines = split_lines(input.theirs);
2108        let ws_mode = WhitespaceMode::default();
2109        let base_compare_lines = normalize_lines_for_compare(&base_lines, &ws_mode);
2110        let ours_compare_lines = normalize_lines_for_compare(&ours_lines, &ws_mode);
2111        let theirs_compare_lines = normalize_lines_for_compare(&theirs_lines, &ws_mode);
2112        let ours_ops = similar::capture_diff_slices(
2113            Algorithm::Myers,
2114            &base_compare_lines,
2115            &ours_compare_lines,
2116        );
2117        let theirs_ops = similar::capture_diff_slices(
2118            Algorithm::Myers,
2119            &base_compare_lines,
2120            &theirs_compare_lines,
2121        );
2122        let hunks = compute_hunks(
2123            &base_lines,
2124            &ours_lines,
2125            &theirs_lines,
2126            &ours_ops,
2127            &theirs_ops,
2128            &ws_mode,
2129        );
2130        assert_eq!(
2131            hunks.len(),
2132            4,
2133            "expected unchanged shared insertion and theirs-only tail insertion"
2134        );
2135        let out = merge(&input).unwrap();
2136        let rendered = String::from_utf8(out.content).unwrap();
2137        assert_eq!(out.conflicts, 0, "{rendered}");
2138        assert_eq!(
2139            rendered, "1\n2\n3\n4\n5\n5.5\n6\n7\n8\n9\n10\n10.5\n",
2140            "{rendered}"
2141        );
2142    }
2143}