Snapshot-first Git object queries for Elixir.
Gitility reads commits, trees, and blobs directly from Git object storage — local bare repositories, in-memory objects, Elixir-backed providers, or remote immutable pack stores — without a worktree, checkout, or shell. Every expensive operation is bounded, observable, and cancellable.
The model in three steps
- Get a store.
Gitility.Repository.open/2for a local repository;Gitility.ODB.start_link/1plusGitility.ODB.handle/1, orGitility.ODB.from_objects/2, for object storage with no filesystem at all. Refs (Gitility.RefDB) are optional and separate. - Pin a snapshot.
Gitility.Repository.snapshot(repo, {:branch, "main"})orGitility.Snapshot.open(odb, commit_oid)resolves a name once and records an immutable commit + tree identity. - Query it. Every function in this module takes that snapshot and answers about exactly that commit, forever.
{:ok, repo} = Gitility.Repository.open("/srv/git/acme/widgets.git", require_bare: true)
{:ok, snapshot} = Gitility.Repository.snapshot(repo, {:branch, "main"})
{:ok, page} = Gitility.list_tree(snapshot, "lib", recursive: true, limit: 500)
{:ok, file} = Gitility.read_file(snapshot, "lib/acme/widget.ex", lines: 120..220)
{:ok, hits} = Gitility.search(snapshot, "def handle_call", pathspecs: ["**/*.ex"])
{:ok, blame} = Gitility.blame(snapshot, "lib/acme/widget.ex", lines: 120..220)Conventions
- Paths and file contents are raw bytes — Git makes no encoding
promise and neither does Gitility (see
Gitility.Path). - Normal failures return
{:error, %Gitility.Error{}}; nothing here raises for repository data, missing objects, timeouts, or backend failures. - Unknown option keys and wrongly typed values for known keys raise
ArgumentError; well-typed values that violate an option's semantic constraints return%Gitility.Error{code: :invalid_argument}. - Everything that can grow returns
%Gitility.Page{}or carriestruncated/stats/warnings— truncation is explicit, never silent. - All operations accept
limits: %Gitility.Limits{}and run as cancellable jobs; eachasync_*variant returns theGitility.Jobdirectly.
Summary
Types
A commit-graph source: an ODB or a snapshot using its ODB.
Any handle that can answer plumbing queries: a repository or ODB.
Functions
Whether ancestor_oid is an ancestor of descendant_oid.
Asynchronous blame/3; returns the Gitility.Job.
Asynchronous diff/3; returns the Gitility.Job.
Asynchronous history/3; returns the Gitility.Job.
Asynchronous list_tree/3; returns the Gitility.Job.
Asynchronous log/2; returns the Gitility.Job.
Asynchronous read_file/3; returns the Gitility.Job.
Asynchronous search/3; returns the Gitility.Job.
Asynchronous submodules/2; returns the Gitility.Job.
Attributes each line of a file to the commit that introduced it,
returned as consecutive hunks (see Gitility.Blame).
Diffs two snapshots as structured data.
Walks the history of one path — the commits that changed it.
Lists tree entries under path (raw bytes; "" for the root).
Walks commit history from the snapshot's commit.
The best common ancestor of two commits, or nil when the histories are
unrelated. Pass all: true to return every best common ancestor.
Peels an object to a target kind — e.g. an annotated tag chain to its
commit (to: :commit, the default).
Confirms the native library is loaded. Returns :pong.
Reads one file (blob) at path, bounded.
Searches blob contents across the snapshot.
Returns .gitmodules declarations correlated with actual snapshot gitlinks.
Types
@type graph_store() :: Gitility.ODB.t() | Gitility.Snapshot.t()
A commit-graph source: an ODB or a snapshot using its ODB.
@type store() :: Gitility.Repository.t() | Gitility.ODB.t()
Any handle that can answer plumbing queries: a repository or ODB.
Functions
@spec ancestor?( graph_store(), Gitility.OID.t() | binary(), Gitility.OID.t() | binary(), keyword() ) :: {:ok, boolean()} | {:error, Gitility.Error.t()}
Whether ancestor_oid is an ancestor of descendant_oid.
This is a short-circuiting reachability walk from the descendant and treats local shallow roots as parentless. It is wrapped in an ok-tuple like everything else: ancestry can fail on missing or malformed objects, and repository-data failures do not raise.
@spec async_blame(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}
Asynchronous blame/3; returns the Gitility.Job.
@spec async_diff(Gitility.Snapshot.t(), Gitility.Snapshot.t(), keyword()) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}
Asynchronous diff/3; returns the Gitility.Job.
@spec async_history(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}
Asynchronous history/3; returns the Gitility.Job.
@spec async_list_tree(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}
Asynchronous list_tree/3; returns the Gitility.Job.
@spec async_log( Gitility.Snapshot.t(), keyword() ) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}
Asynchronous log/2; returns the Gitility.Job.
@spec async_read_file(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}
Asynchronous read_file/3; returns the Gitility.Job.
@spec async_search(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}
Asynchronous search/3; returns the Gitility.Job.
@spec async_submodules( Gitility.Snapshot.t(), keyword() ) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}
Asynchronous submodules/2; returns the Gitility.Job.
@spec blame(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Blame.t()} | {:error, Gitility.Error.t()}
Attributes each line of a file to the commit that introduced it,
returned as consecutive hunks (see Gitility.Blame).
Options
:lines— a 1-based inclusiveRangeto blame (much cheaper than whole-file for large files).:follow_renames— track the content across renames (defaulttrue).:limits— aGitility.Limitsoverride.
There is deliberately no first_parent: option in 0.x: upstream has no
first-parent blame, and a silently-wrong emulation would be worse than
the missing option.
Blame never paginates or returns a partial attribution. A timeout or budget
ceiling fails the whole call; narrow :lines to reduce work. Because the
final file is mandatory input, a HEAD blob above max_object_bytes returns
:object_too_large rather than a warning or truncated result.
The path is literal, not a pathspec. Symlink and gitlink paths return
:invalid_argument by design (R2); canonical Git instead blames a symlink's
target text, which is an intentional capability difference.
@spec diff(Gitility.Snapshot.t(), Gitility.Snapshot.t(), keyword()) :: {:ok, Gitility.Diff.t()} | {:error, Gitility.Error.t()}
Diffs two snapshots as structured data.
The snapshots may come from different ODBs — objects are
content-addressed, so reads resolve through a union of the two stores
(head's first). Both must share a hash algorithm (:hash_mismatch) and
a runtime (:runtime_mismatch). A miss in the head store falls through to
the base store; an object-read error in the head store is fail-fast and is
not retried against the base store.
At patch detail, a type change is represented by exactly two hunks: a pure deletion of the old content followed by a pure insertion of the new content.
Options
:format—:summary,:stats, or:patch(default:patch).:pathspecs— glob patterns limiting the diff.:context_lines— context per hunk (default3).:renames—false(default) or:similarity. Rename detection is opt-in because it reads candidate payloads. It buffers and scoresO(changes)candidates before the first record; diff ceilings bound output, not this detection phase (timeouts and byte limits still apply).:copies— retained in the API surface, but onlyfalseis accepted in 0.x.truereturns:unsupported_operationbecause the current upstream tracker can score a post-image blob and suppress the modified source record. It can return when upstream tracking is sound or the vendored tracker is patched after 1.0.:limits— aGitility.Limitsoverride.
@spec history(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Page.t(Gitility.Commit.t())} | {:error, Gitility.Error.t()}
Walks the history of one path — the commits that changed it.
This is Gitility's own algorithm (upstream has no log --follow): a
budgeted commit walk that tree-diffs each step for the path.
follow_renames: true engages rename tracking to re-target the path
across renames; its rename-candidate selection deviates from canonical
Git in documented ways (see the design doc).
Path history is budgeted separately from log/2 because it may diff
many parent trees. Its worst-case cost is O(history × path-depth), with an
additional bounded change-set pass at each rename candidate.
A merge is emitted exactly when the tracked path state differs from its first
parent. Without rename following, no Git invocation reproduces this rule: the
nearest oracle, git log --full-history -- <path>, additionally emits merges
whose path changed only relative to a non-first parent; Gitility's
design-sanctioned R3 rule deliberately produces fewer such noise merges. With
follow_renames: true, git log --full-history --diff-merges=first-parent --follow -- <path> matches Gitility exactly on the pinned git 2.55.0.
path is always one literal repository path; pathspec magic and wildmatch
metacharacters are rejected. A path that never existed returns an empty page,
matching git log; the corresponding blame query returns :invalid_path.
Options
:follow_renames— follow the path across renames (defaulttrue).:limit,:cursor— pagination.:limits— aGitility.Limitsoverride.
@spec list_tree(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Page.t(Gitility.TreeEntry.t())} | {:error, Gitility.Error.t()}
Lists tree entries under path (raw bytes; "" for the root).
Options
:recursive— descend into subtrees (defaultfalse).:depth— maximum descent depth when recursive.:types— kinds to include, from[:blob, :tree, :symlink, :gitlink](default: all).:pathspecs— Patterns are resolved relative topath; a pattern without wildcards selects that path and everything under it.:include— extra per-entry data:[:size](blob sizes are opt-in because packed object headers may cost work).:limit,:cursor— pagination (seeGitility.Page).:limits— aGitility.Limitsoverride.
Symlinks are never followed; gitlinks are returned as entries, never opened.
@spec log( Gitility.Snapshot.t(), keyword() ) :: {:ok, Gitility.Page.t(Gitility.Commit.t())} | {:error, Gitility.Error.t()}
Walks commit history from the snapshot's commit.
:chronological matches plain git log: newest committer time first, with
Git's priority-queue insertion order (FIFO) for equal timestamps.
:topological and :date match --topo-order and --date-order, including
the same equal-time insertion semantics. Shallow roots are treated as
parentless.
A :since bound uses Git's graph pruning: an older commit is excluded and
traversal does not continue through its parents. :until excludes newer
commits while continuing through their parents.
Chronological calls cost O(emitted commits + any cursor prefix).
Topological/date calls require an O(history) reachable-graph pre-pass on
every call, including cursor resume. If limits.max_objects is below the
reachable commit count, those orders refuse the call with an actionable
:budget_exceeded error before emitting a page.
Options
:order—:chronological(default),:topological, or:date.:first_parent— follow only first parents (defaultfalse).:since/:until— commit-time bounds (Unix seconds orDateTime).:limit,:cursor— pagination.:limits— aGitility.Limitsoverride.
@spec merge_base( graph_store(), Gitility.OID.t() | binary(), Gitility.OID.t() | binary(), keyword() ) :: {:ok, Gitility.OID.t() | nil | [Gitility.OID.t()]} | {:error, Gitility.Error.t()}
The best common ancestor of two commits, or nil when the histories are
unrelated. Pass all: true to return every best common ancestor.
When there are multiple best common ancestors, canonical Git's single
result is unspecified. Gitility deterministically returns the greatest
object ID; use all: true when the full set matters. Local shallow roots
are treated as parentless.
@spec peel(store(), Gitility.OID.t() | String.t(), keyword()) :: {:ok, Gitility.OID.t()} | {:error, Gitility.Error.t()}
Peels an object to a target kind — e.g. an annotated tag chain to its
commit (to: :commit, the default).
@spec ping() :: :pong
Confirms the native library is loaded. Returns :pong.
Useful as an install smoke check: it fails to load (rather than answering) when no precompiled NIF matched this platform and no local build was available.
@spec read_file(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.File.t()} | {:error, Gitility.Error.t()}
Reads one file (blob) at path, bounded.
Options
:lines— a 1-based inclusiveRangeto slice (e.g.120..220).:max_bytes— payload cap; truncation is whole-line except when the first requested line alone exceeds the cap, in which case that line is returned truncated withtruncated: true.:limits— aGitility.Limitsoverride.
The result's total_lines is nil when the byte budget stopped the
read before the whole blob could be scanned. LFS pointers are identified
(lfs_pointer) but never resolved.
@spec search(Gitility.Snapshot.t(), binary(), keyword()) :: {:ok, Gitility.Page.t(Gitility.SearchMatch.t())} | {:error, Gitility.Error.t()}
Searches blob contents across the snapshot.
The scan walks the tree, deduplicates match spans by object ID, and scans within strict budgets. Duplicate paths may physically re-read a payload to materialize results without retaining blob-sized cache entries. (A persistent index may implement this same API later — results are keyed by blob ID to make that a drop-in.)
Cursor resume replays the deterministic tree prefix and re-scans the cursor
path, costing O(prefix paths + one blob re-scan); replayed prefix paths do
not consume limits.max_objects again.
Search checks cancellation between 64 KiB literal-search windows. Regex
search checks each line and each yielded match; one matchless regex pass
over a line is the cancellation-granularity floor and is bounded by
limits.max_object_bytes.
Context belongs to each match independently, so adjacent matches may repeat
lines; unlike git grep -C, search does not merge context hunks. Options are
cursor-fingerprinted, but Gitility.Limits values are not. Changing a limit
such as max_object_bytes between pages can therefore change which later
blobs are scanned.
Options
:mode—:literal(default) or:regex. Regex patterns must be UTF-8 and use a linear-time engine over bytes; arbitrary bytes can be matched with\xNNescapes. Backreferences and lookaround return{:error, %Gitility.Error{code: :unsupported_regex}}— there is no backtracking fallback.:case_sensitive— defaulttrue.:path— restrict to a subtree (raw bytes).:pathspecs— glob patterns filtering candidate files.:binary—:skip(default) or:textto scan binary blobs as bytes.:context_lines— context lines around each match (default0).:limit,:cursor— pagination.:limits— aGitility.Limitsoverride.
@spec submodules( Gitility.Snapshot.t(), keyword() ) :: {:ok, [Gitility.Submodule.t()]} | {:error, Gitility.Error.t()}
Returns .gitmodules declarations correlated with actual snapshot gitlinks.
Results are ordered by raw path bytes. :active rows have both a declaration
and gitlink, :undeclared rows are gitlinks missing from .gitmodules, and
:orphaned rows are declarations with no tree entry. Empty paths sort first.
A declaration without a valued path is ignored. Path values are inert
correlation bytes, so Git-accepted values such as ../name, absolute paths,
trailing slashes, and the empty path are not rejected or used for filesystem
access. A root .gitmodules entry that is not a blob is treated as absent.
Git itself separately refuses symlinked .gitmodules files in a working tree
after CVE-2018-11235; this API reads a bare snapshot. If multiple names
declare one path, the name that sorts first by raw bytes claims its gitlink
and later names are orphaned.
The only option is :limits. The operation performs a full, unpaginated
correlation walk bounded by limits.max_objects and the other tree limits;
there is no cursor, so use limits to constrain work when needed. .gitmodules
has an additional fixed 1 MiB hostile-config cap. SHA-256 object stores are
unsupported by the existing snapshot compatibility check. URLs are inert:
Gitility never resolves them, follows config includes, opens pinned gitlink
commits, or traverses into submodules.