ref:3b5c89918f52b52d8e72eadfb0f5c964bd7083f6

Add diff engine: Myers algorithm + tree/blob/commit diff with hunk formatting

Phase 5 of ex_git_objectstore. Implements: - Myers diff algorithm for computing shortest edit scripts - Tree-level diff (recursive tree entry comparison) - Blob-level diff with configurable context lines and hunk grouping - Commit-level diff (tree diff + blob diffs per file) - Unified diff format output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 3b5c89918f52b52d8e72eadfb0f5c964bd7083f6
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-10 07:51
Parents: c48d20e
4 files changed +817 -0
Type
lib/ex_git_objectstore/diff.ex +348 −0
@@ -1,0 +1,348 @@
defmodule ExGitObjectstore.Diff do
@moduledoc """
Tree-level diff and line-level diff with hunk formatting.
"""
alias ExGitObjectstore.{ObjectResolver, Repo}
alias ExGitObjectstore.Object.{Blob, Tree}
alias ExGitObjectstore.Diff.Myers
@type file_change :: %{
path: String.t(),
status: :added | :deleted | :modified | :renamed,
old_sha: String.t() | nil,
new_sha: String.t() | nil,
old_mode: String.t() | nil,
new_mode: String.t() | nil
}
@type hunk :: %{
old_start: non_neg_integer(),
old_count: non_neg_integer(),
new_start: non_neg_integer(),
new_count: non_neg_integer(),
lines: [{:context | :add | :del, String.t()}]
}
@type file_diff :: %{
path: String.t(),
status: :added | :deleted | :modified,
hunks: [hunk()]
}
@doc """
Compute tree-level diff between two tree SHAs.
Returns a list of file changes.
"""
@spec diff_trees(Repo.t(), String.t() | nil, String.t() | nil) ::
{:ok, [file_change()]} | {:error, term()}
def diff_trees(repo, old_tree_sha, new_tree_sha) do
old_entries = load_tree_entries(repo, old_tree_sha)
new_entries = load_tree_entries(repo, new_tree_sha)
changes = diff_tree_entries(repo, old_entries, new_entries, "")
{:ok, List.flatten(changes)}
end
@doc """
Compute line-level diff for a file change.
Returns hunks with context lines.
"""
@spec diff_blobs(Repo.t(), String.t() | nil, String.t() | nil, keyword()) ::
{:ok, [hunk()]} | {:error, term()}
def diff_blobs(repo, old_sha, new_sha, opts \\ []) do
context = Keyword.get(opts, :context, 3)
old_content = load_blob_content(repo, old_sha)
new_content = load_blob_content(repo, new_sha)
edits = Myers.diff_lines(old_content, new_content)
hunks = edits_to_hunks(edits, context)
{:ok, hunks}
end
@doc """
Compute full diff (tree changes + line diffs) between two commits.
"""
@spec diff_commits(Repo.t(), String.t(), String.t(), keyword()) ::
{:ok, [file_diff()]} | {:error, term()}
def diff_commits(repo, old_commit_sha, new_commit_sha, opts \\ []) do
with {:ok, old_commit} <- ObjectResolver.read(repo, old_commit_sha),
{:ok, new_commit} <- ObjectResolver.read(repo, new_commit_sha),
{:ok, changes} <- diff_trees(repo, old_commit.tree, new_commit.tree) do
file_diffs =
Enum.map(changes, fn change ->
case diff_blobs(repo, change.old_sha, change.new_sha, opts) do
{:ok, hunks} ->
%{path: change.path, status: change.status, hunks: hunks}
{:error, _} ->
%{path: change.path, status: change.status, hunks: []}
end
end)
{:ok, file_diffs}
end
end
@doc """
Format hunks into unified diff text.
"""
@spec format_unified(String.t(), [hunk()]) :: String.t()
def format_unified(path, hunks) do
header = "--- a/#{path}\n+++ b/#{path}\n"
body =
Enum.map(hunks, fn hunk ->
hunk_header =
"@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@\n"
lines =
Enum.map(hunk.lines, fn
{:context, line} -> " #{line}\n"
{:add, line} -> "+#{line}\n"
{:del, line} -> "-#{line}\n"
end)
[hunk_header | lines]
end)
IO.iodata_to_binary([header | body])
end
# -- Private --
defp load_tree_entries(_repo, nil), do: %{}
defp load_tree_entries(repo, tree_sha) do
case ObjectResolver.read(repo, tree_sha) do
{:ok, %Tree{entries: entries}} ->
Map.new(entries, fn entry -> {entry.name, entry} end)
_ ->
%{}
end
end
defp load_blob_content(_repo, nil), do: ""
defp load_blob_content(repo, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %Blob{content: content}} -> content
_ -> ""
end
end
defp diff_tree_entries(repo, old_entries, new_entries, prefix) do
all_names =
MapSet.union(
MapSet.new(Map.keys(old_entries)),
MapSet.new(Map.keys(new_entries))
)
|> Enum.sort()
Enum.flat_map(all_names, fn name ->
path = if prefix == "", do: name, else: "#{prefix}/#{name}"
old = Map.get(old_entries, name)
new = Map.get(new_entries, name)
cond do
old == nil and new != nil ->
if new.mode == "40000" do
# New directory — recurse
new_sub = load_tree_entries(repo, new.sha)
diff_tree_entries(repo, %{}, new_sub, path)
else
[
%{
path: path,
status: :added,
old_sha: nil,
new_sha: new.sha,
old_mode: nil,
new_mode: new.mode
}
]
end
old != nil and new == nil ->
if old.mode == "40000" do
old_sub = load_tree_entries(repo, old.sha)
diff_tree_entries(repo, old_sub, %{}, path)
else
[
%{
path: path,
status: :deleted,
old_sha: old.sha,
new_sha: nil,
old_mode: old.mode,
new_mode: nil
}
]
end
old.sha == new.sha and old.mode == new.mode ->
# Unchanged
[]
old.mode == "40000" and new.mode == "40000" ->
# Both directories — recurse
old_sub = load_tree_entries(repo, old.sha)
new_sub = load_tree_entries(repo, new.sha)
diff_tree_entries(repo, old_sub, new_sub, path)
true ->
[
%{
path: path,
status: :modified,
old_sha: old.sha,
new_sha: new.sha,
old_mode: old.mode,
new_mode: new.mode
}
]
end
end)
end
defp edits_to_hunks(edits, context) do
# Convert Myers edits to indexed lines
indexed = index_edits(edits)
# Group consecutive non-eq edits into change regions
change_groups = group_changes(indexed, context)
Enum.map(change_groups, fn group ->
build_hunk(group)
end)
end
defp index_edits(edits) do
{result, _old_line, _new_line} =
Enum.reduce(edits, {[], 1, 1}, fn
{:eq, line}, {acc, old, new} ->
{[{:context, line, old, new} | acc], old + 1, new + 1}
{:del, line}, {acc, old, new} ->
{[{:del, line, old, new} | acc], old + 1, new}
{:ins, line}, {acc, old, new} ->
{[{:add, line, old, new} | acc], old, new + 1}
end)
Enum.reverse(result)
end
defp group_changes(indexed, context) do
# Find change regions (del/add) and expand with context
{groups, current_group} =
Enum.reduce(indexed, {[], []}, fn item, {groups, current} ->
case item do
{:context, _, _, _} ->
if current == [] do
{groups, []}
else
{groups, current ++ [item]}
end
_ ->
{groups, current ++ [item]}
end
end)
all_groups = if current_group != [], do: groups ++ [current_group], else: groups
# Add context lines around each group
Enum.map(all_groups, fn group ->
add_context(group, indexed, context)
end)
|> Enum.reject(&Enum.empty?/1)
end
defp add_context(group, all_indexed, context) do
return_if_empty = fn -> [] end
case {List.first(group), List.last(group)} do
{nil, _} ->
return_if_empty.()
{first, last} ->
# Find indices in all_indexed
first_idx = Enum.find_index(all_indexed, &(&1 == first))
last_idx = Enum.find_index(all_indexed, &(&1 == last))
if first_idx == nil or last_idx == nil do
group
else
start_idx = max(0, first_idx - context)
end_idx = min(length(all_indexed) - 1, last_idx + context)
Enum.slice(all_indexed, start_idx..end_idx)
end
end
end
defp build_hunk(lines) do
# Filter out trailing empty context that's just the last empty-string split artifact
lines = drop_trailing_empty_context(lines)
{old_lines, new_lines, formatted} =
Enum.reduce(lines, {0, 0, []}, fn
{:context, line, _old, _new}, {old_c, new_c, acc} ->
{old_c + 1, new_c + 1, [{:context, line} | acc]}
{:del, line, _old, _new}, {old_c, new_c, acc} ->
{old_c + 1, new_c, [{:del, line} | acc]}
{:add, line, _old, _new}, {old_c, new_c, acc} ->
{old_c, new_c + 1, [{:add, line} | acc]}
end)
# Find the starting line numbers
first_old =
lines
|> Enum.find(fn
{:context, _, _, _} -> true
{:del, _, _, _} -> true
_ -> false
end)
|> case do
{:context, _, old, _} -> old
{:del, _, old, _} -> old
nil -> 1
end
first_new =
lines
|> Enum.find(fn
{:context, _, _, _} -> true
{:add, _, _, _} -> true
_ -> false
end)
|> case do
{:context, _, _, new} -> new
{:add, _, _, new} -> new
nil -> 1
end
%{
old_start: first_old,
old_count: old_lines,
new_start: first_new,
new_count: new_lines,
lines: Enum.reverse(formatted)
}
end
defp drop_trailing_empty_context(lines) do
lines
|> Enum.reverse()
|> Enum.drop_while(fn
{:context, "", _, _} -> true
_ -> false
end)
|> Enum.reverse()
end
end
lib/ex_git_objectstore/diff/myers.ex +158 −0
@@ -1,0 +1,158 @@
defmodule ExGitObjectstore.Diff.Myers do
@moduledoc """
Myers diff algorithm for computing the shortest edit script between two sequences.
Uses the standard forward-only approach with trace recording and backtracking.
"""
@type edit :: {:eq, term()} | {:ins, term()} | {:del, term()}
@doc """
Compute the shortest edit script to transform `a` into `b`.
Returns a list of `{:eq, elem}`, `{:ins, elem}`, `{:del, elem}` tuples.
"""
@spec diff(list(), list()) :: [edit()]
def diff(a, b) when is_list(a) and is_list(b) do
n = length(a)
m = length(b)
a_vec = List.to_tuple(a)
b_vec = List.to_tuple(b)
ses(a_vec, b_vec, n, m)
end
@doc """
Compute diff for text (splits by lines).
"""
@spec diff_lines(String.t(), String.t()) :: [edit()]
def diff_lines(text_a, text_b) do
lines_a = String.split(text_a, "\n", trim: false)
lines_b = String.split(text_b, "\n", trim: false)
diff(lines_a, lines_b)
end
defp ses(_a, _b, 0, 0), do: []
defp ses(a, b, n, m) do
max = n + m
v0 = %{1 => 0}
# Forward pass: build trace of V snapshots
# trace stores V state AFTER processing each d value
# We also need v0 (the initial state before d=0)
case find_d(a, b, n, m, max, 0, v0, [v0]) do
{:found, trace} ->
backtrack(trace, a, b, n, m)
:not_found ->
[]
end
end
defp find_d(_a, _b, _n, _m, max, d, _v, _trace) when d > max, do: :not_found
defp find_d(a, b, n, m, max, d, v, trace) do
v_new = process_diag(a, b, n, m, d, -d, v)
end_x = Map.get(v_new, n - m, -1)
if end_x >= n do
# trace already has [v0, ...] accumulated in reverse
# Add v_new and reverse to get [v0, v_after_d0, v_after_d1, ..., v_after_dD]
{:found, Enum.reverse([v_new | trace])}
else
find_d(a, b, n, m, max, d + 1, v_new, [v_new | trace])
end
end
defp process_diag(_a, _b, _n, _m, d, k, v) when k > d, do: v
defp process_diag(a, b, n, m, d, k, v) do
x =
if k == -d or (k != d and Map.get(v, k - 1, 0) < Map.get(v, k + 1, 0)) do
Map.get(v, k + 1, 0)
else
Map.get(v, k - 1, 0) + 1
end
y = x - k
{x, _y} = snake(a, b, n, m, x, y)
v = Map.put(v, k, x)
process_diag(a, b, n, m, d, k + 2, v)
end
defp snake(a, b, n, m, x, y) when x < n and y < m do
if elem(a, x) == elem(b, y) do
snake(a, b, n, m, x + 1, y + 1)
else
{x, y}
end
end
defp snake(_a, _b, _n, _m, x, y), do: {x, y}
# Backtrack through V snapshots to reconstruct edit script.
#
# trace = [v_initial, v_after_d0, v_after_d1, ..., v_after_dD]
# trace has D+2 elements. trace[0] = initial V = %{1 => 0}.
# trace[d+1] = V after processing d.
#
# For backtracking at step d (1-indexed edit step):
# - The end point at this step is determined by trace[d+1] at diagonal k
# - The previous state is trace[d] (which is V after processing d-1)
#
# We start at (n, m) and work backwards d = D, D-1, ..., 1
defp backtrack(trace, a, b, n, m) do
trace_arr = List.to_tuple(trace)
# D = number of edits = tuple_size - 2 (since we have D+2 elements)
d_max = tuple_size(trace_arr) - 2
do_backtrack(trace_arr, a, b, d_max, n, m, [])
end
# Base case: d=0, just emit the initial snake from (0,0) to (x, y)
defp do_backtrack(_trace, a, _b, 0, x, _y, edits) do
diag(a, 0, x) ++ edits
end
defp do_backtrack(trace, a, b, d, x, y, edits) do
k = x - y
# V state from previous round (after processing d-1)
v_prev = elem(trace, d)
# Determine which direction we took at step d
prev_k =
if k == -d or (k != d and Map.get(v_prev, k - 1, 0) < Map.get(v_prev, k + 1, 0)) do
k + 1
else
k - 1
end
# End point of previous round on diagonal prev_k
prev_x = Map.get(v_prev, prev_k, 0)
prev_y = prev_x - prev_k
# Mid point after the edit (before snake)
{mid_x, edit} =
if prev_k > k do
# Insert (moved down: y+1, x stays)
{prev_x, {:ins, elem(b, prev_y)}}
else
# Delete (moved right: x+1, y stays)
{prev_x + 1, {:del, elem(a, prev_x)}}
end
# Snake from mid to (x, y) — these are all :eq
edits = diag(a, mid_x, x) ++ edits
# Prepend the edit
edits = [edit | edits]
do_backtrack(trace, a, b, d - 1, prev_x, prev_y, edits)
end
defp diag(_a, x, x), do: []
defp diag(a, x_start, x_end) when x_start < x_end do
[{:eq, elem(a, x_start)} | diag(a, x_start + 1, x_end)]
end
end
test/ex_git_objectstore/diff/diff_test.exs +206 −0
@@ -1,0 +1,206 @@
defmodule ExGitObjectstore.Diff.DiffTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Diff
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
describe "diff_trees" do
test "added file" do
repo = RepoHelper.memory_repo()
blob = Blob.from_content("hello\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
{:ok, changes} = Diff.diff_trees(repo, nil, tree_sha)
assert [%{path: "file.txt", status: :added}] = changes
end
test "deleted file" do
repo = RepoHelper.memory_repo()
blob = Blob.from_content("hello\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
{:ok, changes} = Diff.diff_trees(repo, tree_sha, nil)
assert [%{path: "file.txt", status: :deleted}] = changes
end
test "modified file" do
repo = RepoHelper.memory_repo()
blob1 = Blob.from_content("v1\n")
{:ok, blob1_sha} = Object.write(repo, blob1)
blob2 = Blob.from_content("v2\n")
{:ok, blob2_sha} = Object.write(repo, blob2)
tree1 = Tree.new([%{mode: "100644", name: "file.txt", sha: blob1_sha}])
{:ok, tree1_sha} = Object.write(repo, tree1)
tree2 = Tree.new([%{mode: "100644", name: "file.txt", sha: blob2_sha}])
{:ok, tree2_sha} = Object.write(repo, tree2)
{:ok, changes} = Diff.diff_trees(repo, tree1_sha, tree2_sha)
assert [%{path: "file.txt", status: :modified, old_sha: ^blob1_sha, new_sha: ^blob2_sha}] =
changes
end
test "unchanged file not in diff" do
repo = RepoHelper.memory_repo()
blob = Blob.from_content("same\n")
{:ok, blob_sha} = Object.write(repo, blob)
tree = Tree.new([%{mode: "100644", name: "file.txt", sha: blob_sha}])
{:ok, tree_sha} = Object.write(repo, tree)
{:ok, changes} = Diff.diff_trees(repo, tree_sha, tree_sha)
assert changes == []
end
test "nested directory changes" do
repo = RepoHelper.memory_repo()
blob1 = Blob.from_content("v1\n")
{:ok, blob1_sha} = Object.write(repo, blob1)
blob2 = Blob.from_content("v2\n")
{:ok, blob2_sha} = Object.write(repo, blob2)
sub_tree1 = Tree.new([%{mode: "100644", name: "main.ex", sha: blob1_sha}])
{:ok, sub1_sha} = Object.write(repo, sub_tree1)
sub_tree2 = Tree.new([%{mode: "100644", name: "main.ex", sha: blob2_sha}])
{:ok, sub2_sha} = Object.write(repo, sub_tree2)
root1 = Tree.new([%{mode: "40000", name: "src", sha: sub1_sha}])
{:ok, root1_sha} = Object.write(repo, root1)
root2 = Tree.new([%{mode: "40000", name: "src", sha: sub2_sha}])
{:ok, root2_sha} = Object.write(repo, root2)
{:ok, changes} = Diff.diff_trees(repo, root1_sha, root2_sha)
assert [%{path: "src/main.ex", status: :modified}] = changes
end
end
describe "diff_blobs" do
test "line-level diff with hunks" do
repo = RepoHelper.memory_repo()
old = Blob.from_content("line1\nline2\nline3\n")
{:ok, old_sha} = Object.write(repo, old)
new = Blob.from_content("line1\nmodified\nline3\n")
{:ok, new_sha} = Object.write(repo, new)
{:ok, hunks} = Diff.diff_blobs(repo, old_sha, new_sha)
assert length(hunks) >= 1
hunk = List.first(hunks)
types = Enum.map(hunk.lines, &elem(&1, 0))
assert :del in types
assert :add in types
end
test "added file (old_sha nil)" do
repo = RepoHelper.memory_repo()
new = Blob.from_content("new content\n")
{:ok, new_sha} = Object.write(repo, new)
{:ok, hunks} = Diff.diff_blobs(repo, nil, new_sha)
assert length(hunks) >= 1
end
test "deleted file (new_sha nil)" do
repo = RepoHelper.memory_repo()
old = Blob.from_content("old content\n")
{:ok, old_sha} = Object.write(repo, old)
{:ok, hunks} = Diff.diff_blobs(repo, old_sha, nil)
assert length(hunks) >= 1
end
end
describe "format_unified" do
test "produces unified diff format" do
hunks = [
%{
old_start: 1,
old_count: 3,
new_start: 1,
new_count: 3,
lines: [
{:context, "line1"},
{:del, "line2"},
{:add, "modified"},
{:context, "line3"}
]
}
]
output = Diff.format_unified("file.txt", hunks)
assert output =~ "--- a/file.txt"
assert output =~ "+++ b/file.txt"
assert output =~ "@@ -1,3 +1,3 @@"
assert output =~ " line1"
assert output =~ "-line2"
assert output =~ "+modified"
assert output =~ " line3"
end
end
describe "diff_commits" do
test "diff between two commits" do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
# Commit 1
blob1 = Blob.from_content("hello\n")
{:ok, b1_sha} = Object.write(repo, blob1)
tree1 = Tree.new([%{mode: "100644", name: "file.txt", sha: b1_sha}])
{:ok, t1_sha} = Object.write(repo, tree1)
c1 = %Commit{
tree: t1_sha,
parents: [],
author: "A <a@a.com> 1000000000 +0000",
committer: "A <a@a.com> 1000000000 +0000",
message: "first\n"
}
{:ok, c1_sha} = Object.write(repo, c1)
# Commit 2 (modified file)
blob2 = Blob.from_content("world\n")
{:ok, b2_sha} = Object.write(repo, blob2)
tree2 = Tree.new([%{mode: "100644", name: "file.txt", sha: b2_sha}])
{:ok, t2_sha} = Object.write(repo, tree2)
c2 = %Commit{
tree: t2_sha,
parents: [c1_sha],
author: "A <a@a.com> 1000000001 +0000",
committer: "A <a@a.com> 1000000001 +0000",
message: "second\n"
}
{:ok, c2_sha} = Object.write(repo, c2)
{:ok, diffs} = Diff.diff_commits(repo, c1_sha, c2_sha)
assert [%{path: "file.txt", status: :modified, hunks: hunks}] = diffs
assert length(hunks) >= 1
end
end
end
test/ex_git_objectstore/diff/myers_test.exs +105 −0
@@ -1,0 +1,105 @@
defmodule ExGitObjectstore.Diff.MyersTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.Diff.Myers
describe "diff" do
test "identical sequences" do
assert Myers.diff([1, 2, 3], [1, 2, 3]) == [{:eq, 1}, {:eq, 2}, {:eq, 3}]
end
test "empty sequences" do
assert Myers.diff([], []) == []
end
test "insert only" do
result = Myers.diff([], [1, 2, 3])
assert result == [{:ins, 1}, {:ins, 2}, {:ins, 3}]
end
test "delete only" do
result = Myers.diff([1, 2, 3], [])
assert result == [{:del, 1}, {:del, 2}, {:del, 3}]
end
test "simple substitution" do
result = Myers.diff(["a", "b", "c"], ["a", "x", "c"])
assert result == [
{:eq, "a"},
{:del, "b"},
{:ins, "x"},
{:eq, "c"}
]
end
test "insert in middle" do
result = Myers.diff(["a", "c"], ["a", "b", "c"])
assert result == [
{:eq, "a"},
{:ins, "b"},
{:eq, "c"}
]
end
test "delete from middle" do
result = Myers.diff(["a", "b", "c"], ["a", "c"])
assert result == [
{:eq, "a"},
{:del, "b"},
{:eq, "c"}
]
end
test "complex edit" do
a = ["a", "b", "c", "d", "e"]
b = ["a", "x", "c", "y", "e"]
result = Myers.diff(a, b)
# Verify the result transforms a into b
assert apply_edits(a, result) == b
# Verify expected edit types
types = Enum.map(result, &elem(&1, 0))
assert types == [:eq, :del, :ins, :eq, :del, :ins, :eq]
end
end
describe "diff_lines" do
test "line-level diff" do
a = "line1\nline2\nline3\n"
b = "line1\nmodified\nline3\n"
result = Myers.diff_lines(a, b)
# Should show line2 deleted and modified inserted
dels = Enum.filter(result, fn {op, _} -> op == :del end)
ins = Enum.filter(result, fn {op, _} -> op == :ins end)
assert length(dels) >= 1
assert length(ins) >= 1
end
test "adding lines" do
a = "line1\n"
b = "line1\nline2\n"
result = Myers.diff_lines(a, b)
ins = Enum.filter(result, fn {op, _} -> op == :ins end)
assert length(ins) >= 1
end
end
# Helper to verify edits transform a into b
defp apply_edits(original, edits) do
{result, _remaining} =
Enum.reduce(edits, {[], original}, fn
{:eq, val}, {acc, [_h | t]} -> {acc ++ [val], t}
{:del, _val}, {acc, [_h | t]} -> {acc, t}
{:ins, elem}, {acc, rest} -> {acc ++ [elem], rest}
end)
result
end
end