@@ -285,6 +285,226 @@
end
end
describe "rev_list_range/4" do
# Diamond: base=c1; branch A (c2), branch B (c3->c4); merge c5.
#
# c1(100) <- c2(101) --------\
# \ c5(104, merge) = head
# <- c3(102) <- c4(103) --/
#
setup do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
blob = Blob.from_content("content\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)
commit = fn ts, parents, msg ->
c = %Commit{
tree: tree_sha,
parents: parents,
author: "A <a@a.com> #{ts} +0000",
committer: "A <a@a.com> #{ts} +0000",
message: msg
}
{:ok, sha} = Object.write(repo, c)
sha
end
c1 = commit.(100, [], "c1\n")
c2 = commit.(101, [c1], "c2\n")
c3 = commit.(102, [c1], "c3\n")
c4 = commit.(103, [c3], "c4\n")
c5 = commit.(104, [c2, c4], "c5 merge\n")
%{repo: repo, shas: %{c1: c1, c2: c2, c3: c3, c4: c4, c5: c5}}
end
test "empty range when base == head", %{repo: repo, shas: %{c5: c5}} do
assert {:ok, []} = ExGitObjectstore.rev_list_range(repo, c5, c5)
end
test "returns base..head oldest-first, parents before children",
%{repo: repo, shas: %{c1: c1, c2: c2, c3: c3, c4: c4, c5: c5}} do
{:ok, commits} = ExGitObjectstore.rev_list_range(repo, c1, c5)
shas = Enum.map(commits, &elem(&1, 0))
# base c1 is excluded; c5 (child) comes last; every parent precedes child.
assert shas == [c2, c3, c4, c5]
end
test "exclude_merges drops the merge commit, keeps both sides",
%{repo: repo, shas: %{c1: c1, c2: c2, c3: c3, c4: c4, c5: c5}} do
{:ok, commits} = ExGitObjectstore.rev_list_range(repo, c1, c5, exclude_merges: true)
shas = Enum.map(commits, &elem(&1, 0))
# c5 (merge) removed; the second-parent side (c3, c4) is NOT dropped.
assert shas == [c2, c3, c4]
refute c5 in shas
end
test "linear range excludes the base commit itself",
%{repo: repo, shas: %{c1: c1, c2: c2}} do
{:ok, commits} = ExGitObjectstore.rev_list_range(repo, c1, c2)
assert Enum.map(commits, &elem(&1, 0)) == [c2]
end
end
# Regression for issue #337 — rebase merge of a PR whose head history
# contains merge commits (integration branch built by merging feature
# branches into main). REQ-PR-051.
describe "rev_list_range/4 + rebase_commits/4 (integration-branch shape)" do
setup do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
who = %{name: "T", email: "t@x", when: ~U[2026-07-10 00:00:00Z]}
add_file = fn parents, path, content, msg ->
{:ok, blob} = Object.write(repo, Blob.from_content(content))
{:ok, base_entries} =
case parents do
[p | _] ->
{:ok, %Commit{tree: t}} = Object.read(repo, p)
{:ok, %Tree{entries: e}} = Object.read(repo, t)
{:ok, e}
[] ->
{:ok, []}
end
entries =
Enum.reject(base_entries, &(&1.name == path)) ++
[%{mode: "100644", name: path, sha: blob}]
{:ok, tree} = ExGitObjectstore.write_tree(repo, entries)
{:ok, sha} =
ExGitObjectstore.commit_tree(repo, tree, parents: parents, author: who, message: msg)
sha
end
{:ok, empty} = ExGitObjectstore.write_tree(repo, [])
{:ok, base} = ExGitObjectstore.commit_tree(repo, empty, author: who, message: "base")
feat_a = add_file.([base], "a.txt", "A", "add a")
feat_b = add_file.([base], "b.txt", "B", "add b")
{:ok, tree_a} = ExGitObjectstore.merge_commits(repo, base, feat_a)
{:ok, m1} =
ExGitObjectstore.commit_tree(repo, tree_a,
parents: [base, feat_a],
author: who,
message: "merge a"
)
{:ok, tree_b} = ExGitObjectstore.merge_commits(repo, m1, feat_b)
{:ok, m2} =
ExGitObjectstore.commit_tree(repo, tree_b,
parents: [m1, feat_b],
author: who,
message: "merge b"
)
%{repo: repo, base: base, head: m2, feat_a: feat_a, feat_b: feat_b, who: who}
end
test "replaying the non-merge range onto base succeeds and keeps all content",
%{repo: repo, base: base, head: head, feat_a: feat_a, feat_b: feat_b, who: who} do
{:ok, commits} = ExGitObjectstore.rev_list_range(repo, base, head, exclude_merges: true)
shas = Enum.map(commits, &elem(&1, 0))
# Only the two content-bearing feature commits (the two merge commits are
# dropped). feat_a/feat_b are independent with equal timestamps, so their
# relative order is a deterministic-but-arbitrary tiebreak — assert the
# set, not a brittle sequence.
assert Enum.sort(shas) == Enum.sort([feat_a, feat_b])
# The whole point: replay no longer hits :merge_commit_needs_mainline.
assert {:ok, new_tip} = ExGitObjectstore.rebase_commits(repo, shas, base, committer: who)
{:ok, %Commit{tree: tree}} = Object.read(repo, new_tip)
{:ok, %Tree{entries: entries}} = Object.read(repo, tree)
names = entries |> Enum.map(& &1.name) |> Enum.sort()
assert names == ["a.txt", "b.txt"]
end
end
# Hardening for issue #337 (REQ-PR-051): the range walk must stay correct
# when committer timestamps are NOT monotonic with topology — routine in real
# repos (rebase/cherry-pick reset the committer date, amends, imported history).
# An ancestor of `base` must never leak into `base..head`, regardless of its
# committer timestamp.
describe "rev_list_range/4 — non-monotonic committer timestamps" do
setup do
repo = RepoHelper.memory_repo()
ExGitObjectstore.init(repo)
{:ok, blob_sha} = Object.write(repo, Blob.from_content("x\n"))
{:ok, tree} = Object.write(repo, Tree.new([%{mode: "100644", name: "f", sha: blob_sha}]))
%{repo: repo, tree: tree}
end
# Commit with an explicit committer timestamp to construct skewed histories.
defp commit_at(repo, tree, parents, ts, msg) do
c = %Commit{
tree: tree,
parents: parents,
author: "T <t@t> #{ts} +0000",
committer: "T <t@t> #{ts} +0000",
message: msg
}
{:ok, sha} = Object.write(repo, c)
sha
end
test "excludes an ancestor of base whose committer date is skewed high",
%{repo: repo, tree: tree} do
# X is the common root but carries a HIGHER committer date (200) than its
# descendants; it is an ancestor of base and must NOT appear in base..head.
# X(200) <- B(101) [base]; X(200) <- H(105) [head]
x = commit_at(repo, tree, [], 200, "X skewed-high root\n")
b = commit_at(repo, tree, [x], 101, "B base\n")
h = commit_at(repo, tree, [x], 105, "H head\n")
{:ok, range} = ExGitObjectstore.rev_list_range(repo, b, h)
assert Enum.map(range, &elem(&1, 0)) == [h]
end
test "excludes a common ancestor with a skewed-high committer date",
%{repo: repo, tree: tree} do
# G(100) <- M(150, skewed) <- B(101) [base]
# \ <- N(120) <- H(130) [head]
# base..head = {N, H}; M and G are common ancestors and must be excluded.
g = commit_at(repo, tree, [], 100, "G root\n")
m = commit_at(repo, tree, [g], 150, "M skewed-high common ancestor\n")
b = commit_at(repo, tree, [m], 101, "B base\n")
n = commit_at(repo, tree, [m], 120, "N\n")
h = commit_at(repo, tree, [n], 130, "H head\n")
{:ok, range} = ExGitObjectstore.rev_list_range(repo, b, h)
assert Enum.map(range, &elem(&1, 0)) == [n, h]
end
test "returns an error instead of silently truncating on a missing object",
%{repo: repo, tree: tree} do
# H's parent SHA was never written. The walk must surface an error, not
# silently drop the unreadable parent and report a clean {:ok, [H]}.
u = commit_at(repo, tree, [], 100, "U base\n")
missing = String.duplicate("0", 40)
h = commit_at(repo, tree, [missing], 105, "H head with dangling parent\n")
assert {:error, _} = ExGitObjectstore.rev_list_range(repo, u, h)
end
end
describe "commit" do
test "get commit by ref", %{repo: repo, shas: shas} do
{:ok, {sha, commit}} = ExGitObjectstore.commit(repo, "main")