@@ -1,0 +1,974 @@
# 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.CommitTreeAndMergeTest do
@moduledoc """
Tests for the full merge/rebase toolkit: write_tree/2, commit_tree/3,
merge_branches/4, squash_merge/4, cherry_pick/3, rebase_commits/4,
merge_base/3, ancestor?/3, update_branch/4, format_identity/1,
parse_identity/1.
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.Object
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
alias ExGitObjectstore.Test.RepoHelper
defp alice, do: %{name: "Alice", email: "alice@example.com", when: ~U[2026-04-17 21:00:00Z]}
defp bob, do: %{name: "Bob", email: "bob@example.com", when: ~U[2026-04-17 21:30:00Z]}
defp write_blob(repo, content) do
{:ok, sha} = Object.write(repo, Blob.from_content(content))
sha
end
defp setup_repo_with_tree(files) do
repo = RepoHelper.memory_repo()
entries =
Enum.map(files, fn {name, content} ->
%{mode: "100644", name: name, sha: write_blob(repo, content)}
end)
{:ok, tree_sha} = ExGitObjectstore.write_tree(repo, entries)
{repo, tree_sha}
end
# -- write_tree/2 ---------------------------------------------------
describe "write_tree/2" do
test "writes a tree from entries and returns a SHA readable as a Tree" do
{repo, tree_sha} = setup_repo_with_tree([{"README.md", "# hi"}])
assert is_binary(tree_sha) and String.length(tree_sha) == 40
assert {:ok, %Tree{entries: [entry]}} = Object.read(repo, tree_sha)
assert entry.name == "README.md"
assert entry.mode == "100644"
end
test "empty tree is valid" do
repo = RepoHelper.memory_repo()
assert {:ok, tree_sha} = ExGitObjectstore.write_tree(repo, [])
assert {:ok, %Tree{entries: []}} = Object.read(repo, tree_sha)
end
test "identical entries produce identical SHAs (deterministic)" do
{repo1, sha1} = setup_repo_with_tree([{"a.txt", "x"}, {"b.txt", "y"}])
{_repo2, sha2} = setup_repo_with_tree([{"a.txt", "x"}, {"b.txt", "y"}])
assert sha1 == sha2
# And in any input order — Tree.new sorts entries
{_, sha3} = setup_repo_with_tree([{"b.txt", "y"}, {"a.txt", "x"}])
assert sha1 == sha3
_ = repo1
end
end
# -- commit_tree/3 --------------------------------------------------
describe "commit_tree/3" do
test "creates a root commit (no parents) and round-trips" do
{repo, tree_sha} = setup_repo_with_tree([{"file.txt", "hello"}])
assert {:ok, commit_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
author: alice(),
message: "Initial commit"
)
assert {:ok, %Commit{} = commit} = Object.read(repo, commit_sha)
assert commit.tree == tree_sha
assert commit.parents == []
assert commit.message == "Initial commit\n"
assert commit.author =~ "Alice <alice@example.com>"
assert commit.author =~ "+0000"
# Committer defaults to author when not supplied
assert commit.committer == commit.author
end
test "creates a commit with parents recorded" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
{:ok, p1} =
ExGitObjectstore.commit_tree(repo, tree_sha,
author: alice(),
message: "parent 1"
)
{:ok, p2} =
ExGitObjectstore.commit_tree(repo, tree_sha,
author: alice(),
message: "parent 2"
)
assert {:ok, merge_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [p1, p2],
author: alice(),
message: "merge"
)
assert {:ok, %Commit{parents: [^p1, ^p2]}} = Object.read(repo, merge_sha)
end
test "separate committer is preserved" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
{:ok, commit_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
author: alice(),
committer: bob(),
message: "authored by Alice, committed by Bob"
)
{:ok, %Commit{} = commit} = Object.read(repo, commit_sha)
assert commit.author =~ "Alice <alice@example.com>"
assert commit.committer =~ "Bob <bob@example.com>"
refute commit.author == commit.committer
end
test "adds trailing newline to message if missing" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
{:ok, sha_no_nl} =
ExGitObjectstore.commit_tree(repo, tree_sha,
author: alice(),
message: "no trailing newline"
)
{:ok, sha_with_nl} =
ExGitObjectstore.commit_tree(repo, tree_sha,
author: alice(),
message: "no trailing newline\n"
)
assert sha_no_nl == sha_with_nl
end
test "rejects a missing tree SHA" do
repo = RepoHelper.memory_repo()
bogus = String.duplicate("0", 40)
assert {:error, {:missing_tree, ^bogus}} =
ExGitObjectstore.commit_tree(repo, bogus,
author: alice(),
message: "x"
)
end
test "rejects a non-tree SHA" do
repo = RepoHelper.memory_repo()
blob_sha = write_blob(repo, "not a tree")
assert {:error, {:not_a_tree, ^blob_sha}} =
ExGitObjectstore.commit_tree(repo, blob_sha,
author: alice(),
message: "x"
)
end
test "rejects a missing parent SHA" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
bogus = String.duplicate("f", 40)
assert {:error, {:missing_parent, ^bogus}} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [bogus],
author: alice(),
message: "x"
)
end
test "rejects a parent that isn't a commit" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
# Use the tree SHA as a bogus parent — exists but wrong type
assert {:error, {:not_a_commit_parent, ^tree_sha}} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [tree_sha],
author: alice(),
message: "x"
)
end
test "returns typed error for missing :author option" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
assert {:error, {:missing_option, :author}} =
ExGitObjectstore.commit_tree(repo, tree_sha, message: "no author")
end
test "returns typed error for missing :message option" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
assert {:error, {:missing_option, :message}} =
ExGitObjectstore.commit_tree(repo, tree_sha, author: alice())
end
test "gpgsig round-trips through encode/decode" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
signature =
"-----BEGIN PGP SIGNATURE-----\n" <>
"iHUEABYIAB0WIQTq\n" <>
"FAKEFAKEFAKE\n" <>
"-----END PGP SIGNATURE-----"
{:ok, commit_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
author: alice(),
message: "signed commit",
gpgsig: signature
)
{:ok, %Commit{} = commit} = Object.read(repo, commit_sha)
assert commit.gpgsig == signature
end
test "formats non-UTC timezone offsets correctly" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
# +05:30 (India) — synthesize a DateTime with a non-UTC offset without
# needing a timezone database in the test environment.
dt = %DateTime{
~U[2026-04-17 12:00:00Z]
| utc_offset: 19_800,
std_offset: 0,
zone_abbr: "IST",
time_zone: "Asia/Kolkata"
}
identity = %{name: "N", email: "n@e", when: dt}
{:ok, sha} =
ExGitObjectstore.commit_tree(repo, tree_sha, author: identity, message: "tz test")
{:ok, commit} = Object.read(repo, sha)
assert commit.author =~ " +0530"
end
test "formats negative timezone offsets correctly" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
# -08:00 (US Pacific)
dt = %DateTime{
~U[2026-04-17 12:00:00Z]
| utc_offset: -28_800,
std_offset: 0,
zone_abbr: "PST",
time_zone: "America/Los_Angeles"
}
identity = %{name: "N", email: "n@e", when: dt}
{:ok, sha} =
ExGitObjectstore.commit_tree(repo, tree_sha, author: identity, message: "tz test")
{:ok, commit} = Object.read(repo, sha)
assert commit.author =~ " -0800"
end
end
# -- merge_branches/4 ----------------------------------------------
describe "merge_branches/4" do
setup do
repo = RepoHelper.memory_repo()
# Common ancestor with README.md
readme1 = write_blob(repo, "# v1")
{:ok, base_tree} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme1}])
{:ok, base_commit} =
ExGitObjectstore.commit_tree(repo, base_tree, author: alice(), message: "base")
:ok = ExGitObjectstore.create_branch(repo, "main", base_commit)
:ok = ExGitObjectstore.create_branch(repo, "feature", base_commit)
%{repo: repo, base_commit: base_commit}
end
test "divergent branches with non-overlapping changes produce a merge commit",
%{repo: repo, base_commit: base_commit} do
readme1 = write_blob(repo, "# v1")
a_blob = write_blob(repo, "A")
b_blob = write_blob(repo, "B")
# main adds a.txt
{:ok, main_tree} =
ExGitObjectstore.write_tree(repo, [
%{mode: "100644", name: "README.md", sha: readme1},
%{mode: "100644", name: "a.txt", sha: a_blob}
])
{:ok, main_commit} =
ExGitObjectstore.commit_tree(repo, main_tree,
parents: [base_commit],
author: alice(),
message: "add a"
)
:ok = ExGitObjectstore.create_branch(repo, "main", main_commit)
# feature adds b.txt
{:ok, feat_tree} =
ExGitObjectstore.write_tree(repo, [
%{mode: "100644", name: "README.md", sha: readme1},
%{mode: "100644", name: "b.txt", sha: b_blob}
])
{:ok, feat_commit} =
ExGitObjectstore.commit_tree(repo, feat_tree,
parents: [base_commit],
author: alice(),
message: "add b"
)
:ok = ExGitObjectstore.create_branch(repo, "feature", feat_commit)
assert {:ok, merge_sha} =
ExGitObjectstore.merge_branches(repo, "main", "feature", author: alice())
{:ok, merge_commit} = Object.read(repo, merge_sha)
assert merge_commit.parents == [main_commit, feat_commit]
assert merge_commit.message == "Merge feature into main\n"
# Merged tree has both files
{:ok, %Tree{entries: entries}} = Object.read(repo, merge_commit.tree)
names = Enum.map(entries, & &1.name)
assert "a.txt" in names
assert "b.txt" in names
assert "README.md" in names
end
test "conflict returns :conflicts and does not write a commit",
%{repo: repo, base_commit: base_commit} do
# Both branches modify README.md differently
readme_main = write_blob(repo, "# main version")
readme_feat = write_blob(repo, "# feature version")
{:ok, main_tree} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme_main}])
{:ok, main_commit} =
ExGitObjectstore.commit_tree(repo, main_tree,
parents: [base_commit],
author: alice(),
message: "main"
)
:ok = ExGitObjectstore.create_branch(repo, "main", main_commit)
{:ok, feat_tree} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme_feat}])
{:ok, feat_commit} =
ExGitObjectstore.commit_tree(repo, feat_tree,
parents: [base_commit],
author: alice(),
message: "feat"
)
:ok = ExGitObjectstore.create_branch(repo, "feature", feat_commit)
assert {:error, {:conflicts, conflicts}} =
ExGitObjectstore.merge_branches(repo, "main", "feature", author: alice())
assert [%{path: "README.md"} | _] = conflicts
# Branch refs unchanged
assert {:ok, ^main_commit} = ExGitObjectstore.resolve(repo, "main")
assert {:ok, ^feat_commit} = ExGitObjectstore.resolve(repo, "feature")
end
test "custom message overrides default, producing a real two-parent merge",
%{repo: repo, base_commit: base_commit} do
readme1 = write_blob(repo, "# v1")
a_blob = write_blob(repo, "A")
b_blob = write_blob(repo, "B")
# Divergent branches: each adds a different file.
{:ok, main_tree} =
ExGitObjectstore.write_tree(repo, [
%{mode: "100644", name: "README.md", sha: readme1},
%{mode: "100644", name: "a.txt", sha: a_blob}
])
{:ok, main_commit} =
ExGitObjectstore.commit_tree(repo, main_tree,
parents: [base_commit],
author: alice(),
message: "add a"
)
:ok = ExGitObjectstore.create_branch(repo, "main", main_commit)
{:ok, feat_tree} =
ExGitObjectstore.write_tree(repo, [
%{mode: "100644", name: "README.md", sha: readme1},
%{mode: "100644", name: "b.txt", sha: b_blob}
])
{:ok, feat_commit} =
ExGitObjectstore.commit_tree(repo, feat_tree,
parents: [base_commit],
author: alice(),
message: "add b"
)
:ok = ExGitObjectstore.create_branch(repo, "feature", feat_commit)
{:ok, merge_sha} =
ExGitObjectstore.merge_branches(repo, "main", "feature",
author: alice(),
message: "custom merge message\n"
)
{:ok, %Commit{message: msg, parents: parents, tree: merged_tree_sha}} =
Object.read(repo, merge_sha)
assert msg == "custom merge message\n"
assert parents == [main_commit, feat_commit]
# The merged tree is non-trivially merged — contains files from both sides.
{:ok, %Tree{entries: entries}} = Object.read(repo, merged_tree_sha)
names = Enum.map(entries, & &1.name)
assert "a.txt" in names
assert "b.txt" in names
end
test "returns typed error for missing :author option", %{repo: repo} do
assert {:error, {:missing_option, :author}} =
ExGitObjectstore.merge_branches(repo, "main", "feature", [])
end
end
# -- format_identity/1 & parse_identity/1 ---------------------------
describe "format_identity/1 and parse_identity/1" do
test "formats identity map to wire format" do
assert ExGitObjectstore.format_identity(%{
name: "Alice",
email: "a@x.com",
when: ~U[2026-01-01 00:00:00Z]
}) == "Alice <a@x.com> 1767225600 +0000"
end
test "passes raw wire-format string through unchanged" do
raw = "Bob <b@x.com> 1700000000 -0500"
assert ExGitObjectstore.format_identity(raw) == raw
end
test "parses wire format back into identity" do
{:ok, id} = ExGitObjectstore.parse_identity("Alice <a@x.com> 1767225600 +0000")
assert id.name == "Alice"
assert id.email == "a@x.com"
assert %DateTime{} = id.when
assert DateTime.to_unix(id.when) == 1_767_225_600
assert id.when.utc_offset == 0
end
test "parses negative timezone offset" do
{:ok, id} = ExGitObjectstore.parse_identity("Bob <b@x.com> 1700000000 -0530")
assert id.when.utc_offset == -(5 * 3600 + 30 * 60)
end
test "round-trips format → parse for UTC" do
original = %{name: "Alice", email: "a@x.com", when: ~U[2026-04-18 12:34:56Z]}
formatted = ExGitObjectstore.format_identity(original)
{:ok, parsed} = ExGitObjectstore.parse_identity(formatted)
assert parsed.name == original.name
assert parsed.email == original.email
assert DateTime.to_unix(parsed.when) == DateTime.to_unix(original.when)
assert parsed.when.utc_offset == 0
end
test "rejects malformed identity strings" do
assert {:error, :invalid_identity} = ExGitObjectstore.parse_identity("garbage")
assert {:error, :invalid_identity} = ExGitObjectstore.parse_identity("Alice <a@x.com>")
assert {:error, :invalid_identity} =
ExGitObjectstore.parse_identity("Alice <a@x.com> notanumber +0000")
end
end
# -- commit_tree/3 with raw-string author ---------------------------
describe "commit_tree/3 raw-string identities" do
test "accepts a raw author string verbatim" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
raw = "Preserved Author <p@x.com> 1700000000 -0800"
{:ok, sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
author: raw,
committer: alice(),
message: "cherry-picked"
)
{:ok, %Commit{} = commit} = Object.read(repo, sha)
assert commit.author == raw
# Committer comes from the identity map — formatted separately.
assert commit.committer =~ "Alice <alice@example.com>"
end
test "identity-map and equivalent raw-string produce identical commits" do
{repo, tree_sha} = setup_repo_with_tree([{"a", "1"}])
alice_map = %{name: "Alice", email: "alice@example.com", when: ~U[2026-04-18 12:00:00Z]}
# Derive the raw form via the public formatter to guarantee byte-equivalence.
alice_raw = ExGitObjectstore.format_identity(alice_map)
{:ok, sha_map} =
ExGitObjectstore.commit_tree(repo, tree_sha, author: alice_map, message: "m")
{:ok, sha_raw} =
ExGitObjectstore.commit_tree(repo, tree_sha, author: alice_raw, message: "m")
assert sha_map == sha_raw
end
end
# -- merge_base/3 & ancestor?/3 ----------------------------------
describe "merge_base/3 and ancestor?/3" do
setup do
repo = RepoHelper.memory_repo()
{:ok, tree_sha} = ExGitObjectstore.write_tree(repo, [])
{:ok, root} = ExGitObjectstore.commit_tree(repo, tree_sha, author: alice(), message: "root")
{:ok, child} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [root],
author: alice(),
message: "child"
)
{:ok, grandchild} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [child],
author: alice(),
message: "grandchild"
)
{:ok, sibling} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [root],
author: alice(),
message: "sibling"
)
%{repo: repo, root: root, child: child, grandchild: grandchild, sibling: sibling}
end
test "merge_base of sibling branches is the common root",
%{repo: repo, root: root, grandchild: gc, sibling: s} do
assert {:ok, ^root} = ExGitObjectstore.merge_base(repo, gc, s)
end
test "merge_base of ancestor/descendant is the ancestor",
%{repo: repo, root: root, grandchild: gc} do
assert {:ok, ^root} = ExGitObjectstore.merge_base(repo, root, gc)
end
test "merge_base of identical commits is that commit", %{repo: repo, child: c} do
assert {:ok, ^c} = ExGitObjectstore.merge_base(repo, c, c)
end
test "ancestor? true for ancestor/descendant",
%{repo: repo, root: root, grandchild: gc} do
assert {:ok, true} = ExGitObjectstore.ancestor?(repo, root, gc)
end
test "ancestor? true for self (reflexive)",
%{repo: repo, child: c} do
assert {:ok, true} = ExGitObjectstore.ancestor?(repo, c, c)
end
test "ancestor? false for divergent branches",
%{repo: repo, grandchild: gc, sibling: s} do
assert {:ok, false} = ExGitObjectstore.ancestor?(repo, gc, s)
assert {:ok, false} = ExGitObjectstore.ancestor?(repo, s, gc)
end
test "ancestor? false when descendant is older",
%{repo: repo, root: root, child: c} do
assert {:ok, false} = ExGitObjectstore.ancestor?(repo, c, root)
end
end
# -- update_branch/4 ------------------------------------------------
describe "update_branch/4" do
setup do
repo = RepoHelper.memory_repo()
{:ok, tree_sha} = ExGitObjectstore.write_tree(repo, [])
{:ok, a} = ExGitObjectstore.commit_tree(repo, tree_sha, author: alice(), message: "a")
{:ok, b} = ExGitObjectstore.commit_tree(repo, tree_sha, author: alice(), message: "b")
%{repo: repo, a: a, b: b}
end
test "unconditional update creates a new branch", %{repo: repo, a: a} do
assert :ok = ExGitObjectstore.update_branch(repo, "new-branch", a)
assert {:ok, ^a} = ExGitObjectstore.resolve(repo, "new-branch")
end
test "unconditional update overwrites existing ref", %{repo: repo, a: a, b: b} do
:ok = ExGitObjectstore.update_branch(repo, "b1", a)
:ok = ExGitObjectstore.update_branch(repo, "b1", b)
assert {:ok, ^b} = ExGitObjectstore.resolve(repo, "b1")
end
test "CAS succeeds when expected_old_sha matches current ref",
%{repo: repo, a: a, b: b} do
:ok = ExGitObjectstore.update_branch(repo, "cas", a)
assert :ok = ExGitObjectstore.update_branch(repo, "cas", b, a)
assert {:ok, ^b} = ExGitObjectstore.resolve(repo, "cas")
end
test "CAS fails with :cas_failed when expected_old_sha doesn't match",
%{repo: repo, a: a, b: b} do
:ok = ExGitObjectstore.update_branch(repo, "cas2", a)
# Another writer moves the branch to b
:ok = ExGitObjectstore.update_branch(repo, "cas2", b)
# Our CAS expecting a should now fail
bogus = String.duplicate("0", 40)
assert {:error, :cas_failed} = ExGitObjectstore.update_branch(repo, "cas2", bogus, a)
# Ref unchanged
assert {:ok, ^b} = ExGitObjectstore.resolve(repo, "cas2")
end
end
# -- cherry_pick/3 --------------------------------------------------
describe "cherry_pick/3" do
setup do
repo = RepoHelper.memory_repo()
# base: README only
readme = write_blob(repo, "# v1")
{:ok, base_tree} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme}])
{:ok, base} =
ExGitObjectstore.commit_tree(repo, base_tree, author: alice(), message: "base")
# feature-commit: adds a.txt on top of base
a_blob = write_blob(repo, "A")
{:ok, feat_tree} =
ExGitObjectstore.write_tree(repo, [
%{mode: "100644", name: "README.md", sha: readme},
%{mode: "100644", name: "a.txt", sha: a_blob}
])
{:ok, feat} =
ExGitObjectstore.commit_tree(repo, feat_tree,
parents: [base],
author: alice(),
message: "add a"
)
# divergent: main adds b.txt on top of base
b_blob = write_blob(repo, "B")
{:ok, main_tree} =
ExGitObjectstore.write_tree(repo, [
%{mode: "100644", name: "README.md", sha: readme},
%{mode: "100644", name: "b.txt", sha: b_blob}
])
{:ok, main_tip} =
ExGitObjectstore.commit_tree(repo, main_tree,
parents: [base],
author: alice(),
message: "add b"
)
%{repo: repo, base: base, feat: feat, main_tip: main_tip}
end
test "cherry-picks a non-conflicting commit onto a new parent",
%{repo: repo, feat: feat, main_tip: main_tip} do
{:ok, new_sha} =
ExGitObjectstore.cherry_pick(repo, feat, onto: main_tip, committer: bob())
{:ok, %Commit{} = new_commit} = Object.read(repo, new_sha)
assert new_commit.parents == [main_tip]
# Tree contains both a.txt (from feat) and b.txt (from main_tip)
{:ok, %Tree{entries: entries}} = Object.read(repo, new_commit.tree)
names = Enum.map(entries, & &1.name)
assert "a.txt" in names
assert "b.txt" in names
end
test "preserves the original commit's author by default",
%{repo: repo, feat: feat, main_tip: main_tip} do
{:ok, %Commit{author: feat_author}} = Object.read(repo, feat)
{:ok, new_sha} =
ExGitObjectstore.cherry_pick(repo, feat, onto: main_tip, committer: bob())
{:ok, %Commit{author: new_author, committer: new_committer}} = Object.read(repo, new_sha)
assert new_author == feat_author
assert new_committer =~ "Bob <bob@example.com>"
end
test "preserves the original commit's message by default",
%{repo: repo, feat: feat, main_tip: main_tip} do
{:ok, new_sha} =
ExGitObjectstore.cherry_pick(repo, feat, onto: main_tip, committer: bob())
{:ok, %Commit{message: msg}} = Object.read(repo, new_sha)
assert msg == "add a\n"
end
test ":author and :message overrides are honored",
%{repo: repo, feat: feat, main_tip: main_tip} do
{:ok, new_sha} =
ExGitObjectstore.cherry_pick(repo, feat,
onto: main_tip,
committer: bob(),
author: alice(),
message: "overridden"
)
{:ok, %Commit{author: a, message: m}} = Object.read(repo, new_sha)
assert a =~ "Alice <alice@example.com>"
assert m == "overridden\n"
end
test "returns conflict when cherry-pick would conflict",
%{repo: repo, base: base} do
# Create two divergent edits to README.md and attempt to replay one on top of the other
readme_x = write_blob(repo, "# x")
readme_y = write_blob(repo, "# y")
{:ok, tx} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme_x}])
{:ok, ty} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme_y}])
{:ok, cx} =
ExGitObjectstore.commit_tree(repo, tx, parents: [base], author: alice(), message: "x")
{:ok, cy} =
ExGitObjectstore.commit_tree(repo, ty, parents: [base], author: alice(), message: "y")
assert {:error, {:conflicts, conflicts}} =
ExGitObjectstore.cherry_pick(repo, cx, onto: cy, committer: bob())
assert [%{path: "README.md"} | _] = conflicts
end
test "rejects root commits (no parent)", %{repo: repo, main_tip: main_tip} do
{:ok, tree_sha} = ExGitObjectstore.write_tree(repo, [])
{:ok, root} = ExGitObjectstore.commit_tree(repo, tree_sha, author: alice(), message: "r")
assert {:error, :cannot_cherry_pick_root} =
ExGitObjectstore.cherry_pick(repo, root, onto: main_tip, committer: bob())
end
test "rejects merge commits without mainline selection",
%{repo: repo, feat: feat, main_tip: main_tip} do
# Make a two-parent merge commit
{:ok, merged_tree} = ExGitObjectstore.merge_commits(repo, main_tip, feat)
{:ok, merge_sha} =
ExGitObjectstore.commit_tree(repo, merged_tree,
parents: [main_tip, feat],
author: alice(),
message: "merge"
)
assert {:error, {:merge_commit_needs_mainline, ^merge_sha}} =
ExGitObjectstore.cherry_pick(repo, merge_sha, onto: main_tip, committer: bob())
end
test "missing :onto or :committer returns typed error", %{repo: repo, feat: feat} do
assert {:error, {:missing_option, :onto}} =
ExGitObjectstore.cherry_pick(repo, feat, committer: bob())
assert {:error, {:missing_option, :committer}} =
ExGitObjectstore.cherry_pick(repo, feat, onto: feat)
end
end
# -- rebase_commits/4 -----------------------------------------------
describe "rebase_commits/4" do
setup do
repo = RepoHelper.memory_repo()
{:ok, empty} = ExGitObjectstore.write_tree(repo, [])
{:ok, base} = ExGitObjectstore.commit_tree(repo, empty, author: alice(), message: "base")
# Three sequential commits each adding a distinct file
{:ok, c1} = commit_add_file(repo, base, "a", "A", "add a")
{:ok, c2} = commit_add_file(repo, c1, "b", "B", "add b")
{:ok, c3} = commit_add_file(repo, c2, "c", "C", "add c")
# Divergent base: new root with d.txt
{:ok, new_base} = commit_add_file(repo, base, "d", "D", "add d")
%{repo: repo, base: base, new_base: new_base, chain: [c1, c2, c3]}
end
test "empty list returns onto unchanged",
%{repo: repo, new_base: new_base} do
assert {:ok, ^new_base} =
ExGitObjectstore.rebase_commits(repo, [], new_base, committer: bob())
end
test "replays a chain cleanly onto a divergent base",
%{repo: repo, new_base: new_base, chain: chain} do
assert {:ok, new_tip} =
ExGitObjectstore.rebase_commits(repo, chain, new_base, committer: bob())
# new_tip's history: new_tip → c3' → c2' → c1' → new_base
{:ok, %Commit{} = c3p} = Object.read(repo, new_tip)
assert c3p.message == "add c\n"
[c2p_sha] = c3p.parents
{:ok, %Commit{} = c2p} = Object.read(repo, c2p_sha)
assert c2p.message == "add b\n"
[c1p_sha] = c2p.parents
{:ok, %Commit{} = c1p} = Object.read(repo, c1p_sha)
assert c1p.message == "add a\n"
assert c1p.parents == [new_base]
# Final tree contains a, b, c, d — contributions from chain and new_base
{:ok, %Tree{entries: entries}} = Object.read(repo, c3p.tree)
names = Enum.map(entries, & &1.name)
assert Enum.sort(names) == ["a", "b", "c", "d"]
end
test "halts and returns conflict at the first conflicting commit",
%{repo: repo, base: base, chain: [c1, c2, _c3]} do
# new base also modifies "a", which will conflict with c1 (which adds "a")
# Actually adding the same path — three-way merge will detect the overlap
# when c1's parent had no "a" but new_base has a different "a".
{:ok, new_base_with_a} = commit_add_file(repo, base, "a", "different A", "main a")
assert {:error, {:conflicts, _}} =
ExGitObjectstore.rebase_commits(repo, [c1, c2], new_base_with_a, committer: bob())
end
test "requires :committer", %{repo: repo, new_base: new_base} do
assert {:error, {:missing_option, :committer}} =
ExGitObjectstore.rebase_commits(repo, [], new_base, [])
end
end
# -- squash_merge/4 -------------------------------------------------
describe "squash_merge/4" do
setup do
repo = RepoHelper.memory_repo()
readme = write_blob(repo, "# v1")
{:ok, base_tree} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme}])
{:ok, base} =
ExGitObjectstore.commit_tree(repo, base_tree, author: alice(), message: "base")
:ok = ExGitObjectstore.create_branch(repo, "main", base)
# feature: two commits adding a and b
{:ok, c1} = commit_add_file(repo, base, "a", "A", "add a")
{:ok, c2} = commit_add_file(repo, c1, "b", "B", "add b")
:ok = ExGitObjectstore.create_branch(repo, "feature", c2)
%{repo: repo, base: base, feature_tip: c2}
end
test "produces a single-parent commit with the merged tree",
%{repo: repo, base: base} do
assert {:ok, sha} =
ExGitObjectstore.squash_merge(repo, "main", "feature", author: alice())
{:ok, %Commit{parents: parents, tree: tree, message: msg}} = Object.read(repo, sha)
assert parents == [base]
assert msg == "Squash merge of feature into main\n"
{:ok, %Tree{entries: entries}} = Object.read(repo, tree)
names = Enum.map(entries, & &1.name) |> Enum.sort()
# Base README + feature's a + b
assert names == ["README.md", "a", "b"]
end
test "custom message is honored", %{repo: repo} do
{:ok, sha} =
ExGitObjectstore.squash_merge(repo, "main", "feature",
author: alice(),
message: "Squashed!\n"
)
{:ok, %Commit{message: msg}} = Object.read(repo, sha)
assert msg == "Squashed!\n"
end
test "returns conflict without writing a commit", %{repo: repo, base: base} do
readme_x = write_blob(repo, "# main version")
readme_y = write_blob(repo, "# feature version")
{:ok, tx} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme_x}])
{:ok, ty} =
ExGitObjectstore.write_tree(repo, [%{mode: "100644", name: "README.md", sha: readme_y}])
{:ok, main_conflict} =
ExGitObjectstore.commit_tree(repo, tx, parents: [base], author: alice(), message: "x")
{:ok, feat_conflict} =
ExGitObjectstore.commit_tree(repo, ty, parents: [base], author: alice(), message: "y")
:ok = ExGitObjectstore.update_branch(repo, "main-c", main_conflict)
:ok = ExGitObjectstore.update_branch(repo, "feat-c", feat_conflict)
assert {:error, {:conflicts, _}} =
ExGitObjectstore.squash_merge(repo, "main-c", "feat-c", author: alice())
end
end
# -- helpers --
defp commit_add_file(repo, parent_sha, path, content, message) do
{:ok, %Commit{tree: parent_tree_sha}} = Object.read(repo, parent_sha)
{:ok, %Tree{entries: parent_entries}} = Object.read(repo, parent_tree_sha)
blob_sha = write_blob(repo, content)
new_entries =
parent_entries
|> Enum.reject(&(&1.name == path))
|> Kernel.++([%{mode: "100644", name: path, sha: blob_sha}])
{:ok, new_tree_sha} = ExGitObjectstore.write_tree(repo, new_entries)
ExGitObjectstore.commit_tree(repo, new_tree_sha,
parents: [parent_sha],
author: alice(),
message: message
)
end
end