When imara-diff's Myers algorithm diffs two files, it sometimes splits what is logically one change into a non-empty deletion hunk and a separate empty insertion hunk, with one unchanged base line between them. This is a valid minimal edit script, but it differs from the alignment that git's xdiff (also Myers-based) would choose. When the empty insertion lands at a base position that the other side of a 3-way merge also touches, `take_intersecting` reports an overlap and the merge produces a conflict — even though `git merge-file` resolves the same inputs cleanly. Fix this by adding a pre-processing step after sorting hunks: for each empty-range insertion hunk, look backwards for the nearest same-side non-empty hunk within a gap of at most one unchanged base line. If found, extend that hunk to cover the gap and the insertion point. This re-joins the split hunk, making the merge robust to different diff algorithm alignment choices. The coalescing is conservative: it only applies when (a) the insertion hunk has an empty before-range, (b) there is a same-side non-empty hunk nearby (gap ≤ 1), and (c) that hunk is the nearest same-side hunk. This avoids affecting cases like zdiff3-interesting where empty insertions are standalone and represent genuinely different changes. Closes #2475
Unified · Split
| @@ -0,0 +1,64 @@ | ||
| 1 | +use gix_merge::blob::{builtin_driver, builtin_driver::text::Conflict, Resolution}; | |
| 2 | +use imara_diff::InternedInput; | |
| 3 | + | |
| 4 | +/// Minimal reproduction: Myers produces a false conflict where git merge-file resolves cleanly. | |
| 5 | +/// | |
| 6 | +/// base: alpha_x / (blank) / bravo_x / charlie_x / (blank) | |
| 7 | +/// ours: (blank) / (blank) / bravo_x / charlie_x | |
| 8 | +/// theirs: alpha_x / (blank) / charlie_x / (blank) | |
| 9 | +/// | |
| 10 | +/// base→ours: alpha_x deleted (replaced by blank), trailing blank removed | |
| 11 | +/// base→theirs: bravo_x deleted | |
| 12 | +/// | |
| 13 | +/// These are non-overlapping changes that git merges cleanly. | |
| 14 | +/// See https://github.com/GitoxideLabs/gitoxide/issues/2475 | |
| 15 | +#[test] | |
| 16 | +fn myers_false_conflict_with_blank_line_ambiguity() { | |
| 17 | + let base = b"alpha_x\n\nbravo_x\ncharlie_x\n\n"; | |
| 18 | + let ours = b"\n\nbravo_x\ncharlie_x\n"; | |
| 19 | + let theirs = b"alpha_x\n\ncharlie_x\n\n"; | |
| 20 | + | |
| 21 | + let labels = builtin_driver::text::Labels { | |
| 22 | + ancestor: Some("base".into()), | |
| 23 | + current: Some("ours".into()), | |
| 24 | + other: Some("theirs".into()), | |
| 25 | + }; | |
| 26 | + | |
| 27 | + // Histogram resolves cleanly. | |
| 28 | + { | |
| 29 | + let options = builtin_driver::text::Options { | |
| 30 | + diff_algorithm: imara_diff::Algorithm::Histogram, | |
| 31 | + conflict: Conflict::Keep { | |
| 32 | + style: builtin_driver::text::ConflictStyle::Merge, | |
| 33 | + marker_size: 7.try_into().unwrap(), | |
| 34 | + }, | |
| 35 | + }; | |
| 36 | + let mut out = Vec::new(); | |
| 37 | + let mut input = InternedInput::default(); | |
| 38 | + let res = builtin_driver::text(&mut out, &mut input, labels, ours, base, theirs, options); | |
| 39 | + assert_eq!(res, Resolution::Complete, "Histogram should resolve cleanly"); | |
| 40 | + } | |
| 41 | + | |
| 42 | + // Myers should also resolve cleanly (it used to produce a false conflict because | |
| 43 | + // imara-diff's Myers splits the ours change into two hunks — a deletion at base[0] | |
| 44 | + // and an empty insertion at base[2] — and the insertion collided with theirs' | |
| 45 | + // deletion at base[2]). | |
| 46 | + { | |
| 47 | + let options = builtin_driver::text::Options { | |
| 48 | + diff_algorithm: imara_diff::Algorithm::Myers, | |
| 49 | + conflict: Conflict::Keep { | |
| 50 | + style: builtin_driver::text::ConflictStyle::Merge, | |
| 51 | + marker_size: 7.try_into().unwrap(), | |
| 52 | + }, | |
| 53 | + }; | |
| 54 | + let mut out = Vec::new(); | |
| 55 | + let mut input = InternedInput::default(); | |
| 56 | + let res = builtin_driver::text(&mut out, &mut input, labels, ours, base, theirs, options); | |
| 57 | + assert_eq!( | |
| 58 | + res, | |
| 59 | + Resolution::Complete, | |
| 60 | + "Myers should resolve cleanly (git merge-file does). Output:\n{}", | |
| 61 | + String::from_utf8_lossy(&out) | |
| 62 | + ); | |
| 63 | + } | |
| 64 | +} | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | mod builtin_driver; |
| 2 | +mod false_conflict; | |
| 2 | 3 | mod pipeline; |
| 3 | 4 | mod platform; |
| 4 | 5 | |