defmodule GitGud.Git do
@moduledoc """
Facade for low-level git operations.
Two backends:
* `GitGud.Git.Cli` — shells out to the system `git` binary. Always
available, slower under load.
* `GitGud.Git.Nif` — gitoxide via Rustler. Fast, in-process. Loaded
when the native crate is compiled; otherwise falls back to Cli.
Pick via `config :git_gud, GitGud.Git, backend: GitGud.Git.Cli`.
Default is whichever backend reports `available?/0 == true`,
preferring Nif.
"""
@type sha :: <<_::160>>
@type repo_path :: String.t()
@type ref :: %{
name: String.t(),
target_sha: sha(),
peeled_sha: sha() | nil,
ref_type: String.t()
}
@type commit :: %{
sha: sha(),
tree_sha: sha(),
parent_shas: [sha()],
author_name: String.t(),
author_email: String.t(),
authored_at: DateTime.t(),
committer_name: String.t(),
committer_email: String.t(),
committed_at: DateTime.t(),
message: String.t()
}
@type tree_entry :: %{
name: String.t(),
mode: String.t(),
type: String.t(),
sha: sha(),
size: integer() | nil
}
@type blob_meta :: %{
sha: sha(),
size: integer(),
is_binary: boolean()
}
@type diff_stat :: %{
files_changed: integer(),
insertions: integer(),
deletions: integer(),
paths: [String.t()]
}
@doc "Initialize a bare repo at the given path. Idempotent."
@spec init_bare(repo_path()) :: :ok | {:error, term()}
def init_bare(path), do: dispatch(:init_bare, [path])
@doc "Resolve all refs (branches + tags) in a repo."
@spec list_refs(repo_path()) :: {:ok, [ref()]} | {:error, term()}
def list_refs(path), do: dispatch(:list_refs, [path])
@doc """
Walk commits starting from `start_sha`. Honors `:limit` and `:skip`,
newest first.
"""
@spec log(repo_path(), sha(), keyword()) :: {:ok, [commit()]} | {:error, term()}
def log(path, start_sha, opts \\ []), do: dispatch(:log, [path, start_sha, opts])
@doc "Load a single commit by SHA."
@spec commit(repo_path(), sha()) :: {:ok, commit()} | {:error, term()}
def commit(path, sha), do: dispatch(:commit, [path, sha])
@doc "List the entries of a tree object."
@spec tree(repo_path(), sha()) :: {:ok, [tree_entry()]} | {:error, term()}
def tree(path, sha), do: dispatch(:tree, [path, sha])
@doc "Resolve a path within a tree to its child entry."
@spec tree_entry_at(repo_path(), sha(), String.t()) ::
{:ok, tree_entry()} | {:error, :not_found | term()}
def tree_entry_at(_path, tree_sha, ""), do: {:ok, root_entry(tree_sha)}
def tree_entry_at(_path, tree_sha, "/"), do: {:ok, root_entry(tree_sha)}
def tree_entry_at(path, tree_sha, subpath) do
segments = subpath |> String.trim_leading("/") |> String.split("/", trim: true)
descend(path, tree_sha, segments)
end
defp descend(_path, sha, []), do: {:ok, root_entry(sha)}
defp descend(path, sha, [seg | rest]) do
with {:ok, entries} <- tree(path, sha),
%{} = entry <- Enum.find(entries, &(&1.name == seg)) do
case {entry.type, rest} do
{"tree", []} -> {:ok, entry}
{"tree", more} -> descend(path, entry.sha, more)
{_, []} -> {:ok, entry}
{_, _more} -> {:error, :not_found}
end
else
nil -> {:error, :not_found}
err -> err
end
end
defp root_entry(sha),
do: %{name: "", mode: "040000", type: "tree", sha: sha, size: nil}
@doc "Load blob bytes."
@spec blob_bytes(repo_path(), sha()) :: {:ok, binary()} | {:error, term()}
def blob_bytes(path, sha), do: dispatch(:blob_bytes, [path, sha])
@doc "Cheap blob metadata without reading the full body when possible."
@spec blob_meta(repo_path(), sha()) :: {:ok, blob_meta()} | {:error, term()}
def blob_meta(path, sha), do: dispatch(:blob_meta, [path, sha])
@doc "Diff summary stats between two commits (or any two trees)."
@spec diff_stats(repo_path(), sha() | nil, sha()) ::
{:ok, diff_stat()} | {:error, term()}
def diff_stats(path, base_sha, head_sha),
do: dispatch(:diff_stats, [path, base_sha, head_sha])
@doc """
Full unified diff text between two commits (or root → head when
`base_sha` is nil). Caller is expected to pipe through
`GitGud.Git.DiffParser.parse/1` for the structured form.
Context size defaults to 3 lines but can be overridden with
`context: N` in `opts`.
"""
@spec diff(repo_path(), sha() | nil, sha(), keyword()) ::
{:ok, String.t()} | {:error, term()}
def diff(path, base_sha, head_sha, opts \\ []),
do: dispatch(:diff, [path, base_sha, head_sha, opts])
@doc """
Resolve a ref name or partial SHA into a full commit SHA.
Accepts: "main", "refs/heads/main", "abc1234", full SHA as hex or binary.
"""
@spec resolve(repo_path(), String.t() | sha()) :: {:ok, sha()} | {:error, term()}
def resolve(path, ref_or_sha), do: dispatch(:resolve, [path, ref_or_sha])
@doc "Best-common-ancestor of two commits."
@spec merge_base(repo_path(), sha(), sha()) :: {:ok, sha()} | {:error, term()}
def merge_base(path, a, b), do: dispatch(:merge_base, [path, a, b])
@doc """
Three-way merge probe. Returns the resulting tree SHA on success, or
`{:error, :conflict}` if the merge would conflict.
"""
@spec merge_tree(repo_path(), sha(), sha(), sha()) ::
{:ok, sha()} | {:error, :conflict | term()}
def merge_tree(path, base, a, b), do: dispatch(:merge_tree, [path, base, a, b])
@doc """
Create a commit object from an existing tree and parent list. `author`
is `{name, email, datetime}`. Returns the new commit SHA.
"""
@spec commit_tree(repo_path(), sha(), [sha()], String.t(), keyword()) ::
{:ok, sha()} | {:error, term()}
def commit_tree(path, tree_sha, parents, message, opts \\ []),
do: dispatch(:commit_tree, [path, tree_sha, parents, message, opts])
@doc """
Atomically move a ref from `expected_old` to `new_sha`. Pass `nil` for
`expected_old` to allow ref creation. Fails if the current value
doesn't match `expected_old`.
"""
@spec update_ref(repo_path(), String.t(), sha(), sha() | nil) ::
:ok | {:error, term()}
def update_ref(path, ref, new_sha, expected_old),
do: dispatch(:update_ref, [path, ref, new_sha, expected_old])
# ── SHA helpers ──────────────────────────────────────────────────────
@doc "Decode a 40-char hex string into a 20-byte SHA."
@spec from_hex(String.t()) :: {:ok, sha()} | :error
def from_hex(hex) when is_binary(hex) and byte_size(hex) == 40 do
case Base.decode16(hex, case: :mixed) do
{:ok, sha} -> {:ok, sha}
:error -> :error
end
end
def from_hex(_), do: :error
@doc "Encode a 20-byte SHA into a 40-char lowercase hex string."
@spec to_hex(sha()) :: String.t()
def to_hex(<<sha::binary-size(20)>>), do: Base.encode16(sha, case: :lower)
@doc "Shorten a SHA (raw bin or hex) to 7-char hex."
@spec short(sha() | String.t()) :: String.t()
def short(<<_::binary-size(20)>> = sha), do: sha |> to_hex() |> binary_part(0, 7)
def short(hex) when is_binary(hex), do: binary_part(hex, 0, 7)
# ── backend dispatch ─────────────────────────────────────────────────
#
# Try the primary backend first; on `{:error, :unsupported}` from the
# NIF, fall back to Cli per-operation. This lets the Nif backend
# implement only its hot paths while everything else still works.
defp dispatch(fun, args) do
primary = primary_backend()
case apply(primary, fun, args) do
{:error, :unsupported} when primary != GitGud.Git.Cli ->
apply(GitGud.Git.Cli, fun, args)
other ->
other
end
end
defp primary_backend do
Application.get_env(:git_gud, __MODULE__, [])
|> Keyword.get(:backend)
|> case do
nil -> pick_backend()
mod -> mod
end
end
defp pick_backend do
if Code.ensure_loaded?(GitGud.Git.Nif) and
apply(GitGud.Git.Nif, :available?, []) do
GitGud.Git.Nif
else
GitGud.Git.Cli
end
end
@doc "Inspect the resolved primary backend (debug/observability)."
def current_backend, do: primary_backend()
end
8.3 KiB · text
5af55d9