Repository: GitoxideLabs/gitoxide (2026-03-20T10:33:51Z)

Issue:
Blob merge with Myers algorithm produces false conflict where git merge-file resolves cleanly
## Summary

`gix_merge::blob::builtin_driver::text::merge` with the **Myers** diff algorithm produces a false conflict on certain inputs where `git merge-file` (also Myers-based) resolves cleanly. Switching to **Histogram** resolves the issue.

## Reproduction

Given three versions of a file:
- **base** (71 lines) - original version
- **ours** (164 lines) - heavily expanded version with ~90 new lines inserted before a comment
- **theirs** (70 lines) - identical to base except one comment line deleted

The only change in base→theirs is the deletion of a single comment line:
```diff
--- base
+++ theirs
@@ -29,7 +29,6 @@
 	const uiState = inject(UI_STATE);
 	const claudeCodeService = inject(CLAUDE_CODE_SERVICE);

-	// ── MCP config modal ─────────────────────────────────────────────
 	let mcpConfigModal = $state<CodegenMcpConfigModal>();
```

In base→ours, this same comment exists but is now at line 102 (instead of 32) because ~70 lines of new code were inserted above it. The content around it (`let mcpConfigModal`, `const mcpClaudeConfigQuery`) is unchanged.

### Expected behavior (matches `git merge-file`)

Clean merge: keep all the new code from ours, apply the comment deletion from theirs. `git merge-file` exits 0 with no conflict markers.

```sh
git merge-file -p ours.svelte base.svelte theirs.svelte
# exit 0, 163 lines, 0 conflict markers
```

### Actual behavior

`gix_merge::blob::builtin_driver::text::merge` with `Algorithm::Myers` reports `Resolution::Conflict`:

```
<<<<<<< ours
	// ── Actions ──────────────────────────────────────────────────────
	async function onAbort() { ... }
	async function sendMessage(prompt) { ... }
	async function handleAnswerQuestion(answers) { ... }

	// ── MCP config modal ─────────────────────────────────────────────
=======
>>>>>>> theirs
```

gix sees the comment deletion as conflicting with the large insertion above it. The conflict region spans lines 78-105 of the merged output.

### Key finding: Histogram algorithm works correctly

```rust
// Myers:     Resolution::Conflict (1 false conflict marker)
// Histogram: Resolution::Complete (0 conflicts) ✓
```

This suggests the Myers implementation in imara-diff produces different hunk boundaries than git's xdiff for this input, causing the 3-way merge to see overlapping regions where git doesn't.

## Minimal reproduction code

```rust
use gix_merge::blob::builtin_driver::text;

let base = std::fs::read("base.svelte").unwrap();
let ours = std::fs::read("ours.svelte").unwrap();
let theirs = std::fs::read("theirs.svelte").unwrap();

let mut output = Vec::new();
let mut input = imara_diff::intern::InternedInput::new(&[][..], &[][..]);

// Myers: produces false conflict
let options = text::Options {
    diff_algorithm: imara_diff::Algorithm::Myers,
    conflict: text::Conflict::Keep {
        style: text::ConflictStyle::Merge,
        marker_size: std::num::NonZeroU8::new(7).unwrap(),
    },
};
let labels = text::Labels {
    ancestor: Some("base".into()),
    current: Some("ours".into()),
    other: Some("theirs".into()),
};
let resolution = text::function::merge(
    &mut output, &mut input, labels, &ours, &base, &theirs, options,
);
assert!(matches!(resolution, gix_merge::blob::Resolution::Conflict));
// ^ This should be Resolution::Complete to match git behavior
```

## Impact

This causes GitButler's commit amend operation to reject valid changes with `CherryPickMergeConflict` when the 3-way merge (base=workspace tree, ours=target commit tree, theirs=workspace+change) hits this code path. The user is unable to amend their change into the correct commit even though the merge is semantically clean.

## Environment

- gix 0.80.0 (from `main` branch, commit `1a72380a`)
- imara-diff 0.1.8
- macOS (Darwin 25.3.0, aarch64)

## Files

I'll attach the three `.svelte` files (base/ours/theirs) needed to reproduce.



Discussion:
## Reproduction files

The three files needed to reproduce: https://gist.github.com/mtsgrd/268e10a9e3310d255343e0ec809ababb

```sh
# Download and test with git (clean merge)
git merge-file -p ours.svelte base.svelte theirs.svelte
echo "exit: $?"
# Expected: exit 0, no conflicts

# Compare: gix merge with Myers vs Histogram
# Myers: Resolution::Conflict (false positive)
# Histogram: Resolution::Complete (correct)
```
@Byron I've been debugging this issue since last night. I have been thinking it's the hunk locking algorithm that was at fault, but claude kept insisting it the amend I was attempting should succeed in git terms. Could you have a quick look at the above and let me know if the conclusion is valid?



