defmodule GitGud.Git.Cli do
@moduledoc """
Shell-out backend for git. Slow (fork per call) but correct and
dependency-free. Acts as the floor of `GitGud.Git`.
"""
@behaviour GitGud.Git.Backend
alias GitGud.Git
@impl true
def available?, do: System.find_executable("git") != nil
@impl true
def init_bare(path) do
File.mkdir_p!(path)
case System.cmd("git", ["init", "--bare", "--initial-branch=main", path],
stderr_to_stdout: true
) do
{_out, 0} -> :ok
{out, _} -> {:error, {:git_init, out}}
end
end
@impl true
def list_refs(path) do
case git(path, [
"for-each-ref",
"--format=%(refname)%00%(objectname)%00%(*objectname)%00%(objecttype)",
"refs/heads/",
"refs/tags/"
]) do
{:ok, out} ->
refs =
out
|> String.split("\n", trim: true)
|> Enum.map(&parse_ref_line/1)
|> Enum.reject(&is_nil/1)
{:ok, refs}
err ->
err
end
end
defp parse_ref_line(line) do
case String.split(line, "\0") do
[name, target_hex, peeled_hex, type] ->
with {:ok, target} <- Git.from_hex(target_hex) do
%{
name: name,
target_sha: target,
peeled_sha: hex_to_sha_or_nil(peeled_hex),
ref_type: ref_type(name, type)
}
else
_ -> nil
end
_ ->
nil
end
end
defp hex_to_sha_or_nil(""), do: nil
defp hex_to_sha_or_nil(hex) do
case Git.from_hex(hex) do
{:ok, sha} -> sha
:error -> nil
end
end
defp ref_type("refs/tags/" <> _, _), do: "tag"
defp ref_type(_, _), do: "branch"
@impl true
def log(path, start_sha, opts) do
limit = Keyword.get(opts, :limit, 50)
skip = Keyword.get(opts, :skip, 0)
paths = Keyword.get(opts, :paths, [])
args =
[
"log",
"--pretty=format:%x01%H%x00%T%x00%P%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B%x02",
"-n",
to_string(limit),
"--skip",
to_string(skip),
Git.to_hex(start_sha)
] ++ if(paths == [], do: [], else: ["--" | paths])
case git(path, args) do
{:ok, out} -> {:ok, parse_commits(out)}
err -> err
end
end
@impl true
def commit(path, sha) do
case log(path, sha, limit: 1) do
{:ok, [c]} -> {:ok, c}
{:ok, []} -> {:error, :not_found}
err -> err
end
end
defp parse_commits(out) do
out
|> String.split(<<2>>, trim: true)
|> Enum.map(&parse_commit_record/1)
|> Enum.reject(&is_nil/1)
end
# `git log` separates commit records with a literal newline; only the
# first record starts with our `\x01` sentinel at byte 0. Strip
# leading whitespace before checking so subsequent records parse too.
defp parse_commit_record(record) do
case String.trim_leading(record) do
<<1, rest::binary>> -> parse_commit_fields(rest)
_ -> nil
end
end
defp parse_commit_fields(record) do
case String.split(record, <<0>>, parts: 10) do
[sha_hex, tree_hex, parents_str, an, ae, ai, cn, ce, ci, message] ->
with {:ok, sha} <- Git.from_hex(sha_hex),
{:ok, tree_sha} <- Git.from_hex(tree_hex) do
%{
sha: sha,
tree_sha: tree_sha,
parent_shas: parse_parents(parents_str),
author_name: an,
author_email: ae,
authored_at: parse_dt(ai),
committer_name: cn,
committer_email: ce,
committed_at: parse_dt(ci),
message: String.trim_trailing(message, "\n")
}
else
_ -> nil
end
_ ->
nil
end
end
defp parse_parents(""), do: []
defp parse_parents(s) do
s
|> String.split(" ", trim: true)
|> Enum.map(&Git.from_hex/1)
|> Enum.flat_map(fn
{:ok, sha} -> [sha]
:error -> []
end)
end
defp parse_dt(s) do
case DateTime.from_iso8601(s) do
{:ok, dt, _} -> DateTime.truncate(dt, :second)
_ -> nil
end
end
@impl true
def tree(path, sha) do
case git(path, ["ls-tree", "--long", "-z", Git.to_hex(sha)]) do
{:ok, out} ->
entries =
out
|> String.split(<<0>>, trim: true)
|> Enum.map(&parse_tree_entry/1)
|> Enum.reject(&is_nil/1)
{:ok, entries}
err ->
err
end
end
# ls-tree --long format: "<mode> SP <type> SP <sha> SP* <size> TAB <name>"
# `--long` pads size with spaces; size is "-" for tree entries.
defp parse_tree_entry(line) do
with [meta, name] <- String.split(line, "\t", parts: 2),
[mode, type, sha_hex, size_str] <- String.split(meta, ~r/\s+/, trim: true, parts: 4),
{:ok, sha} <- Git.from_hex(sha_hex) do
%{
name: name,
mode: mode,
type: type,
sha: sha,
size: parse_size(size_str)
}
else
_ -> nil
end
end
defp parse_size("-"), do: nil
defp parse_size(s) do
case Integer.parse(s) do
{n, _} -> n
:error -> nil
end
end
@impl true
def blob_bytes(path, sha) do
case System.cmd("git", ["-C", path, "cat-file", "blob", Git.to_hex(sha)],
stderr_to_stdout: false
) do
{bytes, 0} -> {:ok, bytes}
{err, _} -> {:error, {:git_cat_file, err}}
end
end
@impl true
def blob_meta(path, sha) do
hex = Git.to_hex(sha)
with {:ok, size_str} <- git(path, ["cat-file", "-s", hex]),
{size, _} <- Integer.parse(String.trim(size_str)) do
is_binary? = blob_is_binary?(path, hex)
{:ok, %{sha: sha, size: size, is_binary: is_binary?}}
else
_ -> {:error, :blob_meta_failed}
end
end
defp blob_is_binary?(path, hex) do
case System.cmd("git", ["-C", path, "cat-file", "blob", hex], stderr_to_stdout: false) do
{bytes, 0} ->
# Sample the first 8KB; null byte = binary, same heuristic git uses.
head = binary_part(bytes, 0, min(byte_size(bytes), 8192))
:binary.match(head, <<0>>) != :nomatch
_ ->
false
end
end
@impl true
def diff(path, base_sha, head_sha, opts) do
context = Keyword.get(opts, :context, 3)
args =
case base_sha do
nil ->
# Root commit — show against the empty tree to get a full diff
# of every blob.
["show", "--no-color", "--format=", "-U#{context}", Git.to_hex(head_sha)]
bs ->
["diff", "--no-color", "-U#{context}", Git.to_hex(bs), Git.to_hex(head_sha)]
end
case git(path, args) do
{:ok, out} -> {:ok, out}
err -> err
end
end
@impl true
def diff_stats(path, base_sha, head_sha) do
args =
case base_sha do
nil -> ["diff-tree", "--no-commit-id", "--numstat", "-r", "--root", Git.to_hex(head_sha)]
bs -> ["diff", "--numstat", Git.to_hex(bs), Git.to_hex(head_sha)]
end
case git(path, args) do
{:ok, out} ->
rows =
out
|> String.split("\n", trim: true)
|> Enum.map(&String.split(&1, "\t", parts: 3))
|> Enum.flat_map(fn
[ins, del, path] -> [{parse_int(ins), parse_int(del), path}]
_ -> []
end)
{ins, del} =
Enum.reduce(rows, {0, 0}, fn {i, d, _}, {ai, ad} -> {ai + i, ad + d} end)
paths = Enum.map(rows, fn {_, _, p} -> p end)
{:ok,
%{
files_changed: length(paths),
insertions: ins,
deletions: del,
paths: paths
}}
err ->
err
end
end
defp parse_int("-"), do: 0
defp parse_int(s), do: String.to_integer(s)
@impl true
def resolve(_path, <<_::binary-size(20)>> = sha), do: {:ok, sha}
def resolve(path, ref) when is_binary(ref) do
case System.cmd("git", ["-C", path, "rev-parse", "--verify", "--quiet", ref],
stderr_to_stdout: true
) do
{hex, 0} -> Git.from_hex(String.trim(hex)) |> wrap_or_error()
{_out, _} -> {:error, :not_found}
end
end
defp wrap_or_error({:ok, sha}), do: {:ok, sha}
defp wrap_or_error(:error), do: {:error, :invalid_sha}
# ── merge plumbing ───────────────────────────────────────────────────
@impl true
def merge_base(path, a, b) do
case git(path, ["merge-base", Git.to_hex(a), Git.to_hex(b)]) do
{:ok, hex} -> Git.from_hex(String.trim(hex)) |> wrap_or_error()
err -> err
end
end
@impl true
def merge_tree(path, base, a, b) do
args = [
"merge-tree",
"--write-tree",
"--merge-base=" <> Git.to_hex(base),
Git.to_hex(a),
Git.to_hex(b)
]
case System.cmd("git", ["-C", path | args], stderr_to_stdout: true) do
{out, 0} ->
out
|> String.trim()
|> String.split("\n", parts: 2)
|> List.first()
|> Git.from_hex()
|> wrap_or_error()
# exit 1 from merge-tree indicates conflicts
{_out, 1} ->
{:error, :conflict}
{err, code} ->
{:error, {:git_exit, code, err}}
end
end
@impl true
def commit_tree(path, tree_sha, parents, message, opts) do
{author_name, author_email, dt} =
Keyword.get(opts, :author) || default_identity()
iso = DateTime.to_iso8601(dt || DateTime.utc_now(:second))
env = [
{"GIT_AUTHOR_NAME", author_name},
{"GIT_AUTHOR_EMAIL", author_email},
{"GIT_AUTHOR_DATE", iso},
{"GIT_COMMITTER_NAME", author_name},
{"GIT_COMMITTER_EMAIL", author_email},
{"GIT_COMMITTER_DATE", iso}
]
parent_args = parents |> Enum.flat_map(&["-p", Git.to_hex(&1)])
case System.cmd(
"git",
["-C", path, "commit-tree", Git.to_hex(tree_sha) | parent_args] ++ ["-m", message],
stderr_to_stdout: false,
env: env
) do
{hex, 0} -> Git.from_hex(String.trim(hex)) |> wrap_or_error()
{err, code} -> {:error, {:git_exit, code, err}}
end
end
defp default_identity, do: {"GitGud", "noreply@gitgud.local", DateTime.utc_now(:second)}
@impl true
def update_ref(path, ref, new_sha, expected_old) do
args =
case expected_old do
nil -> ["update-ref", ref, Git.to_hex(new_sha)]
old -> ["update-ref", ref, Git.to_hex(new_sha), Git.to_hex(old)]
end
case System.cmd("git", ["-C", path | args], stderr_to_stdout: true) do
{_, 0} -> :ok
{err, code} -> {:error, {:git_exit, code, err}}
end
end
# ── helpers ──────────────────────────────────────────────────────────
defp git(path, args) do
case System.cmd("git", ["-C", path | args], stderr_to_stdout: false) do
{out, 0} -> {:ok, out}
{err, code} -> {:error, {:git_exit, code, err}}
end
end
end
10.8 KiB · text
5af55d9