ref:c1a037fb0edb4ac75ac06099ac046e31bf32ab3b

Add three-way merge and merge_base tests (Phase 7)

Implement tree-level three-way merge with recursive subtree support. Add merge_base/3 tests for the existing Walk module function. - New ExGitObjectstore.Merge module with merge_trees/4 and merge_commits/4 - Public API delegates: merge_commits/4, merge_trees/4 - 23 merge tests covering all three-way merge cases - 6 merge_base tests for Walk.merge_base/3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: c1a037fb0edb4ac75ac06099ac046e31bf32ab3b
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-10 13:22
Parents: 726fbbc
4 files changed +876 -1
Type
lib/ex_git_objectstore.ex +23 −1
@@ -6,7 +6,7 @@
All git data (objects, refs, packs) is stored via a pluggable storage backend.
"""
alias ExGitObjectstore.{Object, ObjectResolver, Repo, Ref, Walk}
alias ExGitObjectstore.{Merge, Object, ObjectResolver, Repo, Ref, Walk}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
@type sha :: String.t()
@@ -181,6 +181,28 @@
{:ok, content} -> {:ok, byte_size(content)}
{:error, _} = err -> err
end
end
@doc """
Three-way merge of two commits.
Finds the merge base (LCA), then merges the trees.
Returns `{:ok, merged_tree_sha}` on clean merge, or
`{:error, {:conflicts, [conflict]}}` if there are conflicts.
"""
@spec merge_commits(Repo.t(), sha(), sha(), keyword()) ::
{:ok, sha()} | {:error, term()}
def merge_commits(%Repo{} = repo, ours_sha, theirs_sha, opts \\ []) do
Merge.merge_commits(repo, ours_sha, theirs_sha, opts)
end
@doc """
Three-way merge of tree objects given base, ours, and theirs tree SHAs.
"""
@spec merge_trees(Repo.t(), sha(), sha(), sha()) ::
{:ok, sha()} | {:error, term()}
def merge_trees(%Repo{} = repo, base_tree_sha, ours_tree_sha, theirs_tree_sha) do
Merge.merge_trees(repo, base_tree_sha, ours_tree_sha, theirs_tree_sha)
end
# Walk into nested tree directories by path components
lib/ex_git_objectstore/merge.ex +211 −0
@@ -1,0 +1,211 @@
defmodule ExGitObjectstore.Merge do
@moduledoc """
Three-way tree merge.
Given a base tree (the common ancestor) and two modified trees (ours and theirs),
produces a merged tree or a list of conflicts.
"""
alias ExGitObjectstore.{Object, ObjectResolver, Repo, Walk}
alias ExGitObjectstore.Object.{Commit, Tree}
@type conflict :: %{
path: String.t(),
base: String.t() | nil,
ours: String.t() | nil,
theirs: String.t() | nil
}
@doc """
Merge two commits by finding their merge base and merging the resulting trees.
Returns `{:ok, merged_tree_sha}` on clean merge, or
`{:error, {:conflicts, [conflict()]}}` if there are conflicts.
## Options
* `:author` — author line for the merge commit (not used here, caller creates commit)
"""
@spec merge_commits(Repo.t(), String.t(), String.t(), keyword()) ::
{:ok, String.t()} | {:error, term()}
def merge_commits(%Repo{} = repo, ours_sha, theirs_sha, _opts \\ []) do
with {:ok, base_sha} <- Walk.merge_base(repo, ours_sha, theirs_sha),
{:ok, %Commit{tree: base_tree}} <- ObjectResolver.read(repo, base_sha),
{:ok, %Commit{tree: ours_tree}} <- ObjectResolver.read(repo, ours_sha),
{:ok, %Commit{tree: theirs_tree}} <- ObjectResolver.read(repo, theirs_sha) do
merge_trees(repo, base_tree, ours_tree, theirs_tree)
end
end
@doc """
Three-way merge of tree objects.
Walks all three trees in parallel, applying standard three-way merge rules:
- Entry identical in all three → keep
- Changed in ours only → take ours
- Changed in theirs only → take theirs
- Changed identically in both → take either
- Changed differently in both → conflict (blobs) or recurse (subtrees)
- Added in one side only → add
- Deleted in one side, unchanged in other → delete
- Deleted in one side, changed in other → conflict
Returns `{:ok, merged_tree_sha}` on clean merge, or
`{:error, {:conflicts, [conflict()]}}` if there are conflicts.
"""
@spec merge_trees(Repo.t(), String.t(), String.t(), String.t()) ::
{:ok, String.t()} | {:error, term()}
def merge_trees(%Repo{} = repo, base_tree_sha, ours_tree_sha, theirs_tree_sha) do
with {:ok, base_tree} <- read_tree(repo, base_tree_sha),
{:ok, ours_tree} <- read_tree(repo, ours_tree_sha),
{:ok, theirs_tree} <- read_tree(repo, theirs_tree_sha) do
merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, "")
end
end
# -- Private --
defp read_tree(repo, sha) do
case ObjectResolver.read(repo, sha) do
{:ok, %Tree{} = tree} -> {:ok, tree}
{:ok, _} -> {:error, :not_a_tree}
{:error, _} = err -> err
end
end
# Merge tree entries from all three sides
defp merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, path_prefix) do
base_map = entries_to_map(base_tree.entries)
ours_map = entries_to_map(ours_tree.entries)
theirs_map = entries_to_map(theirs_tree.entries)
# Collect all unique entry names across all three trees
all_names =
[Map.keys(base_map), Map.keys(ours_map), Map.keys(theirs_map)]
|> List.flatten()
|> Enum.uniq()
|> Enum.sort()
# Process each entry
{merged_entries, conflicts} =
Enum.reduce(all_names, {[], []}, fn name, {entries_acc, conflicts_acc} ->
base_entry = Map.get(base_map, name)
ours_entry = Map.get(ours_map, name)
theirs_entry = Map.get(theirs_map, name)
full_path = join_path(path_prefix, name)
case merge_entry(repo, base_entry, ours_entry, theirs_entry, full_path) do
{:keep, entry} ->
{[entry | entries_acc], conflicts_acc}
:delete ->
{entries_acc, conflicts_acc}
{:conflict, conflict} ->
{entries_acc, [conflict | conflicts_acc]}
{:error, _} = err ->
throw(err)
end
end)
if conflicts == [] do
# Write the merged tree
tree = Tree.new(Enum.reverse(merged_entries))
Object.write(repo, tree)
else
{:error, {:conflicts, Enum.reverse(conflicts)}}
end
catch
{:error, _} = err -> err
end
# Three-way merge logic for a single entry
defp merge_entry(repo, base, ours, theirs, path) do
cond do
# All three identical — no change
same_entry?(base, ours) and same_entry?(base, theirs) ->
if base, do: {:keep, base}, else: :delete
# Only ours changed (theirs == base)
same_entry?(base, theirs) and not same_entry?(base, ours) ->
if ours, do: {:keep, ours}, else: :delete
# Only theirs changed (ours == base)
same_entry?(base, ours) and not same_entry?(base, theirs) ->
if theirs, do: {:keep, theirs}, else: :delete
# Both changed identically
same_entry?(ours, theirs) ->
if ours, do: {:keep, ours}, else: :delete
# Both changed differently — check if subtrees can be recursively merged
both_are_trees?(ours, theirs) and (base == nil or is_tree_entry?(base)) ->
merge_subtrees(repo, base, ours, theirs, path)
# Conflict: both modified differently (blobs), or delete vs modify
true ->
{:conflict,
%{
path: path,
base: entry_sha(base),
ours: entry_sha(ours),
theirs: entry_sha(theirs)
}}
end
end
defp merge_subtrees(repo, base, ours, theirs, path) do
# For recursive subtree merge, we need actual tree objects
base_sha = if base, do: base.sha, else: empty_tree_sha(repo)
ours_sha = ours.sha
theirs_sha = theirs.sha
# Use merge_tree_entries directly to keep path prefix context
with {:ok, base_tree} <- read_tree(repo, base_sha),
{:ok, ours_tree} <- read_tree(repo, ours_sha),
{:ok, theirs_tree} <- read_tree(repo, theirs_sha) do
case merge_tree_entries(repo, base_tree, ours_tree, theirs_tree, path) do
{:ok, merged_sha} ->
{:keep, %{mode: ours.mode, name: ours.name, sha: merged_sha}}
{:error, {:conflicts, _}} = err ->
throw(err)
{:error, _} = err ->
err
end
end
end
# Create an empty tree and return its SHA for base-less subtree merges
defp empty_tree_sha(repo) do
tree = Tree.new([])
case Object.write(repo, tree) do
{:ok, sha} -> sha
{:error, _} -> raise "Failed to write empty tree"
end
end
defp entries_to_map(entries) do
Map.new(entries, fn entry -> {entry.name, entry} end)
end
defp same_entry?(nil, nil), do: true
defp same_entry?(nil, _), do: false
defp same_entry?(_, nil), do: false
defp same_entry?(a, b), do: a.sha == b.sha and a.mode == b.mode
defp entry_sha(nil), do: nil
defp entry_sha(entry), do: entry.sha
defp is_tree_entry?(%{mode: "40000"}), do: true
defp is_tree_entry?(_), do: false
defp both_are_trees?(nil, _), do: false
defp both_are_trees?(_, nil), do: false
defp both_are_trees?(a, b), do: is_tree_entry?(a) and is_tree_entry?(b)
defp join_path("", name), do: name
defp join_path(prefix, name), do: prefix <> "/" <> name
end
test/ex_git_objectstore/merge_test.exs +504 −0
@@ -1,0 +1,504 @@
defmodule ExGitObjectstore.MergeTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Merge, Object, Repo}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
# Helper to create a blob and return its SHA
defp write_blob(repo, content) do
{:ok, sha} = Object.write(repo, Blob.from_content(content))
sha
end
# Helper to create a tree from a map of {name => blob_sha}
defp write_tree(repo, entries) do
tree_entries =
Enum.map(entries, fn
{name, {:tree, sha}} -> %{mode: "40000", name: name, sha: sha}
{name, sha} -> %{mode: "100644", name: name, sha: sha}
end)
{:ok, sha} = Object.write(repo, Tree.new(tree_entries))
sha
end
# Helper to create a commit pointing at a tree with given parents
defp write_commit(repo, tree_sha, parents, message \\ "test commit") do
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "Test User <test@test.com> 1700000000 +0000",
committer: "Test User <test@test.com> 1700000000 +0000",
message: message
}
{:ok, sha} = Object.write(repo, commit)
sha
end
describe "merge_trees/4" do
test "identical trees produce same tree" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "hello")
tree_sha = write_tree(repo, %{"file.txt" => blob_a})
assert {:ok, merged_sha} = Merge.merge_trees(repo, tree_sha, tree_sha, tree_sha)
assert merged_sha == tree_sha
end
test "one side adds a file, other side unchanged" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "hello")
blob_b = write_blob(repo, "new file")
base_tree = write_tree(repo, %{"file.txt" => blob_a})
ours_tree = write_tree(repo, %{"file.txt" => blob_a, "new.txt" => blob_b})
theirs_tree = base_tree
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
# Merged tree should contain both files
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
names = Enum.map(entries, & &1.name)
assert "file.txt" in names
assert "new.txt" in names
end
test "theirs adds a file, ours unchanged" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "hello")
blob_b = write_blob(repo, "theirs file")
base_tree = write_tree(repo, %{"file.txt" => blob_a})
ours_tree = base_tree
theirs_tree = write_tree(repo, %{"file.txt" => blob_a, "added.txt" => blob_b})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
names = Enum.map(entries, & &1.name)
assert "file.txt" in names
assert "added.txt" in names
end
test "both sides add different files" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "base")
blob_ours = write_blob(repo, "ours new")
blob_theirs = write_blob(repo, "theirs new")
base_tree = write_tree(repo, %{"base.txt" => blob_base})
ours_tree = write_tree(repo, %{"base.txt" => blob_base, "ours.txt" => blob_ours})
theirs_tree = write_tree(repo, %{"base.txt" => blob_base, "theirs.txt" => blob_theirs})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
names = Enum.map(entries, & &1.name)
assert "base.txt" in names
assert "ours.txt" in names
assert "theirs.txt" in names
end
test "ours modifies a file, theirs unchanged" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "original")
blob_modified = write_blob(repo, "modified by ours")
base_tree = write_tree(repo, %{"file.txt" => blob_base})
ours_tree = write_tree(repo, %{"file.txt" => blob_modified})
theirs_tree = base_tree
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
file_entry = Enum.find(entries, &(&1.name == "file.txt"))
assert file_entry.sha == blob_modified
end
test "theirs modifies a file, ours unchanged" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "original")
blob_modified = write_blob(repo, "modified by theirs")
base_tree = write_tree(repo, %{"file.txt" => blob_base})
ours_tree = base_tree
theirs_tree = write_tree(repo, %{"file.txt" => blob_modified})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
file_entry = Enum.find(entries, &(&1.name == "file.txt"))
assert file_entry.sha == blob_modified
end
test "both modify same file identically — no conflict" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "original")
blob_same = write_blob(repo, "both changed to this")
base_tree = write_tree(repo, %{"file.txt" => blob_base})
ours_tree = write_tree(repo, %{"file.txt" => blob_same})
theirs_tree = write_tree(repo, %{"file.txt" => blob_same})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
file_entry = Enum.find(entries, &(&1.name == "file.txt"))
assert file_entry.sha == blob_same
end
test "both modify same file differently — conflict" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "original")
blob_ours = write_blob(repo, "ours version")
blob_theirs = write_blob(repo, "theirs version")
base_tree = write_tree(repo, %{"file.txt" => blob_base})
ours_tree = write_tree(repo, %{"file.txt" => blob_ours})
theirs_tree = write_tree(repo, %{"file.txt" => blob_theirs})
assert {:error, {:conflicts, conflicts}} =
Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
assert length(conflicts) == 1
[conflict] = conflicts
assert conflict.path == "file.txt"
assert conflict.base == blob_base
assert conflict.ours == blob_ours
assert conflict.theirs == blob_theirs
end
test "ours deletes a file, theirs unchanged — delete" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "file a")
blob_b = write_blob(repo, "file b")
base_tree = write_tree(repo, %{"a.txt" => blob_a, "b.txt" => blob_b})
ours_tree = write_tree(repo, %{"b.txt" => blob_b})
theirs_tree = base_tree
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
names = Enum.map(entries, & &1.name)
refute "a.txt" in names
assert "b.txt" in names
end
test "theirs deletes a file, ours unchanged — delete" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "file a")
blob_b = write_blob(repo, "file b")
base_tree = write_tree(repo, %{"a.txt" => blob_a, "b.txt" => blob_b})
ours_tree = base_tree
theirs_tree = write_tree(repo, %{"b.txt" => blob_b})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
names = Enum.map(entries, & &1.name)
refute "a.txt" in names
assert "b.txt" in names
end
test "delete vs modify — conflict" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "original")
blob_modified = write_blob(repo, "modified")
base_tree = write_tree(repo, %{"file.txt" => blob_base})
ours_tree = write_tree(repo, %{})
theirs_tree = write_tree(repo, %{"file.txt" => blob_modified})
assert {:error, {:conflicts, conflicts}} =
Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
[conflict] = conflicts
assert conflict.path == "file.txt"
assert conflict.ours == nil
assert conflict.theirs == blob_modified
end
test "modify vs delete — conflict" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "original")
blob_modified = write_blob(repo, "modified")
base_tree = write_tree(repo, %{"file.txt" => blob_base})
ours_tree = write_tree(repo, %{"file.txt" => blob_modified})
theirs_tree = write_tree(repo, %{})
assert {:error, {:conflicts, conflicts}} =
Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
[conflict] = conflicts
assert conflict.path == "file.txt"
assert conflict.ours == blob_modified
assert conflict.theirs == nil
end
test "nested subtree merge — non-conflicting" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "file a")
blob_b = write_blob(repo, "file b")
blob_new = write_blob(repo, "new file")
# Base: dir/ contains a.txt and b.txt
sub_base = write_tree(repo, %{"a.txt" => blob_a, "b.txt" => blob_b})
base_tree = write_tree(repo, %{"dir" => {:tree, sub_base}})
# Ours: adds new.txt to dir/
sub_ours = write_tree(repo, %{"a.txt" => blob_a, "b.txt" => blob_b, "new.txt" => blob_new})
ours_tree = write_tree(repo, %{"dir" => {:tree, sub_ours}})
# Theirs: unchanged
theirs_tree = base_tree
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
# The merged dir should contain a.txt, b.txt, and new.txt
{:ok, %Tree{entries: root_entries}} = Object.read(repo, merged_sha)
dir_entry = Enum.find(root_entries, &(&1.name == "dir"))
assert dir_entry.mode == "40000"
{:ok, %Tree{entries: dir_entries}} = Object.read(repo, dir_entry.sha)
names = Enum.map(dir_entries, & &1.name)
assert "a.txt" in names
assert "b.txt" in names
assert "new.txt" in names
end
test "nested subtree merge — both sides modify different files in same dir" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "file a")
blob_b = write_blob(repo, "file b")
blob_a_mod = write_blob(repo, "file a modified")
blob_b_mod = write_blob(repo, "file b modified")
sub_base = write_tree(repo, %{"a.txt" => blob_a, "b.txt" => blob_b})
base_tree = write_tree(repo, %{"dir" => {:tree, sub_base}})
# Ours modifies a.txt
sub_ours = write_tree(repo, %{"a.txt" => blob_a_mod, "b.txt" => blob_b})
ours_tree = write_tree(repo, %{"dir" => {:tree, sub_ours}})
# Theirs modifies b.txt
sub_theirs = write_tree(repo, %{"a.txt" => blob_a, "b.txt" => blob_b_mod})
theirs_tree = write_tree(repo, %{"dir" => {:tree, sub_theirs}})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: root_entries}} = Object.read(repo, merged_sha)
dir_entry = Enum.find(root_entries, &(&1.name == "dir"))
{:ok, %Tree{entries: dir_entries}} = Object.read(repo, dir_entry.sha)
a_entry = Enum.find(dir_entries, &(&1.name == "a.txt"))
b_entry = Enum.find(dir_entries, &(&1.name == "b.txt"))
assert a_entry.sha == blob_a_mod
assert b_entry.sha == blob_b_mod
end
test "nested subtree merge — both sides modify same file in dir — conflict" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "file a")
blob_a_ours = write_blob(repo, "ours version of a")
blob_a_theirs = write_blob(repo, "theirs version of a")
sub_base = write_tree(repo, %{"a.txt" => blob_a})
base_tree = write_tree(repo, %{"dir" => {:tree, sub_base}})
sub_ours = write_tree(repo, %{"a.txt" => blob_a_ours})
ours_tree = write_tree(repo, %{"dir" => {:tree, sub_ours}})
sub_theirs = write_tree(repo, %{"a.txt" => blob_a_theirs})
theirs_tree = write_tree(repo, %{"dir" => {:tree, sub_theirs}})
assert {:error, {:conflicts, conflicts}} =
Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
[conflict] = conflicts
assert conflict.path == "dir/a.txt"
end
test "both sides add same file with same content — no conflict" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "existing")
blob_new = write_blob(repo, "same new content")
base_tree = write_tree(repo, %{"base.txt" => blob_base})
ours_tree = write_tree(repo, %{"base.txt" => blob_base, "new.txt" => blob_new})
theirs_tree = write_tree(repo, %{"base.txt" => blob_base, "new.txt" => blob_new})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
names = Enum.map(entries, & &1.name)
assert "new.txt" in names
end
test "both sides add same filename with different content — conflict" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "existing")
blob_ours = write_blob(repo, "ours new content")
blob_theirs = write_blob(repo, "theirs new content")
base_tree = write_tree(repo, %{"base.txt" => blob_base})
ours_tree = write_tree(repo, %{"base.txt" => blob_base, "new.txt" => blob_ours})
theirs_tree = write_tree(repo, %{"base.txt" => blob_base, "new.txt" => blob_theirs})
assert {:error, {:conflicts, conflicts}} =
Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
[conflict] = conflicts
assert conflict.path == "new.txt"
assert conflict.base == nil
assert conflict.ours == blob_ours
assert conflict.theirs == blob_theirs
end
test "both sides delete same file — clean merge" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "file a")
blob_b = write_blob(repo, "file b")
base_tree = write_tree(repo, %{"a.txt" => blob_a, "b.txt" => blob_b})
ours_tree = write_tree(repo, %{"b.txt" => blob_b})
theirs_tree = write_tree(repo, %{"b.txt" => blob_b})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
names = Enum.map(entries, & &1.name)
refute "a.txt" in names
assert "b.txt" in names
end
test "multiple conflicts reported" do
repo = RepoHelper.memory_repo()
blob_base1 = write_blob(repo, "original 1")
blob_base2 = write_blob(repo, "original 2")
blob_ours1 = write_blob(repo, "ours 1")
blob_ours2 = write_blob(repo, "ours 2")
blob_theirs1 = write_blob(repo, "theirs 1")
blob_theirs2 = write_blob(repo, "theirs 2")
base_tree = write_tree(repo, %{"a.txt" => blob_base1, "b.txt" => blob_base2})
ours_tree = write_tree(repo, %{"a.txt" => blob_ours1, "b.txt" => blob_ours2})
theirs_tree = write_tree(repo, %{"a.txt" => blob_theirs1, "b.txt" => blob_theirs2})
assert {:error, {:conflicts, conflicts}} =
Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
assert length(conflicts) == 2
paths = Enum.map(conflicts, & &1.path) |> Enum.sort()
assert paths == ["a.txt", "b.txt"]
end
test "empty base tree — both add files non-conflicting" do
repo = RepoHelper.memory_repo()
blob_ours = write_blob(repo, "ours file")
blob_theirs = write_blob(repo, "theirs file")
base_tree = write_tree(repo, %{})
ours_tree = write_tree(repo, %{"ours.txt" => blob_ours})
theirs_tree = write_tree(repo, %{"theirs.txt" => blob_theirs})
assert {:ok, merged_sha} = Merge.merge_trees(repo, base_tree, ours_tree, theirs_tree)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_sha)
names = Enum.map(entries, & &1.name)
assert "ours.txt" in names
assert "theirs.txt" in names
end
end
describe "merge_commits/4" do
test "clean merge of diverged branches" do
repo = RepoHelper.memory_repo()
# Create base commit
blob_base = write_blob(repo, "base content")
base_tree = write_tree(repo, %{"file.txt" => blob_base})
base_commit = write_commit(repo, base_tree, [], "base commit")
# Ours: add a file
blob_ours = write_blob(repo, "ours new")
ours_tree = write_tree(repo, %{"file.txt" => blob_base, "ours.txt" => blob_ours})
ours_commit = write_commit(repo, ours_tree, [base_commit], "ours commit")
# Theirs: add a different file
blob_theirs = write_blob(repo, "theirs new")
theirs_tree = write_tree(repo, %{"file.txt" => blob_base, "theirs.txt" => blob_theirs})
theirs_commit = write_commit(repo, theirs_tree, [base_commit], "theirs commit")
assert {:ok, merged_tree_sha} = Merge.merge_commits(repo, ours_commit, theirs_commit)
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_tree_sha)
names = Enum.map(entries, & &1.name)
assert "file.txt" in names
assert "ours.txt" in names
assert "theirs.txt" in names
end
test "conflicting merge of diverged branches" do
repo = RepoHelper.memory_repo()
blob_base = write_blob(repo, "base content")
base_tree = write_tree(repo, %{"file.txt" => blob_base})
base_commit = write_commit(repo, base_tree, [], "base commit")
blob_ours = write_blob(repo, "ours version")
ours_tree = write_tree(repo, %{"file.txt" => blob_ours})
ours_commit = write_commit(repo, ours_tree, [base_commit], "ours commit")
blob_theirs = write_blob(repo, "theirs version")
theirs_tree = write_tree(repo, %{"file.txt" => blob_theirs})
theirs_commit = write_commit(repo, theirs_tree, [base_commit], "theirs commit")
assert {:error, {:conflicts, conflicts}} =
Merge.merge_commits(repo, ours_commit, theirs_commit)
assert length(conflicts) == 1
[conflict] = conflicts
assert conflict.path == "file.txt"
end
test "no merge base — unrelated histories" do
repo = RepoHelper.memory_repo()
blob_a = write_blob(repo, "a content")
tree_a = write_tree(repo, %{"a.txt" => blob_a})
commit_a = write_commit(repo, tree_a, [], "commit a")
blob_b = write_blob(repo, "b content")
tree_b = write_tree(repo, %{"b.txt" => blob_b})
commit_b = write_commit(repo, tree_b, [], "commit b")
assert {:error, :no_merge_base} = Merge.merge_commits(repo, commit_a, commit_b)
end
end
end
test/ex_git_objectstore/walk/merge_base_test.exs +138 −0
@@ -1,0 +1,138 @@
defmodule ExGitObjectstore.Walk.MergeBaseTest do
use ExUnit.Case, async: true
alias ExGitObjectstore.{Object, Walk}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
defp write_blob(repo, content) do
{:ok, sha} = Object.write(repo, Blob.from_content(content))
sha
end
defp write_tree(repo, entries) do
tree_entries =
Enum.map(entries, fn {name, sha} ->
%{mode: "100644", name: name, sha: sha}
end)
{:ok, sha} = Object.write(repo, Tree.new(tree_entries))
sha
end
defp write_commit(repo, tree_sha, parents, timestamp \\ 1_700_000_000) do
commit = %Commit{
tree: tree_sha,
parents: parents,
author: "Test <test@test.com> #{timestamp} +0000",
committer: "Test <test@test.com> #{timestamp} +0000",
message: "commit at #{timestamp}"
}
{:ok, sha} = Object.write(repo, commit)
sha
end
describe "merge_base/3" do
test "linear history — merge base is the older commit" do
repo = RepoHelper.memory_repo()
blob = write_blob(repo, "content")
tree = write_tree(repo, %{"f.txt" => blob})
c1 = write_commit(repo, tree, [], 1_700_000_000)
c2 = write_commit(repo, tree, [c1], 1_700_001_000)
c3 = write_commit(repo, tree, [c2], 1_700_002_000)
assert {:ok, base} = Walk.merge_base(repo, c2, c3)
assert base == c2
end
test "simple fork — merge base is common ancestor" do
repo = RepoHelper.memory_repo()
blob = write_blob(repo, "content")
tree = write_tree(repo, %{"f.txt" => blob})
# c1
# / \
# c2 c3
c1 = write_commit(repo, tree, [], 1_700_000_000)
c2 = write_commit(repo, tree, [c1], 1_700_001_000)
c3 = write_commit(repo, tree, [c1], 1_700_002_000)
assert {:ok, base} = Walk.merge_base(repo, c2, c3)
assert base == c1
end
test "deeper fork with multiple commits" do
repo = RepoHelper.memory_repo()
blob = write_blob(repo, "content")
tree = write_tree(repo, %{"f.txt" => blob})
# c1
# / \
# c2 c4
# | |
# c3 c5
c1 = write_commit(repo, tree, [], 1_700_000_000)
c2 = write_commit(repo, tree, [c1], 1_700_001_000)
c3 = write_commit(repo, tree, [c2], 1_700_002_000)
c4 = write_commit(repo, tree, [c1], 1_700_003_000)
c5 = write_commit(repo, tree, [c4], 1_700_004_000)
assert {:ok, base} = Walk.merge_base(repo, c3, c5)
assert base == c1
end
test "same commit — merge base is itself" do
repo = RepoHelper.memory_repo()
blob = write_blob(repo, "content")
tree = write_tree(repo, %{"f.txt" => blob})
c1 = write_commit(repo, tree, [], 1_700_000_000)
assert {:ok, base} = Walk.merge_base(repo, c1, c1)
assert base == c1
end
test "no common ancestor — unrelated histories" do
repo = RepoHelper.memory_repo()
blob = write_blob(repo, "content")
tree = write_tree(repo, %{"f.txt" => blob})
c1 = write_commit(repo, tree, [], 1_700_000_000)
c2 = write_commit(repo, tree, [], 1_700_001_000)
assert {:error, :no_merge_base} = Walk.merge_base(repo, c1, c2)
end
test "diamond graph — merge base is the fork point" do
repo = RepoHelper.memory_repo()
blob = write_blob(repo, "content")
tree = write_tree(repo, %{"f.txt" => blob})
# c1
# / \
# c2 c3
# \ /
# c4
c1 = write_commit(repo, tree, [], 1_700_000_000)
c2 = write_commit(repo, tree, [c1], 1_700_001_000)
c3 = write_commit(repo, tree, [c1], 1_700_002_000)
c4 = write_commit(repo, tree, [c2, c3], 1_700_003_000)
# merge_base of c4 and c3 should be c3 (c3 is an ancestor of c4)
assert {:ok, base} = Walk.merge_base(repo, c4, c3)
assert base == c3
# merge_base of c4 and c2 should be c2
assert {:ok, base} = Walk.merge_base(repo, c4, c2)
assert base == c2
end
end
end