@@ -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