// Rustler NIF bridging GitGud.Git to gitoxide.
//
// Conventions:
// * SHAs cross the boundary as 20-byte binaries (never hex).
// * Returns {:ok, _} | {:error, reason :: atom | binary}.
// * Operations not yet implemented return {:error, :unsupported};
// the Elixir facade falls back to the Cli backend per-operation
// when it sees that.
// * Result shapes are atom-keyed Elixir maps so they line up with
// what the Cli backend produces.
use rustler::{Binary, Encoder, Env, NifResult, OwnedBinary, Term};
use std::path::Path;
mod atoms {
rustler::atoms! {
ok,
error,
not_found,
invalid_sha,
gix_error,
unsupported,
// commit / tree map keys
name,
target_sha,
peeled_sha,
ref_type,
sha,
tree_sha,
parent_shas,
author_name,
author_email,
authored_at,
committer_name,
committer_email,
committed_at,
message,
mode,
size,
is_binary,
// diff_stats keys
files_changed,
insertions,
deletions,
paths,
// %DateTime{} fields
calendar,
year,
month,
day,
hour,
minute,
second,
microsecond,
std_offset,
utc_offset,
zone_abbr,
time_zone,
// `:type` is a reserved word in Elixir; the rustler crate
// exposes raw atom strings via these:
struct_key = "__struct__",
type_key = "type",
elixir_datetime = "Elixir.DateTime",
calendar_iso = "Elixir.Calendar.ISO",
etc_utc = "Etc/UTC",
utc = "UTC",
}
}
// ── plumbing ─────────────────────────────────────────────────────────
#[rustler::nif]
fn nif_loaded() -> bool {
true
}
fn open(path: &str) -> Result<gix::Repository, String> {
gix::open(path).map_err(|e| format!("open: {e}"))
}
fn sha_from_bin(bin: &Binary) -> Result<gix::ObjectId, String> {
if bin.as_slice().len() != 20 {
return Err("sha must be 20 bytes".into());
}
let mut buf = [0u8; 20];
buf.copy_from_slice(bin.as_slice());
Ok(gix::ObjectId::from(buf))
}
fn sha_to_binary<'a>(env: Env<'a>, sha: &gix::ObjectId) -> Term<'a> {
let mut bin = OwnedBinary::new(20).expect("alloc 20-byte sha");
bin.as_mut_slice().copy_from_slice(sha.as_slice());
bin.release(env).encode(env)
}
// Build an atom-keyed Elixir map from `(atom, term)` pairs.
fn map_of<'a>(env: Env<'a>, entries: &[(rustler::Atom, Term<'a>)]) -> Term<'a> {
let mut m = rustler::types::map::map_new(env);
for (k, v) in entries {
m = m.map_put(k.encode(env), *v).expect("map_put");
}
m
}
// Build an `%Elixir.DateTime{}` term in UTC from a unix-seconds value.
// gix stores commit time as (seconds, offset_seconds); we throw away
// the offset and normalize to UTC for the cross-backend contract. The
// shape matches what `DateTime.from_unix!(seconds)` produces.
fn unix_to_datetime_term<'a>(env: Env<'a>, secs: i64) -> Term<'a> {
use chrono::{Datelike, Timelike};
let dt =
chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0).unwrap_or_else(|| {
chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap()
});
let microsec_tuple = (0i32, 0i32).encode(env);
map_of(
env,
&[
(atoms::struct_key(), atoms::elixir_datetime().encode(env)),
(atoms::calendar(), atoms::calendar_iso().encode(env)),
(atoms::year(), (dt.year() as i32).encode(env)),
(atoms::month(), (dt.month() as u32).encode(env)),
(atoms::day(), (dt.day() as u32).encode(env)),
(atoms::hour(), (dt.hour() as u32).encode(env)),
(atoms::minute(), (dt.minute() as u32).encode(env)),
(atoms::second(), (dt.second() as u32).encode(env)),
(atoms::microsecond(), microsec_tuple),
(atoms::std_offset(), 0i32.encode(env)),
(atoms::utc_offset(), 0i32.encode(env)),
// Strings, not atoms — Ecto's `check_utc_timezone!` does a
// string equality check against "Etc/UTC".
(atoms::zone_abbr(), "UTC".encode(env)),
(atoms::time_zone(), "Etc/UTC".encode(env)),
],
)
}
// ── init_bare ────────────────────────────────────────────────────────
#[rustler::nif]
fn init_bare<'a>(env: Env<'a>, path: String) -> NifResult<Term<'a>> {
// Mirror Cli: create the parent directory tree so callers can
// hand us a fresh nested path.
if let Err(e) = std::fs::create_dir_all(&path) {
return Ok((atoms::error(), format!("mkdir: {e}")).encode(env));
}
match gix::init_bare(Path::new(&path)) {
Ok(_) => Ok(atoms::ok().encode(env)),
Err(e) => Ok((atoms::error(), format!("{e}")).encode(env)),
}
}
// ── list_refs ────────────────────────────────────────────────────────
#[rustler::nif]
fn list_refs<'a>(env: Env<'a>, path: String) -> NifResult<Term<'a>> {
let repo = match open(&path) {
Ok(r) => r,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let refs = match repo.references() {
Ok(r) => r,
Err(e) => return Ok((atoms::error(), format!("{e}")).encode(env)),
};
let iter = match refs.all() {
Ok(it) => it,
Err(e) => return Ok((atoms::error(), format!("{e}")).encode(env)),
};
let mut out: Vec<Term<'a>> = Vec::new();
for reference in iter {
let mut reference = match reference {
Ok(r) => r,
Err(_) => continue,
};
let nm = reference.name().as_bstr().to_string();
let target: gix::ObjectId = match reference.peel_to_id_in_place() {
Ok(id) => id.detach(),
Err(_) => continue,
};
let rt = if nm.starts_with("refs/tags/") {
"tag"
} else {
"branch"
};
out.push(map_of(
env,
&[
(atoms::name(), nm.encode(env)),
(atoms::target_sha(), sha_to_binary(env, &target)),
(atoms::peeled_sha(), rustler::types::atom::nil().encode(env)),
(atoms::ref_type(), rt.encode(env)),
],
));
}
Ok((atoms::ok(), out).encode(env))
}
// ── blob ─────────────────────────────────────────────────────────────
#[rustler::nif]
fn blob_bytes<'a>(env: Env<'a>, path: String, sha: Binary) -> NifResult<Term<'a>> {
let repo = match open(&path) {
Ok(r) => r,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let id = match sha_from_bin(&sha) {
Ok(i) => i,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let obj = match repo.find_object(id) {
Ok(o) => o,
Err(_) => return Ok((atoms::error(), atoms::not_found()).encode(env)),
};
let mut bin = OwnedBinary::new(obj.data.len()).expect("alloc blob");
bin.as_mut_slice().copy_from_slice(&obj.data);
Ok((atoms::ok(), bin.release(env)).encode(env))
}
#[rustler::nif]
fn blob_meta<'a>(env: Env<'a>, path: String, sha: Binary) -> NifResult<Term<'a>> {
let repo = match open(&path) {
Ok(r) => r,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let id = match sha_from_bin(&sha) {
Ok(i) => i,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let header = match repo.find_header(id) {
Ok(h) => h,
Err(_) => return Ok((atoms::error(), atoms::not_found()).encode(env)),
};
let total_size = header.size() as i64;
// Sniff binary-ness with the first 8KiB. We re-fetch the object
// body for this; could be optimized later by streaming.
let bytes = repo
.find_object(id)
.map(|o| o.data.clone())
.unwrap_or_default();
let head = &bytes[..bytes.len().min(8192)];
let is_bin = head.contains(&0u8);
let m = map_of(
env,
&[
(atoms::sha(), sha_to_binary(env, &id)),
(atoms::size(), total_size.encode(env)),
(atoms::is_binary(), is_bin.encode(env)),
],
);
Ok((atoms::ok(), m).encode(env))
}
// ── resolve ──────────────────────────────────────────────────────────
#[rustler::nif]
fn resolve<'a>(env: Env<'a>, path: String, ref_str: String) -> NifResult<Term<'a>> {
let repo = match open(&path) {
Ok(r) => r,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
match repo.rev_parse_single(ref_str.as_str()) {
Ok(id) => {
let oid: gix::ObjectId = id.detach();
Ok((atoms::ok(), sha_to_binary(env, &oid)).encode(env))
}
Err(_) => Ok((atoms::error(), atoms::not_found()).encode(env)),
}
}
// ── commit ───────────────────────────────────────────────────────────
#[rustler::nif]
fn commit<'a>(env: Env<'a>, path: String, sha: Binary) -> NifResult<Term<'a>> {
let repo = match open(&path) {
Ok(r) => r,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let id = match sha_from_bin(&sha) {
Ok(i) => i,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
match commit_to_map(env, &repo, id) {
Ok(m) => Ok((atoms::ok(), m).encode(env)),
Err(e) => Ok((atoms::error(), e).encode(env)),
}
}
fn commit_to_map<'a>(
env: Env<'a>,
repo: &gix::Repository,
id: gix::ObjectId,
) -> Result<Term<'a>, Term<'a>> {
let obj = repo
.find_object(id)
.map_err(|_| atoms::not_found().encode(env))?;
let commit = obj
.try_into_commit()
.map_err(|_| atoms::not_found().encode(env))?;
let decoded = commit
.decode()
.map_err(|e| format!("decode: {e}").encode(env))?;
let tree_sha = decoded.tree();
let parent_terms: Vec<Term<'a>> = decoded
.parents()
.map(|p| sha_to_binary(env, &p))
.collect();
let author = decoded.author;
let committer = decoded.committer;
let message = decoded.message.to_string();
let m = map_of(
env,
&[
(atoms::sha(), sha_to_binary(env, &id)),
(atoms::tree_sha(), sha_to_binary(env, &tree_sha)),
(atoms::parent_shas(), parent_terms.encode(env)),
(atoms::author_name(), author.name.to_string().encode(env)),
(atoms::author_email(), author.email.to_string().encode(env)),
(atoms::authored_at(), unix_to_datetime_term(env, author.time.seconds)),
(atoms::committer_name(), committer.name.to_string().encode(env)),
(atoms::committer_email(), committer.email.to_string().encode(env)),
(atoms::committed_at(), unix_to_datetime_term(env, committer.time.seconds)),
(atoms::message(), message.encode(env)),
],
);
Ok(m)
}
// ── log ──────────────────────────────────────────────────────────────
#[rustler::nif]
fn log<'a>(
env: Env<'a>,
path: String,
start_sha: Binary,
opts: Term<'a>,
) -> NifResult<Term<'a>> {
let repo = match open(&path) {
Ok(r) => r,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let start = match sha_from_bin(&start_sha) {
Ok(i) => i,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let (limit, skip, paths) = decode_log_opts(env, opts);
// `paths` filtering is not implemented in the NIF yet — the Cli
// backend pushes that to `git log -- <paths>`. Drop back to Cli
// when a caller actually uses it.
if !paths.is_empty() {
return Ok((atoms::error(), atoms::unsupported()).encode(env));
}
let walker = match repo.rev_walk([start]).all() {
Ok(w) => w,
Err(e) => return Ok((atoms::error(), format!("{e}")).encode(env)),
};
let mut commits: Vec<Term<'a>> = Vec::new();
let mut seen: usize = 0;
for info in walker {
let info = match info {
Ok(i) => i,
Err(_) => continue,
};
if seen < skip {
seen += 1;
continue;
}
if commits.len() >= limit {
break;
}
let id: gix::ObjectId = info.id.into();
match commit_to_map(env, &repo, id) {
Ok(m) => commits.push(m),
Err(_) => continue,
}
}
Ok((atoms::ok(), commits).encode(env))
}
fn decode_log_opts<'a>(env: Env<'a>, opts: Term<'a>) -> (usize, usize, Vec<String>) {
let mut limit: usize = 50;
let mut skip: usize = 0;
let mut paths: Vec<String> = Vec::new();
// `opts` arrives as an Elixir keyword list — a list of {atom, value}.
if let Ok(list) = opts.decode::<Vec<Term<'a>>>() {
for item in list {
if let Ok((k, v)) = item.decode::<(rustler::Atom, Term<'a>)>() {
let key = k.to_term(env);
let key_str = key.atom_to_string().unwrap_or_default();
match key_str.as_str() {
"limit" => limit = v.decode::<i64>().unwrap_or(50).max(0) as usize,
"skip" => skip = v.decode::<i64>().unwrap_or(0).max(0) as usize,
"paths" => paths = v.decode::<Vec<String>>().unwrap_or_default(),
_ => {}
}
}
}
}
(limit, skip, paths)
}
// ── tree ─────────────────────────────────────────────────────────────
#[rustler::nif]
fn tree<'a>(env: Env<'a>, path: String, sha: Binary) -> NifResult<Term<'a>> {
let repo = match open(&path) {
Ok(r) => r,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let id = match sha_from_bin(&sha) {
Ok(i) => i,
Err(e) => return Ok((atoms::error(), e).encode(env)),
};
let obj = match repo.find_object(id) {
Ok(o) => o,
Err(_) => return Ok((atoms::error(), atoms::not_found()).encode(env)),
};
let tree = match obj.try_into_tree() {
Ok(t) => t,
Err(_) => return Ok((atoms::error(), atoms::not_found()).encode(env)),
};
let decoded = match tree.decode() {
Ok(d) => d,
Err(e) => return Ok((atoms::error(), format!("decode: {e}")).encode(env)),
};
let mut entries: Vec<Term<'a>> = Vec::new();
for entry in decoded.entries.iter() {
let entry_sha: gix::ObjectId = entry.oid.into();
let mode_oct = format!("{:o}", entry.mode.0);
let type_str = entry_type(entry.mode);
// Cheap size: only meaningful for blobs; trees / commits / etc.
// come back as nil to match the Cli backend's `--long` `-` cells.
let size_term: Term<'a> = if type_str == "blob" {
match repo.find_header(entry_sha) {
Ok(h) => (h.size() as i64).encode(env),
Err(_) => rustler::types::atom::nil().encode(env),
}
} else {
rustler::types::atom::nil().encode(env)
};
let entry_map = map_of(
env,
&[
(atoms::name(), entry.filename.to_string().encode(env)),
(atoms::mode(), mode_oct.encode(env)),
(atoms::type_key(), type_str.encode(env)),
(atoms::sha(), sha_to_binary(env, &entry_sha)),
(atoms::size(), size_term),
],
);
entries.push(entry_map);
}
Ok((atoms::ok(), entries).encode(env))
}
fn entry_type(mode: gix::objs::tree::EntryMode) -> &'static str {
use gix::objs::tree::EntryKind::*;
match mode.kind() {
Tree => "tree",
Blob | BlobExecutable => "blob",
Link => "blob", // gitlink targets a blob in the parent's eyes
Commit => "commit", // submodule
}
}
// ── operations still deferred to Cli ─────────────────────────────────
//
// `diff_stats`, `merge_base`, `merge_tree`, `commit_tree`, and
// `update_ref` return `{:error, :unsupported}` so the Elixir facade
// transparently falls back to the Cli backend per-call. gix 0.66's
// public surface for these has churned across recent releases and
// the perf gap is tolerable for now — `commit`, `tree`, and `log` are
// the request-path hot paths.
#[rustler::nif]
fn diff_stats<'a>(
env: Env<'a>,
_path: String,
_base: Term<'a>,
_head: Binary,
) -> NifResult<Term<'a>> {
Ok((atoms::error(), atoms::unsupported()).encode(env))
}
#[rustler::nif]
fn merge_base<'a>(env: Env<'a>, _path: String, _a: Binary, _b: Binary) -> NifResult<Term<'a>> {
Ok((atoms::error(), atoms::unsupported()).encode(env))
}
#[rustler::nif]
fn merge_tree<'a>(
env: Env<'a>,
_path: String,
_base: Binary,
_a: Binary,
_b: Binary,
) -> NifResult<Term<'a>> {
Ok((atoms::error(), atoms::unsupported()).encode(env))
}
#[rustler::nif]
fn commit_tree<'a>(
env: Env<'a>,
_path: String,
_tree_sha: Binary,
_parents: Term<'a>,
_message: String,
_opts: Term<'a>,
) -> NifResult<Term<'a>> {
Ok((atoms::error(), atoms::unsupported()).encode(env))
}
#[rustler::nif]
fn update_ref<'a>(
env: Env<'a>,
_path: String,
_ref: String,
_new_sha: Binary,
_expected_old: Term<'a>,
) -> NifResult<Term<'a>> {
Ok((atoms::error(), atoms::unsupported()).encode(env))
}
rustler::init!("Elixir.GitGud.Git.Nif");
18.0 KiB · text
5af55d9