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