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 | mod builtin_driver; | 1 | mod builtin_driver; |
| 2 | mod false_conflict; | ||
| 2 | mod pipeline; | 3 | mod pipeline; |
| 3 | mod platform; | 4 | mod platform; |
| 4 | 5 | ||