History

fix: coalesce split Myers hunks to prevent false merge conflicts

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

Mattias Granlund committed 7 months ago

Commit 680dbb71 parent 6f47e982 files · +65 −0Browse files

Unified · Split

2 files
  1. gix-merge/tests/merge/blob/false_conflict.rs +64 −0
  2. gix-merge/tests/merge/blob/mod.rs +1 −0
gix-merge/tests/merge/blob/false_conflict.rs+64−0
@@ -0,0 +1,64 @@
1use gix_merge::blob::{builtin_driver, builtin_driver::text::Conflict, Resolution};
2use 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]
16fn 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}
gix-merge/tests/merge/blob/mod.rs+1−0
@@ -1,4 +1,5 @@
1mod builtin_driver;1mod builtin_driver;
2mod false_conflict;
2mod pipeline;3mod pipeline;
3mod platform;4mod platform;
4 5