//! Thin wrappers around the system `git` binary.
//!
//! Like Gitea, Ferrit does not reimplement git; it stores bare repositories on
//! disk and shells out to `git` for inspection and for the smart-HTTP transport.
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct TreeEntry {
pub kind: String, // "tree" or "blob"
pub name: String,
}
pub struct CommitInfo {
pub short: String,
pub author: String,
pub date: String,
pub subject: String,
}
/// Absolute path to the bare repository on disk: `data/repos/<owner>/<name>.git`.
pub fn repo_path(data_dir: &Path, owner: &str, name: &str) -> PathBuf {
let name = name.trim_end_matches(".git");
data_dir
.join("repos")
.join(owner)
.join(format!("{name}.git"))
}
pub fn init_bare(path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let out = Command::new("git")
.arg("init")
.arg("--bare")
.arg("--initial-branch=main")
.arg(path)
.output()?;
if !out.status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
String::from_utf8_lossy(&out.stderr).to_string(),
));
}
// Allow push over smart-HTTP (git http-backend disables receive-pack by default).
let _ = Command::new("git")
.arg("-C")
.arg(path)
.args(["config", "http.receivepack", "true"])
.output();
Ok(())
}
fn git(repo: &Path, args: &[&str]) -> Option<Vec<u8>> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.ok()?;
if out.status.success() {
Some(out.stdout)
} else {
None
}
}
fn git_str(repo: &Path, args: &[&str]) -> Option<String> {
git(repo, args).map(|b| String::from_utf8_lossy(&b).to_string())
}
/// Returns the symbolic ref that HEAD points to, e.g. "main". `None` if the
/// repository has no commits yet.
pub fn default_branch(repo: &Path) -> Option<String> {
// Fails on an empty repo (HEAD points to an unborn branch).
let head = git_str(repo, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let head = head.trim().to_string();
// Confirm the branch actually resolves to a commit.
git_str(repo, &["rev-parse", "--verify", "HEAD"])?;
Some(head)
}
pub fn is_empty(repo: &Path) -> bool {
default_branch(repo).is_none()
}
/// List the entries of a tree at `rev`/`path`. `path` may be empty for the root.
pub fn ls_tree(repo: &Path, rev: &str, path: &str) -> Vec<TreeEntry> {
let spec = if path.is_empty() {
format!("{rev}:")
} else {
format!("{rev}:{path}")
};
let raw = match git_str(repo, &["ls-tree", "--long", &spec]) {
Some(r) => r,
None => return Vec::new(),
};
let mut entries = Vec::new();
for line in raw.lines() {
// Format: "<mode> <type> <hash> <size>\t<name>"
let (meta, name) = match line.split_once('\t') {
Some(x) => x,
None => continue,
};
let mut parts = meta.split_whitespace();
let _mode = parts.next().unwrap_or("");
let kind = parts.next().unwrap_or("").to_string();
entries.push(TreeEntry {
kind,
name: name.to_string(),
});
}
// Directories first, then files, each alphabetically.
entries.sort_by(|a, b| {
let ad = a.kind == "tree";
let bd = b.kind == "tree";
bd.cmp(&ad).then(a.name.cmp(&b.name))
});
entries
}
/// Read a blob at `rev`/`path`. Returns raw bytes.
pub fn read_blob(repo: &Path, rev: &str, path: &str) -> Option<Vec<u8>> {
git(repo, &["show", &format!("{rev}:{path}")])
}
pub fn log(repo: &Path, rev: &str, limit: usize) -> Vec<CommitInfo> {
let fmt = "%h%x1f%an%x1f%ad%x1f%s";
let raw = match git_str(
repo,
&[
"log",
&format!("--max-count={limit}"),
"--date=short",
&format!("--pretty=format:{fmt}"),
rev,
],
) {
Some(r) => r,
None => return Vec::new(),
};
raw.lines()
.filter_map(|line| {
let mut f = line.split('\u{1f}');
Some(CommitInfo {
short: f.next()?.to_string(),
author: f.next()?.to_string(),
date: f.next()?.to_string(),
subject: f.next().unwrap_or("").to_string(),
})
})
.collect()
}
pub fn commit_count(repo: &Path, rev: &str) -> usize {
git_str(repo, &["rev-list", "--count", rev])
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}