Gitility (Gitility v0.2.0)

Copy Markdown View Source

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

  1. Get a store. Gitility.Repository.open/2 for a local repository; Gitility.ODB.start_link/1 plus Gitility.ODB.handle/1, or Gitility.ODB.from_objects/2, for object storage with no filesystem at all. Refs (Gitility.RefDB) are optional and separate.
  2. Pin a snapshot. Gitility.Repository.snapshot(repo, {:branch, "main"}) or Gitility.Snapshot.open(odb, commit_oid) resolves a name once and records an immutable commit + tree identity.
  3. 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 carries truncated/stats/warnings — truncation is explicit, never silent.
  • All operations accept limits: %Gitility.Limits{} and run as cancellable jobs; each async_* variant returns the Gitility.Job directly.

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.

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

graph_store()

@type graph_store() :: Gitility.ODB.t() | Gitility.Snapshot.t()

A commit-graph source: an ODB or a snapshot using its ODB.

store()

@type store() :: Gitility.Repository.t() | Gitility.ODB.t()

Any handle that can answer plumbing queries: a repository or ODB.

Functions

ancestor?(store, ancestor_oid, descendant_oid, opts \\ [])

@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.

async_blame(snapshot, path, opts \\ [])

@spec async_blame(Gitility.Snapshot.t(), binary(), keyword()) ::
  {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}

Asynchronous blame/3; returns the Gitility.Job.

async_diff(base_snapshot, head_snapshot, opts \\ [])

@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.

async_history(snapshot, path, opts \\ [])

@spec async_history(Gitility.Snapshot.t(), binary(), keyword()) ::
  {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}

Asynchronous history/3; returns the Gitility.Job.

async_list_tree(snapshot, path \\ "", opts \\ [])

@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.

async_log(snapshot, opts \\ [])

@spec async_log(
  Gitility.Snapshot.t(),
  keyword()
) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}

Asynchronous log/2; returns the Gitility.Job.

async_read_file(snapshot, path, opts \\ [])

@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.

async_search(snapshot, query, opts \\ [])

@spec async_search(Gitility.Snapshot.t(), binary(), keyword()) ::
  {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}

Asynchronous search/3; returns the Gitility.Job.

async_submodules(snapshot, opts \\ [])

@spec async_submodules(
  Gitility.Snapshot.t(),
  keyword()
) :: {:ok, Gitility.Job.t()} | {:error, Gitility.Error.t()}

Asynchronous submodules/2; returns the Gitility.Job.

blame(snapshot, path, opts \\ [])

@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 inclusive Range to blame (much cheaper than whole-file for large files).
  • :follow_renames — track the content across renames (default true).
  • :limits — a Gitility.Limits override.

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.

diff(base_snapshot, head_snapshot, opts \\ [])

@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 (default 3).
  • :renamesfalse (default) or :similarity. Rename detection is opt-in because it reads candidate payloads. It buffers and scores O(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 only false is accepted in 0.x. true returns :unsupported_operation because 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 — a Gitility.Limits override.

history(snapshot, path, opts \\ [])

@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 (default true).
  • :limit, :cursor — pagination.
  • :limits — a Gitility.Limits override.

list_tree(snapshot, path \\ "", opts \\ [])

@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 (default false).
  • :depth — maximum descent depth when recursive.
  • :types — kinds to include, from [:blob, :tree, :symlink, :gitlink] (default: all).
  • :pathspecs — Patterns are resolved relative to path; 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 (see Gitility.Page).
  • :limits — a Gitility.Limits override.

Symlinks are never followed; gitlinks are returned as entries, never opened.

log(snapshot, opts \\ [])

@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 (default false).
  • :since / :until — commit-time bounds (Unix seconds or DateTime).
  • :limit, :cursor — pagination.
  • :limits — a Gitility.Limits override.

merge_base(store, left_oid, right_oid, opts \\ [])

@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.

peel(store, oid, opts \\ [])

@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).

ping()

@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.

read_file(snapshot, path, opts \\ [])

@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 inclusive Range to 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 with truncated: true.
  • :limits — a Gitility.Limits override.

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.

search(snapshot, query, opts \\ [])

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 \xNN escapes. Backreferences and lookaround return {:error, %Gitility.Error{code: :unsupported_regex}} — there is no backtracking fallback.
  • :case_sensitive — default true.
  • :path — restrict to a subtree (raw bytes).
  • :pathspecs — glob patterns filtering candidate files.
  • :binary:skip (default) or :text to scan binary blobs as bytes.
  • :context_lines — context lines around each match (default 0).
  • :limit, :cursor — pagination.
  • :limits — a Gitility.Limits override.

submodules(snapshot, opts \\ [])

@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.