@@ -1,0 +1,208 @@
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.Merge.Diff3 do
@moduledoc """
Three-way line-level merge (diff3), matching canonical git behavior.
This is the algorithm behind `git merge-file`: given a common ancestor and
two descendants, it splits the three files into regions delimited by lines
that are *stable* (unchanged on both sides), then resolves each unstable
region between stable anchors:
* region unchanged on ours -> take theirs
* region unchanged on theirs -> take ours
* both sides changed identically -> take either
* both sides changed differently -> conflict
The grouping is the subtle part that makes this match git rather than a
naive per-line comparison: two edits on different lines still *conflict* if
no stable line separates them (e.g. ours edits line 2 while theirs edits the
adjacent line 3). Both fall in one unstable region where both sides diverge.
Reference: Khanna, Kunal & Pierce, "A Formal Investigation of Diff3" (2007);
cross-checked against `git merge-file` output. Stable anchors are the base
lines matched in both the base↔ours and base↔theirs Myers diffs.
Binary content (any NUL byte) is never line-merged — like git, it returns
`:conflict`.
## Fidelity to git
On realistic inputs (files whose lines are mostly distinct, as source code
is) this agrees with `git merge-file` byte-for-byte in both directions —
verified by a differential fuzz test (`diff3_git_differential_test.exs`).
On pathological inputs with many *identical repeated lines*, where Myers and
git's xdiff can align a rewritten run differently, the result is **biased
conservative**: an adversarial fuzz of repeated single-character lines
produces zero false auto-merges (we never merge cleanly where git conflicts)
and only a small fraction of false *conflicts* (we conflict where git merges
— the safe direction). This is achieved by refusing to anchor on a line
inside a repeated run; closing the remaining safe-direction gap would need a
full port of git's `xdl_merge` and is tracked as a follow-up.
"""
alias ExGitObjectstore.Diff.Myers
@doc """
Merge `ours` and `theirs` against common ancestor `base`.
Returns `{:ok, merged_bytes}` on a clean merge (byte-exact, including
trailing-newline handling), or `:conflict` if any region cannot be merged.
"""
@spec merge(binary(), binary(), binary()) :: {:ok, binary()} | :conflict
def merge(base, ours, theirs)
when is_binary(base) and is_binary(ours) and is_binary(theirs) do
if binary?(base) or binary?(ours) or binary?(theirs) do
:conflict
else
base_lines = split(base)
ours_lines = split(ours)
theirs_lines = split(theirs)
case merge_lines(base_lines, ours_lines, theirs_lines) do
{:ok, merged} -> {:ok, Enum.join(merged, "\n")}
:conflict -> :conflict
end
end
end
# `String.split/3` with trim: false and `Enum.join/2` are exact inverses,
# so a clean merge reproduces bytes precisely (incl. trailing newline,
# which surfaces as a trailing "" element).
defp split(content), do: String.split(content, "\n", trim: false)
defp merge_lines(base, ours, theirs) do
base_t = List.to_tuple(base)
ours_t = List.to_tuple(ours)
theirs_t = List.to_tuple(theirs)
match_o = match_map(base, ours)
match_t = match_map(base, theirs)
anchors =
match_o
|> stable_anchors(match_t)
|> Enum.reject(&in_repeated_run?(&1, base_t))
walk(anchors, base_t, ours_t, theirs_t, 0, 0, 0, [])
end
# A base line that is part of a run of identical lines is an unreliable
# anchor: Myers may match it to a different occurrence than git's xdiff
# would, splitting a run that both sides rewrote into falsely-clean pieces.
# Dropping it forces the whole run to resolve as one region — so a run
# changed by both sides conflicts (matching git), while a run left unchanged
# on either side still merges cleanly via `resolve/3`. A lone repeated line
# (e.g. a single blank between distinct code lines) is not in a run and
# stays a valid anchor, so ordinary blank-separated edits are unaffected.
defp in_repeated_run?({b, _o, _t}, base_t) do
line = elem(base_t, b)
(b > 0 and elem(base_t, b - 1) == line) or
(b + 1 < tuple_size(base_t) and elem(base_t, b + 1) == line)
end
# Build %{base_index => side_index} for lines unchanged between base and side.
# Reconstructs positions by replaying the Myers edit script in order.
defp match_map(base, side) do
base
|> Myers.diff(side)
|> slide_down()
|> Enum.reduce({0, 0, %{}}, fn
{:eq, _}, {bi, si, acc} -> {bi + 1, si + 1, Map.put(acc, bi, si)}
{:del, _}, {bi, si, acc} -> {bi + 1, si, acc}
{:ins, _}, {bi, si, acc} -> {bi, si + 1, acc}
end)
|> elem(2)
end
# Canonicalize a Myers edit script by sliding each insertion/deletion as far
# down as it can go through runs of identical lines. Diffing ours and theirs
# against base independently can otherwise place an edit at different ends of
# a repeated run; sliding both to the same boundary makes jointly-edited runs
# overlap so they conflict instead of being falsely auto-merged. This mirrors
# git's diff compaction (xdiff `xdl_change_compact`).
#
# Rewrite, while x == y: [{:del, x}, {:eq, y}] -> [{:eq, x}, {:del, y}] and
# [{:ins, x}, {:eq, y}] -> [{:eq, x}, {:ins, y}]. Each rewrite preserves both
# reconstructed sequences (x == y) and moves the edit one line later; the
# recursion cascades a group to the bottom of its run in a single pass.
defp slide_down([{:del, x}, {:eq, y} | rest]) when x == y do
[{:eq, x} | slide_down([{:del, y} | rest])]
end
defp slide_down([{:ins, x}, {:eq, y} | rest]) when x == y do
[{:eq, x} | slide_down([{:ins, y} | rest])]
end
defp slide_down([edit | rest]), do: [edit | slide_down(rest)]
defp slide_down([]), do: []
# Base lines matched on *both* sides, as {base_idx, ours_idx, theirs_idx}.
# Both match maps are monotonic in base_idx, so sorting by base_idx yields
# triples strictly increasing in all three coordinates.
defp stable_anchors(match_o, match_t) do
match_o
|> Map.keys()
|> Enum.filter(&Map.has_key?(match_t, &1))
|> Enum.sort()
|> Enum.map(fn b -> {b, Map.fetch!(match_o, b), Map.fetch!(match_t, b)} end)
end
# `acc` accumulates output segments (each a list of lines) in reverse.
defp walk([], base_t, ours_t, theirs_t, bi, oi, ti, acc) do
# Trailing unstable region after the last anchor.
case resolve(slice(base_t, bi), slice(ours_t, oi), slice(theirs_t, ti)) do
:conflict -> :conflict
{:ok, lines} -> {:ok, flatten([lines | acc])}
end
end
defp walk([{b, o, t} | rest], base_t, ours_t, theirs_t, bi, oi, ti, acc) do
case resolve(slice(base_t, bi, b), slice(ours_t, oi, o), slice(theirs_t, ti, t)) do
:conflict ->
:conflict
{:ok, lines} ->
acc = [[elem(base_t, b)], lines | acc]
walk(rest, base_t, ours_t, theirs_t, b + 1, o + 1, t + 1, acc)
end
end
# Resolve one unstable region (base/ours/theirs slices between anchors).
defp resolve(base, ours, theirs) do
cond do
ours == base -> {:ok, theirs}
theirs == base -> {:ok, ours}
ours == theirs -> {:ok, ours}
true -> :conflict
end
end
defp flatten(reversed_segments) do
reversed_segments |> Enum.reverse() |> Enum.concat()
end
defp slice(tuple, lo), do: slice(tuple, lo, tuple_size(tuple))
defp slice(_tuple, lo, hi) when lo >= hi, do: []
defp slice(tuple, lo, hi), do: for(i <- lo..(hi - 1)//1, do: elem(tuple, i))
# Same heuristic git/ExGitObjectstore.Diff use: NUL byte in the first 8 KiB.
defp binary?(content) do
chunk = binary_part(content, 0, min(byte_size(content), 8192))
:binary.match(chunk, <<0>>) != :nomatch
end
end