Patch:
diff --git a/gix-merge/src/blob/builtin_driver/text/utils.rs b/gix-merge/src/blob/builtin_driver/text/utils.rs
index cd0b4428b16..f13c260ecf0 100644
--- a/gix-merge/src/blob/builtin_driver/text/utils.rs
+++ b/gix-merge/src/blob/builtin_driver/text/utils.rs
@@ -479,7 +479,9 @@ pub fn collect_hunks(
     side: Side,
     mut hunks: Vec<Hunk>,
 ) -> Vec<Hunk> {
-    hunks.extend(imara_diff::Diff::compute(algorithm, input).hunks().map(|hunk| Hunk {
+    let mut diff = imara_diff::Diff::compute(algorithm, input);
+    diff.postprocess_lines(input);
+    hunks.extend(diff.hunks().map(|hunk| Hunk {
         before: hunk.before,
         after: hunk.after,
         side,


Test patch:
diff --git a/gix-merge/tests/merge/blob/builtin_driver.rs b/gix-merge/tests/merge/blob/builtin_driver.rs
index 82d7c9320c5..29559eb0037 100644
--- a/gix-merge/tests/merge/blob/builtin_driver.rs
+++ b/gix-merge/tests/merge/blob/builtin_driver.rs
@@ -369,6 +369,73 @@ mod text {
         }
     }
 
+    mod false_conflict {
+        use gix_merge::blob::{builtin_driver, builtin_driver::text::Conflict, Resolution};
+        use imara_diff::InternedInput;
+
+        /// Minimal reproduction: Myers produces a false conflict where git merge-file resolves cleanly.
+        ///
+        /// base:   alpha_x / (blank) / bravo_x / charlie_x / (blank)
+        /// ours:   (blank) / (blank) / bravo_x / charlie_x
+        /// theirs: alpha_x / (blank) / charlie_x / (blank)
+        ///
+        /// base→ours:  alpha_x deleted (replaced by blank), trailing blank removed
+        /// base→theirs: bravo_x deleted
+        ///
+        /// These are non-overlapping changes that git merges cleanly.
+        /// See https://github.com/GitoxideLabs/gitoxide/issues/2475
+        #[test]
+        fn myers_false_conflict_with_blank_line_ambiguity() {
+            let base = b"alpha_x\n\nbravo_x\ncharlie_x\n\n";
+            let ours = b"\n\nbravo_x\ncharlie_x\n";
+            let theirs = b"alpha_x\n\ncharlie_x\n\n";
+
+            let labels = builtin_driver::text::Labels {
+                ancestor: Some("base".into()),
+                current: Some("ours".into()),
+                other: Some("theirs".into()),
+            };
+
+            // Histogram resolves cleanly.
+            {
+                let options = builtin_driver::text::Options {
+                    diff_algorithm: imara_diff::Algorithm::Histogram,
+                    conflict: Conflict::Keep {
+                        style: builtin_driver::text::ConflictStyle::Merge,
+                        marker_size: 7.try_into().unwrap(),
+                    },
+                };
+                let mut out = Vec::new();
+                let mut input = InternedInput::default();
+                let res = builtin_driver::text(&mut out, &mut input, labels, ours, base, theirs, options);
+                assert_eq!(res, Resolution::Complete, "Histogram should resolve cleanly");
+            }
+
+            // Myers should also resolve cleanly (it used to produce a false conflict because
+            // imara-diff's Myers splits the ours change into two hunks — a deletion at base[0]
+            // and an empty insertion at base[2] — and the insertion collided with theirs'
+            // deletion at base[2]).
+            {
+                let options = builtin_driver::text::Options {
+                    diff_algorithm: imara_diff::Algorithm::Myers,
+                    conflict: Conflict::Keep {
+                        style: builtin_driver::text::ConflictStyle::Merge,
+                        marker_size: 7.try_into().unwrap(),
+                    },
+                };
+                let mut out = Vec::new();
+                let mut input = InternedInput::default();
+                let res = builtin_driver::text(&mut out, &mut input, labels, ours, base, theirs, options);
+                assert_eq!(
+                    res,
+                    Resolution::Complete,
+                    "Myers should resolve cleanly (git merge-file does). Output:\n{}",
+                    String::from_utf8_lossy(&out)
+                );
+            }
+        }
+    }
+
     mod baseline {
         use std::path::Path;