ref:aef8c4de87e2e108e964d90b4969ddac21de63be

feat: write_tree/2, commit_tree/3, merge_branches/4 (#24) (#14)

Closes #24 ## Summary Adds top-level primitives for building trees and commits programmatically and for creating a merge commit from two refs in-process. These unblock fangorn/anvil#45 — Anvil's merge/rebase path currently shells out to \`git\` in a temp filesystem clone because this library offered no way to construct commits without a working directory. ## API \`\`\`elixir ExGitObjectstore.write_tree(repo, entries) # {:ok, tree_sha} ExGitObjectstore.commit_tree(repo, tree_sha, opts) # {:ok, commit_sha} ExGitObjectstore.merge_branches(repo, ours, theirs, opts) # {:ok, merge_sha} \`\`\` - **\`write_tree/2\`** — thin wrapper over \`Tree.new/1 + Object.write/2\`. SHA is stable across input orderings because \`Tree.new\` canonicalizes. - **\`commit_tree/3\`** — structured \`%{name, email, when: DateTime}\` identity shape, formatted to git's wire format (\`\"Name <email> <unix> <+HHMM>\"\`). Validates tree SHA exists + is a Tree, and each parent SHA exists + is a Commit, before writing. Normalizes missing trailing message newline. Supports optional \`gpgsig\`. - **\`merge_branches/4\`** — resolves both refs → commit SHAs, calls the existing \`Merge.merge_commits/3\` to three-way-merge against the merge base, then writes a two-parent merge commit via \`commit_tree/3\`. Returns \`{:error, {:conflicts, [...]}}\` on conflict without writing anything. ## Tests New file \`test/ex_git_objectstore/commit_tree_and_merge_test.exs\` — 16 tests: - Tree writes, empty tree, deterministic SHA - Root commit + commit-with-parents round-trip - Default-committer-equals-author, separate committer preserved - Trailing-newline normalization idempotence - Missing tree, non-tree, missing parent, non-commit parent rejection - Positive AND negative TZ offset formatting - Divergent non-conflicting merge produces two-parent commit with merged tree - Conflict returns \`{:conflicts, _}\` and leaves branch refs unchanged - Custom merge message overrides default Full suite: **582 tests, 0 failures**. ## Follow-up Once this merges and the \`mix.lock\` pin in \`fangorn/anvil\` is bumped, fangorn/anvil#45 (replace shell-out merge with in-process tree builder) can proceed.
SHA: aef8c4de87e2e108e964d90b4969ddac21de63be
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-18 13:04
Parents: 0b41726
3 files changed +1534 -0
Type
CHANGELOG.md +36 −0
@@ -9,6 +9,42 @@
### Added
- **Merge / rebase toolkit** — complete set of primitives for performing
merges and rebases in-process, without a working directory or any
shell-out to `git`. Unblocks fangorn/anvil#45. See fangorn/ex_git_objectstore#24.
- `write_tree/2` — write a tree from a list of entries.
- `commit_tree/3` — build and store a commit pointing at a tree. Accepts
either structured `%{name, email, when: DateTime}` identities or
pre-formatted git wire-format strings (useful for cherry-pick to
preserve author verbatim). Validates tree + parent SHAs exist and are
the right types. Typed error tuples (`{:error, {:missing_option, key}}`,
`{:missing_tree, sha}`, `{:missing_parent, sha}`, etc.) instead of raises.
Supports optional `:gpgsig`.
- `merge_branches/4` — resolves two refs, runs three-way merge against
their merge base, writes a two-parent merge commit. Returns
`{:error, {:conflicts, [...]}}` on conflict without writing.
- `squash_merge/4` — same three-way merge, single-parent commit —
history from `head` collapsed onto `base`.
- `cherry_pick/3` — three-way replay of one commit onto a new parent.
Preserves author verbatim (no parse/format round-trip), updates
committer, drops the GPG signature (rewrite invalidates it). Rejects
root commits and merge commits (latter pending `:mainline` support).
- `rebase_commits/4` — sequential cherry-pick of a list of commits onto
a new base. Halts on first conflict.
- `merge_base/3` — lowest common ancestor of two commits (top-level
delegate to `Walk.merge_base/3`).
- `ancestor?/3` — true if A is an ancestor of B (reflexive).
- `update_branch/4` — ergonomic wrapper over `Ref.put/3`, with optional
compare-and-swap via `expected_old_sha`.
- `format_identity/1` — identity map → git wire-format string; raw
strings pass through.
- `parse_identity/1` — git wire-format string → identity map, preserving
timezone offset on the returned `DateTime`.
None of these primitives update any refs beyond `update_branch/4`;
persisting merge / rebase results to branches is the caller's
responsibility.
- `blob_sizes/3` — batched variant of `blob_size/2` with bounded-concurrency
parallel reads, deduplication, and `{:ok, %{sha => size}}` return. Drops
the 100s-of-sequential-round-trips cost of rendering large directory
lib/ex_git_objectstore.ex +524 −0
@@ -18,6 +18,31 @@
Provides git operations without requiring libgit2, git CLI, or any NIF.
All git data (objects, refs, packs) is stored via a pluggable storage backend.
## Building objects
* `write_tree/2` — write a tree from a list of entries
* `commit_tree/3` — build and store a commit pointing at a tree
## Merge / rebase toolkit
Complete set of primitives for performing merges and rebases in-process
without a working directory:
* `merge_branches/4` — three-way merge two refs into a merge commit (two parents)
* `squash_merge/4` — three-way merge producing a single-parent commit
* `cherry_pick/3` — replay a single commit onto a new parent
* `rebase_commits/4` — replay a list of commits onto a new base
## Ref operations
* `create_branch/3`, `delete_branch/2`
* `update_branch/4` — atomic compare-and-swap ref update
## Graph queries
* `merge_base/3` — lowest common ancestor of two commits
* `ancestor?/3` — true if A is an ancestor of B
"""
alias ExGitObjectstore.{Merge, Object, ObjectResolver, Ref, Repo, Walk}
@@ -26,6 +51,28 @@
@type sha :: String.t()
@type ref_name :: String.t()
@typedoc """
A structured author/committer identity.
The `:when` DateTime's `utc_offset + std_offset` produces the timezone;
sub-minute offsets are rounded to the minute, matching git's own behavior.
"""
@type identity :: %{
required(:name) => String.t(),
required(:email) => String.t(),
required(:when) => DateTime.t()
}
@typedoc """
Either a structured `identity/0` map or a pre-formatted git wire-format
string like `"Name <email> 1234567890 +0000"`. Accepted by `commit_tree/3`
and related APIs. Raw strings pass through unchanged; maps are formatted
via `format_identity/1`. Pass-through is useful for cherry-pick and rebase,
where preserving the original commit's author string byte-for-byte avoids
any parse/format round-trip loss.
"""
@type identity_or_raw :: identity() | String.t()
@doc """
Initialize a new empty repository.
"""
@@ -313,6 +360,483 @@
{: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
@doc """
Write a tree from a list of entries and return its SHA.
Entries are validated and canonicalized by `Tree.new/1`. Each entry is a map
with `:mode`, `:name`, and `:sha` — see `ExGitObjectstore.Object.Tree` for
allowed mode values.
## Example
{:ok, tree_sha} = ExGitObjectstore.write_tree(repo, [
%{mode: "100644", name: "README.md", sha: readme_blob_sha},
%{mode: "40000", name: "src", sha: src_tree_sha}
])
"""
@spec write_tree(Repo.t(), [Tree.entry()]) :: {:ok, sha()} | {:error, term()}
def write_tree(%Repo{} = repo, entries) when is_list(entries) do
Object.write(repo, Tree.new(entries))
end
@doc """
Build and write a commit object pointing at a tree.
Returns the new commit's SHA. Validates the tree and all parent SHAs exist
in storage before writing.
## Options
* `:parents` — list of parent commit SHAs. Empty list for a root commit.
* `:author` (required) — an `identity_or_raw/0`. Pass an `identity/0` map
and it's formatted to git wire format; pass a pre-formatted string and
it's used as-is (useful for cherry-pick to preserve author verbatim).
* `:committer` — same shape as `:author`. Defaults to `:author`.
* `:message` (required) — commit message. A trailing newline is added if
missing, per git convention.
* `:gpgsig` — optional detached GPG signature to embed in the commit.
## Errors
* `{:error, {:missing_option, :author | :message}}` — required option omitted.
* `{:error, {:missing_tree, sha}}` — tree SHA not in storage.
* `{:error, {:not_a_tree, sha}}` — SHA exists but isn't a tree.
* `{:error, {:missing_parent, sha}}` — parent SHA not in storage.
* `{:error, {:not_a_commit_parent, sha}}` — parent SHA exists but isn't a commit.
## Example
{:ok, commit_sha} =
ExGitObjectstore.commit_tree(repo, tree_sha,
parents: [base_sha, head_sha],
author: %{name: "Alice", email: "a@x.com", when: DateTime.utc_now()},
message: "Merge head into base"
)
"""
@spec commit_tree(Repo.t(), sha(),
parents: [sha()],
author: identity_or_raw(),
committer: identity_or_raw(),
message: String.t(),
gpgsig: String.t() | nil
) :: {:ok, sha()} | {:error, term()}
def commit_tree(%Repo{} = repo, tree_sha, opts) when is_binary(tree_sha) and is_list(opts) do
with {:ok, author} <- fetch_required(opts, :author),
{:ok, message} <- fetch_required(opts, :message),
:ok <- validate_tree_exists(repo, tree_sha),
:ok <- validate_parents_exist(repo, Keyword.get(opts, :parents, [])) do
commit = %Commit{
tree: tree_sha,
parents: Keyword.get(opts, :parents, []),
author: format_identity(author),
committer: format_identity(Keyword.get(opts, :committer, author)),
message: ensure_trailing_newline(message),
gpgsig: Keyword.get(opts, :gpgsig)
}
Object.write(repo, commit)
end
end
@doc """
Merge `theirs` into `ours`, creating a merge commit.
Resolves both refs (or SHAs) to commits, performs a three-way merge against
their merge base, and creates a new commit with the merged tree and both
commits as parents. On conflict, returns without writing the merge commit.
**Does not update any ref.** The returned SHA is only written as a commit
object; persisting it to a branch is the caller's responsibility (e.g.
`create_branch/3` or a storage-level CAS on `refs/heads/<branch>`).
Tag refs resolve transitively to their target commit via `resolve/2`.
Uses the same `identity/0` shape as `commit_tree/3`.
## Options
* `:author` (required) — identity for the merge commit author.
* `:committer` — defaults to `:author`.
* `:message` — merge commit message. Defaults to
`"Merge <theirs_ref> into <ours_ref>\\n"`.
## Returns
* `{:ok, merge_commit_sha}` on clean merge.
* `{:error, {:conflicts, [%{path, base, ours, theirs}]}}` on conflict
(no commit is written).
* `{:error, {:missing_option, :author}}` — required option omitted.
* `{:error, reason}` for resolution or storage failures.
## Example
{:ok, merge_sha} =
ExGitObjectstore.merge_branches(repo, "main", "feature",
author: %{name: "Alice", email: "a@x.com", when: DateTime.utc_now()}
)
"""
@spec merge_branches(Repo.t(), ref_name() | sha(), ref_name() | sha(),
author: identity(),
committer: identity(),
message: String.t()
) :: {:ok, sha()} | {:error, term()}
def merge_branches(%Repo{} = repo, ours_ref, theirs_ref, opts) when is_list(opts) do
with {:ok, author} <- fetch_required(opts, :author),
{:ok, ours_sha} <- resolve(repo, ours_ref),
{:ok, theirs_sha} <- resolve(repo, theirs_ref),
{:ok, merged_tree_sha} <- Merge.merge_commits(repo, ours_sha, theirs_sha) do
committer = Keyword.get(opts, :committer, author)
message =
Keyword.get_lazy(opts, :message, fn ->
"Merge #{theirs_ref} into #{ours_ref}\n"
end)
commit_tree(repo, merged_tree_sha,
parents: [ours_sha, theirs_sha],
author: author,
committer: committer,
message: message
)
end
end
# -- Squash / cherry-pick / rebase --
@doc """
Squash-merge `head` into `base`, writing a single-parent commit.
Same three-way merge as `merge_branches/4`, but the resulting commit has
only `base` as a parent — `head`'s history is collapsed into one commit.
**Does not update any ref.** Caller is responsible for persisting the
returned SHA (typically by updating `base`'s branch ref).
## Options
* `:author` (required) — `identity_or_raw/0`.
* `:committer` — defaults to `:author`.
* `:message` — defaults to `"Squash merge of <head_ref> into <base_ref>\\n"`.
## Returns
* `{:ok, squash_commit_sha}` on clean merge.
* `{:error, {:conflicts, [...]}}` on conflict (no commit written).
* `{:error, {:missing_option, :author}}`, or a resolution/storage error.
"""
@spec squash_merge(Repo.t(), ref_name() | sha(), ref_name() | sha(),
author: identity_or_raw(),
committer: identity_or_raw(),
message: String.t()
) :: {:ok, sha()} | {:error, term()}
def squash_merge(%Repo{} = repo, base_ref, head_ref, opts) when is_list(opts) do
with {:ok, author} <- fetch_required(opts, :author),
{:ok, base_sha} <- resolve(repo, base_ref),
{:ok, head_sha} <- resolve(repo, head_ref),
{:ok, merged_tree_sha} <- Merge.merge_commits(repo, base_sha, head_sha) do
committer = Keyword.get(opts, :committer, author)
message =
Keyword.get_lazy(opts, :message, fn ->
"Squash merge of #{head_ref} into #{base_ref}\n"
end)
commit_tree(repo, merged_tree_sha,
parents: [base_sha],
author: author,
committer: committer,
message: message
)
end
end
@doc """
Cherry-pick a single commit onto a new parent.
Performs a three-way merge with `commit`'s first parent as the base,
`onto`'s tree as "ours", and `commit`'s tree as "theirs". On success,
writes a new commit with `onto` as its sole parent, preserving `commit`'s
original author (and message, unless overridden).
The GPG signature of the original commit is **not** copied — cherry-picking
rewrites the commit, which invalidates any signature over the old content.
## Options
* `:onto` (required) — SHA of the commit that will become the new parent.
* `:committer` (required) — `identity_or_raw/0` for the cherry-picker.
* `:author` — override the author. Default: preserve `commit`'s author.
* `:message` — override the message. Default: preserve `commit`'s message.
## Errors
* `{:error, :cannot_cherry_pick_root}` — `commit` has no parents.
* `{:error, {:merge_commit_needs_mainline, sha}}` — `commit` is a merge
commit (has 2+ parents); cherry-picking it requires choosing a mainline
parent, which isn't yet supported.
* `{:error, {:conflicts, [...]}}` — three-way merge conflict.
* Other errors as `commit_tree/3`.
## Example
{:ok, new_sha} =
ExGitObjectstore.cherry_pick(repo, commit_sha,
onto: base_tip_sha,
committer: %{name: "Bot", email: "bot@x.com", when: DateTime.utc_now()}
)
"""
@spec cherry_pick(Repo.t(), sha(),
onto: sha(),
committer: identity_or_raw(),
author: identity_or_raw(),
message: String.t()
) :: {:ok, sha()} | {:error, term()}
def cherry_pick(%Repo{} = repo, commit_sha, opts)
when is_binary(commit_sha) and is_list(opts) do
with {:ok, onto_sha} <- fetch_required(opts, :onto),
{:ok, committer} <- fetch_required(opts, :committer),
{:ok, %Commit{} = commit} <- read_commit(repo, commit_sha),
{:ok, parent_sha} <- cherry_pick_parent(commit, commit_sha),
{:ok, %Commit{tree: parent_tree}} <- read_commit(repo, parent_sha),
{:ok, %Commit{tree: onto_tree}} <- read_commit(repo, onto_sha),
{:ok, merged_tree_sha} <- Merge.merge_trees(repo, parent_tree, onto_tree, commit.tree) do
author = Keyword.get(opts, :author, commit.author)
message = Keyword.get(opts, :message, commit.message)
commit_tree(repo, merged_tree_sha,
parents: [onto_sha],
author: author,
committer: committer,
message: message
)
end
end
@doc """
Replay a list of commits onto `onto`, returning the final tip SHA.
Cherry-picks each commit in order. Halts on the first conflict and returns
the error without writing any more commits. The commits already cherry-picked
before the halt remain as unreferenced objects in storage (they become
unreachable garbage unless the caller does something with them, which is
the normal outcome of an aborted rebase).
**Does not update any ref.** The returned tip is the caller's to persist.
Typical usage: pass the commits returned by a range walk (e.g. commits
reachable from `head` but not `base`, oldest-first).
## Options
* `:committer` (required) — `identity_or_raw/0` used for every replayed
commit's committer field. The author of each commit is preserved.
## Returns
* `{:ok, new_tip_sha}` — cleanly replayed all commits.
* `{:ok, ^onto}` if `commits` is empty.
* `{:error, {:conflicts, [...]}}` at the first conflicting commit.
* Other errors propagated from `cherry_pick/3`.
"""
@spec rebase_commits(Repo.t(), [sha()], sha(), committer: identity_or_raw()) ::
{:ok, sha()} | {:error, term()}
def rebase_commits(%Repo{} = repo, commits, onto, opts)
when is_list(commits) and is_binary(onto) and is_list(opts) do
with {:ok, committer} <- fetch_required(opts, :committer) do
Enum.reduce_while(commits, {:ok, onto}, &rebase_step(repo, committer, &1, &2))
end
end
defp rebase_step(repo, committer, commit_sha, {:ok, current_tip}) do
case cherry_pick(repo, commit_sha, onto: current_tip, committer: committer) do
{:ok, new_sha} -> {:cont, {:ok, new_sha}}
{:error, _} = err -> {:halt, err}
end
end
# -- Graph queries --
@doc """
Lowest common ancestor of two commits.
Delegates to `ExGitObjectstore.Walk.merge_base/3`.
"""
@spec merge_base(Repo.t(), sha(), sha()) :: {:ok, sha()} | {:error, term()}
def merge_base(%Repo{} = repo, sha_a, sha_b) do
Walk.merge_base(repo, sha_a, sha_b)
end
@doc """
True if `ancestor` is an ancestor of `descendant` (inclusive — a commit is
its own ancestor).
Implemented by checking `merge_base(ancestor, descendant) == ancestor`.
"""
@spec ancestor?(Repo.t(), sha(), sha()) :: {:ok, boolean()} | {:error, term()}
def ancestor?(%Repo{} = repo, ancestor_sha, descendant_sha) do
case Walk.merge_base(repo, ancestor_sha, descendant_sha) do
{:ok, ^ancestor_sha} -> {:ok, true}
{:ok, _} -> {:ok, false}
{:error, _} = err -> err
end
end
# -- Ref update --
@doc """
Update a branch ref, optionally with compare-and-swap.
If `expected_old_sha` is `nil` (default), the update is unconditional.
If provided, the update succeeds only if the ref currently equals
`expected_old_sha`; otherwise the storage backend returns `:cas_failed`.
Creates the branch if it doesn't exist (when `expected_old_sha` is `nil`).
## Example
# unconditional
:ok = ExGitObjectstore.update_branch(repo, "main", new_sha)
# CAS — fails with {:error, :cas_failed} if ref has moved
case ExGitObjectstore.update_branch(repo, "main", new_sha, observed_sha) do
:ok -> ...
{:error, :cas_failed} -> # concurrent push, retry or abort
end
"""
@spec update_branch(Repo.t(), String.t(), sha(), sha() | nil) :: :ok | {:error, term()}
def update_branch(%Repo{} = repo, name, new_sha, expected_old_sha \\ nil)
when is_binary(name) and is_binary(new_sha) do
Ref.put(repo, "refs/heads/#{name}", new_sha, expected_old_sha)
end
# -- Identity helpers (public) --
@doc """
Format an identity as a git wire-format string.
A raw string input passes through unchanged — this lets callers that
already have a formatted identity (e.g. reading a commit's `:author`
field) pipe it through a uniform API.
## Examples
iex> ExGitObjectstore.format_identity(%{
...> name: "Alice", email: "a@x.com", when: ~U[2026-01-01 00:00:00Z]
...> })
"Alice <a@x.com> 1767225600 +0000"
iex> ExGitObjectstore.format_identity("Alice <a@x.com> 1767225600 +0000")
"Alice <a@x.com> 1767225600 +0000"
"""
@spec format_identity(identity_or_raw()) :: String.t()
def format_identity(value), do: do_format_identity(value)
@doc """
Parse a git wire-format identity string back into a structured identity.
Returns `{:ok, %{name, email, when}}` or `{:error, :invalid_identity}`.
The returned `DateTime` preserves the original timezone offset; a `+0000`
input produces a UTC DateTime, and non-zero offsets are represented via
`utc_offset` on the DateTime struct.
"""
@spec parse_identity(String.t()) :: {:ok, identity()} | {:error, :invalid_identity}
def parse_identity(str) when is_binary(str) do
case Regex.run(~r/^(.+?)\s+<([^>]*)>\s+(\d+)\s+([+-])(\d{2})(\d{2})$/, str) do
[_, name, email, ts_str, sign, hh_str, mm_str] ->
unix = String.to_integer(ts_str)
hours = String.to_integer(hh_str)
minutes = String.to_integer(mm_str)
offset_sec = (hours * 3600 + minutes * 60) * if(sign == "-", do: -1, else: 1)
dt =
unix
|> DateTime.from_unix!()
|> Map.merge(%{
utc_offset: offset_sec,
std_offset: 0,
zone_abbr: "UTC",
time_zone: "Etc/UTC"
})
{:ok, %{name: name, email: email, when: dt}}
_ ->
{:error, :invalid_identity}
end
end
# -- Option fetching & validation helpers --
defp fetch_required(opts, key) do
case Keyword.fetch(opts, key) do
{:ok, value} -> {:ok, value}
:error -> {:error, {:missing_option, key}}
end
end
defp read_commit(repo, sha) do
case cat_object(repo, sha) do
{:ok, %Commit{} = c} -> {:ok, c}
{:ok, _} -> {:error, {:not_a_commit, sha}}
{:error, _} = err -> err
end
end
defp cherry_pick_parent(%Commit{parents: []}, _sha), do: {:error, :cannot_cherry_pick_root}
defp cherry_pick_parent(%Commit{parents: [p]}, _sha), do: {:ok, p}
defp cherry_pick_parent(%Commit{parents: [_ | _]}, sha),
do: {:error, {:merge_commit_needs_mainline, sha}}
# Pattern-match wrapper so the public API and internal callers go through
# a single code path — raw strings pass through, maps get formatted.
defp do_format_identity(str) when is_binary(str), do: str
defp do_format_identity(%{name: name, email: email, when: %DateTime{} = dt})
when is_binary(name) and is_binary(email) do
timestamp = DateTime.to_unix(dt)
tz = format_tz_offset(dt)
"#{name} <#{email}> #{timestamp} #{tz}"
end
defp format_tz_offset(%DateTime{utc_offset: utc, std_offset: std}) do
total = utc + std
sign = if total >= 0, do: "+", else: "-"
abs_total = abs(total)
hours = div(abs_total, 3600)
minutes = rem(div(abs_total, 60), 60)
[sign, pad2(hours), pad2(minutes)] |> IO.iodata_to_binary()
end
defp pad2(n) when n < 10, do: "0#{n}"
defp pad2(n), do: Integer.to_string(n)
defp ensure_trailing_newline(message) when is_binary(message) do
if String.ends_with?(message, "\n"), do: message, else: message <> "\n"
end
defp validate_tree_exists(repo, sha) do
case cat_object(repo, sha) do
{:ok, %Tree{}} -> :ok
{:ok, _} -> {:error, {:not_a_tree, sha}}
{:error, _} -> {:error, {:missing_tree, sha}}
end
end
defp validate_parents_exist(_repo, []), do: :ok
defp validate_parents_exist(repo, parents) when is_list(parents) do
Enum.reduce_while(parents, :ok, fn sha, :ok ->
case cat_object(repo, sha) do
{:ok, %Commit{}} -> {:cont, :ok}
{:ok, _} -> {:halt, {:error, {:not_a_commit_parent, sha}}}
{:error, _} -> {:halt, {:error, {:missing_parent, sha}}}
end
end)
end
# Walk into nested tree directories by path components
test/ex_git_objectstore/commit_tree_and_merge_test.exs +974 −0
@@ -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