Ferrit Explore
中文·繁體·EN·日本語 Sign in Register
cielxl / ferrit / src / gitio.rs
//! 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)
}