ref:c9e281483f59d8f5a1753d02768b35f6dbf29b70

feat(merge): expose conflict regions + diff3 marker rendering

Diff3 could only report a whole-file `:conflict` flag. Its `walk/8` already resolves each region between stable anchors and knows each region's real line offset in base/ours/theirs — it just discarded that on conflict. Add two functions that reuse the same anchor analysis: - `conflict_regions/3` returns each conflicting region localized to its true per-side line offsets (`base_start`/`ours_start`/`theirs_start`) plus that side's lines. This is what a conflict *visualization* needs — the hunks that block a merge, not three whole files. - `merge_markers/4` renders a diff3 conflict-marker file (a ready-to-edit seed for manual resolution). Clean regions reproduce `merge/3` byte-exact. Both share a new `segments/3` walk; the verified `merge/3` path is unchanged (differential fuzz against `git merge-file` still passes). Supports Anvil #336 (PR conflict visualization rework). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: c9e281483f59d8f5a1753d02768b35f6dbf29b70
Author: CI <ci@anvil.test>
Date: 2026-07-10 05:53
Parents: 37fa655
2 files changed +297 -6
Type
lib/ex_git_objectstore/merge/diff3.ex +180 −6
@@ -79,6 +79,174 @@
end
end
@typedoc """
A single conflicting region, with each side's lines and the 0-based line
index at which that region starts in each version. `base_start` indexes the
common ancestor, `ours_start` the ours version, `theirs_start` the theirs
version — so a caller can pull surrounding context from the full files and
render real line numbers per side.
"""
@type region :: %{
base_start: non_neg_integer(),
base_lines: [String.t()],
ours_start: non_neg_integer(),
ours_lines: [String.t()],
theirs_start: non_neg_integer(),
theirs_lines: [String.t()]
}
@doc """
Localize the conflicting regions of a three-way merge.
Uses the same stable-anchor analysis as `merge/3`, but instead of collapsing
to a single `:conflict` it returns every region that cannot be auto-merged,
each anchored to its real line offset in base/ours/theirs. This is what a
conflict *visualization* needs: the specific hunks that block a merge, not
three whole files.
* `{:clean, nil}` — no region conflicts (the file merges cleanly)
* `{:conflict, [region]}` — one entry per conflicting region, in file order
* `:binary` — a side contains a NUL byte; not line-mergeable (like git)
"""
@spec conflict_regions(binary(), binary(), binary()) ::
{:clean, nil} | {:conflict, [region()]} | :binary
def conflict_regions(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
:binary
else
regions =
for {:conflict, region} <- segments(split(base), split(ours), split(theirs)),
do: region
if regions == [], do: {:clean, nil}, else: {:conflict, regions}
end
end
@doc """
Render a three-way merge as a conflict-marker file (diff3 style).
Clean regions are emitted verbatim; each conflicting region is wrapped in the
canonical `git merge-file --diff3` markers so the result is a ready-to-edit
starting point for a human resolving the conflict:
<<<<<<< ours-label
...ours...
||||||| base-label
...base...
=======
...theirs...
>>>>>>> theirs-label
Options: `:ours_label`, `:base_label`, `:theirs_label` (all default to the
bare side name). Returns `{:clean, bytes}` when nothing conflicts,
`{:conflict, bytes}` when markers were inserted, or `:binary`.
"""
@spec merge_markers(binary(), binary(), binary(), keyword()) ::
{:clean, binary()} | {:conflict, binary()} | :binary
def merge_markers(base, ours, theirs, opts \\ [])
when is_binary(base) and is_binary(ours) and is_binary(theirs) do
if binary?(base) or binary?(ours) or binary?(theirs) do
:binary
else
segs = segments(split(base), split(ours), split(theirs))
conflicted? = Enum.any?(segs, &match?({:conflict, _}, &1))
body =
segs
|> Enum.flat_map(&render_segment(&1, opts))
|> Enum.join("\n")
if conflicted?, do: {:conflict, body}, else: {:clean, body}
end
end
defp render_segment({:clean, lines}, _opts), do: lines
defp render_segment({:conflict, region}, opts) do
ours_label = Keyword.get(opts, :ours_label, "ours")
base_label = Keyword.get(opts, :base_label, "base")
theirs_label = Keyword.get(opts, :theirs_label, "theirs")
["<<<<<<< #{ours_label}"] ++
region.ours_lines ++
["||||||| #{base_label}"] ++
region.base_lines ++
["======="] ++
region.theirs_lines ++
[">>>>>>> #{theirs_label}"]
end
# Walk the three versions into an ordered list of segments, each either
# `{:clean, lines}` (an auto-merged run, incl. stable anchor lines) or
# `{:conflict, region}`. `conflict_regions/3` and `merge_markers/4` both
# derive from this; clean segments reproduce exactly what `merge/3` would
# emit for the same region.
defp segments(base, ours, theirs) do
base_t = List.to_tuple(base)
ours_t = List.to_tuple(ours)
theirs_t = List.to_tuple(theirs)
anchors = anchor_list(base, ours, theirs, base_t)
seg_walk(anchors, base_t, ours_t, theirs_t, 0, 0, 0, [])
end
defp seg_walk([], base_t, ours_t, theirs_t, bi, oi, ti, acc) do
acc =
add_segment(
acc,
slice(base_t, bi),
slice(ours_t, oi),
slice(theirs_t, ti),
bi,
oi,
ti
)
Enum.reverse(acc)
end
defp seg_walk([{b, o, t} | rest], base_t, ours_t, theirs_t, bi, oi, ti, acc) do
acc =
add_segment(
acc,
slice(base_t, bi, b),
slice(ours_t, oi, o),
slice(theirs_t, ti, t),
bi,
oi,
ti
)
# The anchor line is stable in all three versions — always clean.
acc = push_clean(acc, [elem(base_t, b)])
seg_walk(rest, base_t, ours_t, theirs_t, b + 1, o + 1, t + 1, acc)
end
defp add_segment(acc, base, ours, theirs, bi, oi, ti) do
case resolve(base, ours, theirs) do
{:ok, lines} ->
push_clean(acc, lines)
:conflict ->
region = %{
base_start: bi,
base_lines: base,
ours_start: oi,
ours_lines: ours,
theirs_start: ti,
theirs_lines: theirs
}
[{:conflict, region} | acc]
end
end
# `acc` is newest-first; a run of adjacent clean lines is coalesced so
# callers see contiguous context rather than one segment per line.
defp push_clean([{:clean, prev} | rest], lines), do: [{:clean, prev ++ lines} | rest]
defp push_clean(acc, lines), do: [{:clean, lines} | acc]
# `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).
@@ -88,16 +256,22 @@
base_t = List.to_tuple(base)
ours_t = List.to_tuple(ours)
theirs_t = List.to_tuple(theirs)
anchors = anchor_list(base, ours, theirs, base_t)
walk(anchors, base_t, ours_t, theirs_t, 0, 0, 0, [])
end
# Stable anchors shared by every walk over these three versions: base lines
# matched to both ours and theirs, minus lines inside a repeated run (see
# `in_repeated_run?/2`). `{base_idx, ours_idx, theirs_idx}`, sorted, strictly
# increasing in all three coordinates.
defp anchor_list(base, ours, theirs, base_t) do
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))
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
test/ex_git_objectstore/merge/diff3_test.exs +117 −0
@@ -147,4 +147,121 @@
assert {:ok, "x\nd\nd\ny\nz\nQ\n"} = Diff3.merge(base, ours, theirs)
end
end
describe "conflict_regions/3" do
test "clean merge reports no regions" do
base = "a\nb\nc\nd\ne\n"
ours = "a\nXB\nc\nd\ne\n"
theirs = "a\nb\nc\nXD\ne\n"
assert {:clean, nil} = Diff3.conflict_regions(base, ours, theirs)
end
test "single mid-file conflict is localized with real per-side offsets" do
# Both sides rewrite line 3 (index 2) differently -> one conflict there.
base = "a\nb\nc\nd\ne\n"
ours = "a\nb\nC-OURS\nd\ne\n"
theirs = "a\nb\nC-THEIRS\nd\ne\n"
assert {:conflict, [region]} = Diff3.conflict_regions(base, ours, theirs)
assert region.base_start == 2
assert region.ours_start == 2
assert region.theirs_start == 2
assert region.base_lines == ["c"]
assert region.ours_lines == ["C-OURS"]
assert region.theirs_lines == ["C-THEIRS"]
end
test "conflict deep in a large file keeps its true offset (not truncated to the top)" do
prefix = for(i <- 1..300, do: "line#{i}") |> Enum.join("\n")
base = prefix <> "\nTARGET\n" <> "tail\n"
ours = prefix <> "\nTARGET-OURS\n" <> "tail\n"
theirs = prefix <> "\nTARGET-THEIRS\n" <> "tail\n"
assert {:conflict, [region]} = Diff3.conflict_regions(base, ours, theirs)
# 300 prefix lines (indices 0..299), TARGET at index 300 on every side.
assert region.base_start == 300
assert region.ours_start == 300
assert region.theirs_start == 300
assert region.ours_lines == ["TARGET-OURS"]
assert region.theirs_lines == ["TARGET-THEIRS"]
end
test "two independent conflicts return two regions with distinct offsets" do
base = "a\nb\nc\nd\ne\nf\ng\n"
ours = "A1\nb\nc\nd\ne\nf\nG1\n"
theirs = "A2\nb\nc\nd\ne\nf\nG2\n"
assert {:conflict, [first, second]} = Diff3.conflict_regions(base, ours, theirs)
assert first.base_start == 0
assert first.ours_lines == ["A1"]
assert first.theirs_lines == ["A2"]
assert second.base_start == 6
assert second.ours_lines == ["G1"]
assert second.theirs_lines == ["G2"]
end
test "diverging offsets when sides insert different numbers of lines" do
# ours inserts 2 lines before the conflict; theirs inserts 1. The conflict
# region's per-side start must reflect each side's own line numbering.
base = "top\nMID\nbot\n"
ours = "top\no1\no2\nMID-OURS\nbot\n"
theirs = "top\nt1\nMID-THEIRS\nbot\n"
assert {:conflict, [region]} = Diff3.conflict_regions(base, ours, theirs)
assert region.base_start == 1
assert region.ours_start == 1
assert region.theirs_start == 1
# The conflicting run on each side includes that side's inserted lines.
assert "MID-OURS" in region.ours_lines
assert "MID-THEIRS" in region.theirs_lines
end
test "binary content is reported as :binary, not regions" do
base = "a\nb\n"
ours = "a\n\x00\n"
theirs = "a\nc\n"
assert :binary = Diff3.conflict_regions(base, ours, theirs)
end
end
describe "merge_markers/4" do
test "clean merge round-trips to the same bytes as merge/3" do
base = "a\nb\nc\nd\ne\n"
ours = "a\nXB\nc\nd\ne\n"
theirs = "a\nb\nc\nXD\ne\n"
assert {:ok, merged} = Diff3.merge(base, ours, theirs)
assert {:clean, ^merged} = Diff3.merge_markers(base, ours, theirs)
end
test "conflict produces diff3 markers around the conflicting region" do
base = "a\nb\nc\nd\ne\n"
ours = "a\nb\nC-OURS\nd\ne\n"
theirs = "a\nb\nC-THEIRS\nd\ne\n"
assert {:conflict, body} =
Diff3.merge_markers(base, ours, theirs,
ours_label: "current",
base_label: "base",
theirs_label: "incoming"
)
assert body =~ "<<<<<<< current"
assert body =~ "||||||| base"
assert body =~ "======="
assert body =~ ">>>>>>> incoming"
# Clean context lines are preserved outside the markers.
assert body =~ "a\nb\n<<<<<<< current"
assert body =~ "C-OURS"
assert body =~ "C-THEIRS"
# The base version of the conflicting line sits between ||||||| and =======.
assert body =~ "||||||| base\nc\n======="
end
test "binary content is reported as :binary" do
assert :binary = Diff3.merge_markers("a\n", "\x00", "b\n")
end
end
end