ref:c82d407baa0e88607480b44baa84903d510349e7

Prepare for initial Hex.pm release

- README: project overview, features, quick start, storage backend docs, known limitations - CHANGELOG: v0.1.0 entry documenting all features - mix.exs: source_url, homepage_url, links, files list, ex_doc config with module groups - Add ex_doc and dialyxir dependencies - CI workflow: compile, test, format, license check, dialyzer (all parallel after compile) - Release workflow: publish to Hex.pm + create GitHub release on "Release v*" commits to main - Fix compiler warnings in merge_test.exs and merge_base_test.exs - Fix formatting in filesystem.ex - Add /priv/plts/ to .gitignore for dialyzer cache Closes #1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: c82d407baa0e88607480b44baa84903d510349e7
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-02-10 23:46
Parents: 479b61e
10 files changed +389 -13
Type
.gitignore +3 −0
@@ -25,3 +25,6 @@
# Temporary files, for example, from tests.
/tmp/
# Dialyzer PLT files
/priv/plts/
CHANGELOG.md +26 −0
@@ -1,0 +1,26 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-02-10
### Added
- Git object encoding/decoding (blob, tree, commit, tag) with SHA-1 hashing
- Pluggable storage backends: Filesystem, S3, and Memory
- Ref management: branches, tags, HEAD, compare-and-swap updates
- Packfile support: read/write `.pack` and `.idx` v2 files
- Delta resolution (OFS_DELTA) in pack reader
- Three-way recursive tree merge with conflict detection
- Myers diff algorithm with unified diff output and context hunks
- Commit log traversal and merge base (LCA) finding
- Git wire protocol: pkt-line framing, upload-pack, receive-pack
- ETS-based LRU object cache with configurable size limits
- Atomic file writes and lock-file CAS for filesystem storage
- Path traversal prevention in filesystem storage
- Input validation for refs, SHAs, tree entry names, pack counts
- Apache 2.0 license with headers on all source files
- `mix license_check` task for CI enforcement
mix.exs +47 −3
@@ -15,6 +15,8 @@
defmodule ExGitObjectstore.MixProject do
use Mix.Project
@source_url "https://github.com/notifd/ex_git_objectstore"
def project do
[
app: :ex_git_objectstore,
@@ -24,17 +26,57 @@
deps: deps(),
elixirc_paths: elixirc_paths(Mix.env()),
package: package(),
description: "Pure Elixir git object store with pluggable storage backends",
source_url: @source_url,
homepage_url: @source_url,
docs: docs(),
dialyzer: [
description: "Pure Elixir git object store with pluggable storage backends"
plt_file: {:no_warn, "priv/plts/dialyzer.plt"}
]
]
end
defp package do
[
licenses: ["Apache-2.0"],
links: %{}
links: %{"GitHub" => @source_url},
files: ~w(lib LICENSE NOTICE README.md CHANGELOG.md mix.exs .formatter.exs)
]
end
defp docs do
[
main: "readme",
extras: ["README.md", "CHANGELOG.md", "LICENSE", "NOTICE"],
source_url: @source_url,
groups_for_modules: [
"Object Types": [
ExGitObjectstore.Object.Blob,
ExGitObjectstore.Object.Commit,
ExGitObjectstore.Object.Tree,
ExGitObjectstore.Object.Tag
],
"Storage Backends": [
ExGitObjectstore.Storage,
ExGitObjectstore.Storage.Filesystem,
ExGitObjectstore.Storage.Memory,
ExGitObjectstore.Storage.S3
],
"Pack Format": [
ExGitObjectstore.Pack.Reader,
ExGitObjectstore.Pack.Writer,
ExGitObjectstore.Pack.Index,
ExGitObjectstore.Pack.Delta
],
"Git Protocol": [
ExGitObjectstore.Protocol.PktLine,
ExGitObjectstore.Protocol.UploadPack,
ExGitObjectstore.Protocol.ReceivePack
]
]
]
end
def application do
[
extra_applications: [:logger, :crypto]
@@ -50,7 +92,9 @@
{:ex_aws_s3, "~> 2.5"},
{:sweet_xml, "~> 0.7"},
{:hackney, "~> 1.18"},
{:jason, "~> 1.4"}
{:jason, "~> 1.4"},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
]
end
end
mix.lock +8 −0
@@ -1,13 +1,21 @@
%{
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"},
"ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"},
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
README.md +120 −6
@@ -1,11 +1,27 @@
# ExGitObjectstore
**TODO: Add description**
Pure Elixir git object store with pluggable storage backends.
Read, write, and manipulate git objects (blobs, trees, commits, tags), refs, and
packfiles without requiring libgit2, the git CLI, or any NIF. All git data is
stored through a pluggable storage backend — use the local filesystem, S3, or
in-memory storage.
## Features
- **Git objects** — encode, decode, hash, read, and write blobs, trees, commits, and tags
- **Refs** — branches, tags, HEAD, compare-and-swap updates
- **Packfiles** — read and write `.pack` and `.idx` v2 files, delta resolution
- **Three-way merge** — recursive tree merge with conflict detection
- **Diff engine** — Myers diff algorithm with unified diff output and context hunks
- **Graph traversal** — commit log, merge base (LCA) finding
- **Git wire protocol** — pkt-line framing, upload-pack, receive-pack
- **Pluggable storage** — filesystem, S3 (any S3-compatible service), and in-memory backends
- **ETS caching** — LRU object cache with configurable size limits
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `ex_git_objectstore` to your list of dependencies in `mix.exs`:
Add `ex_git_objectstore` to your list of dependencies in `mix.exs`:
```elixir
def deps do
@@ -14,8 +30,106 @@
]
end
```
## Quick Start
```elixir
alias ExGitObjectstore.{Repo, Object}
alias ExGitObjectstore.Object.{Blob, Commit, Tree}
# Create a repo with in-memory storage
repo = %Repo{
id: "my-repo",
storage: ExGitObjectstore.Storage.Memory,
storage_config: %{}
}
# Initialize and write objects
:ok = ExGitObjectstore.init(repo)
{:ok, blob_sha} = Object.write(repo, Blob.from_content("Hello, world!\n"))
{:ok, tree_sha} = Object.write(repo, Tree.new([
%{mode: "100644", name: "README.md", sha: blob_sha}
]))
{:ok, commit_sha} = Object.write(repo, %Commit{
tree: tree_sha,
parents: [],
author: "Alice <alice@example.com> 1700000000 +0000",
committer: "Alice <alice@example.com> 1700000000 +0000",
message: "Initial commit"
})
:ok = ExGitObjectstore.create_branch(repo, "main", commit_sha)
# Read it back
{:ok, {^commit_sha, commit}} = ExGitObjectstore.commit(repo, "main")
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
{:ok, tree} = ExGitObjectstore.tree(repo, "main")
{:ok, content} = ExGitObjectstore.blob(repo, blob_sha)
```
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/ex_git_objectstore>.
## Storage Backends
### Filesystem
```elixir
repo = %Repo{
id: "my-repo",
storage: ExGitObjectstore.Storage.Filesystem,
storage_config: %{root: "/var/git/repos/my-repo"}
}
```
Uses the standard git loose object layout with atomic writes (temp file + rename)
and lock-file compare-and-swap for ref updates.
### S3
```elixir
repo = %Repo{
id: "my-repo",
storage: ExGitObjectstore.Storage.S3,
storage_config: %{
bucket: "my-git-bucket",
prefix: "repos/my-repo",
ex_aws_config: [
access_key_id: "...",
secret_access_key: "...",
region: "us-east-1"
]
}
}
```
Works with AWS S3, MinIO, or any S3-compatible service. Handles pagination
for repositories with many objects.
### Memory
```elixir
repo = %Repo{
id: "my-repo",
storage: ExGitObjectstore.Storage.Memory,
storage_config: %{}
}
```
Stores everything in an ETS table. Useful for testing and ephemeral operations.
## Known Limitations
This is v0.1.0 — a functional foundation with some protocol features still in progress:
- **REF_DELTA** — pack reader resolves OFS_DELTA but not REF_DELTA (thin packs from `git fetch` may fail)
- **ofs-delta generation** — pack writer stores full objects only, no delta compression
- **multi_ack_detailed** — upload-pack uses NAK-only negotiation
- **side-band-64k** — receive-pack doesn't use side-band for status reporting
These are tracked as TODOs in the source — the scaffolding is in place for implementation.
## License
Copyright 2026 Cole Christensen
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details.