rns-git 0.1.0

Reticulum Git transport tools
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::protocol::RefUpdate;
use crate::util::validate_repo_name;
use crate::{Error, Result};

pub fn check_git_available() -> Result<()> {
    run(Command::new("git").arg("--version")).map(|_| ())
}

pub fn repository_path(root: &Path, repository: &str) -> Result<PathBuf> {
    validate_repo_name(repository)?;
    Ok(root.join(repository))
}

pub fn ensure_bare_repository(path: &Path) -> Result<()> {
    if path.join("HEAD").exists() && path.join("objects").is_dir() {
        return Ok(());
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    run(Command::new("git").arg("init").arg("--bare").arg(path)).map(|_| ())
}

pub fn list_refs(path: &Path) -> Result<Vec<(String, String)>> {
    require_repository(path)?;
    let output = run(Command::new("git")
        .arg("--git-dir")
        .arg(path)
        .arg("for-each-ref")
        .arg("--format=%(objectname) %(refname)"))?;
    Ok(output
        .lines()
        .filter_map(|line| {
            let (sha, name) = line.split_once(' ')?;
            Some((sha.to_string(), name.to_string()))
        })
        .collect())
}

pub fn list_refs_text(path: &Path) -> Result<Vec<u8>> {
    let mut out = Vec::new();
    for (sha, name) in list_refs(path)? {
        out.extend_from_slice(sha.as_bytes());
        out.push(b' ');
        out.extend_from_slice(name.as_bytes());
        out.push(b'\n');
    }
    Ok(out)
}

pub fn create_bundle(path: &Path, _have: &[String]) -> Result<Vec<u8>> {
    require_repository(path)?;
    if list_refs(path)?.is_empty() {
        return Ok(Vec::new());
    }
    let bundle_path = temp_path("rngit-fetch", "bundle");
    let result = run(Command::new("git")
        .arg("--git-dir")
        .arg(path)
        .arg("bundle")
        .arg("create")
        .arg(&bundle_path)
        .arg("--all"));
    let bytes = match result {
        Ok(_) => fs::read(&bundle_path)?,
        Err(err) => {
            let _ = fs::remove_file(&bundle_path);
            return Err(err);
        }
    };
    let _ = fs::remove_file(&bundle_path);
    Ok(bytes)
}

pub fn apply_push(path: &Path, bundle: &[u8], updates: &[RefUpdate]) -> Result<()> {
    ensure_bare_repository(path)?;
    if !bundle.is_empty() {
        let bundle_path = temp_path("rngit-push", "bundle");
        fs::write(&bundle_path, bundle)?;
        let result = run(Command::new("git")
            .arg("--git-dir")
            .arg(path)
            .arg("fetch")
            .arg(&bundle_path)
            .arg("+refs/heads/*:refs/heads/*")
            .arg("+refs/tags/*:refs/tags/*"));
        let _ = fs::remove_file(&bundle_path);
        result?;
    }

    for update in updates {
        if let Some(new) = update.new.as_deref() {
            update_ref(
                path,
                &update.refname,
                new,
                update.old.as_deref(),
                update.force,
            )?;
        } else {
            delete_ref(path, &update.refname, update.old.as_deref(), update.force)?;
        }
    }
    Ok(())
}

pub fn local_ref_sha(refname: &str) -> Result<Option<String>> {
    let output = Command::new("git")
        .arg("rev-parse")
        .arg("--verify")
        .arg(refname)
        .output()?;
    if !output.status.success() {
        return Ok(None);
    }
    Ok(Some(
        String::from_utf8_lossy(&output.stdout).trim().to_string(),
    ))
}

pub fn create_local_bundle(refs: &[String]) -> Result<Vec<u8>> {
    if refs.is_empty() {
        return Ok(Vec::new());
    }
    let bundle_path = temp_path("rngit-local-push", "bundle");
    let result = run(Command::new("git")
        .arg("bundle")
        .arg("create")
        .arg(&bundle_path)
        .args(refs));
    let bytes = match result {
        Ok(_) => fs::read(&bundle_path)?,
        Err(err) => {
            let _ = fs::remove_file(&bundle_path);
            return Err(err);
        }
    };
    let _ = fs::remove_file(&bundle_path);
    Ok(bytes)
}

pub fn fetch_bundle_into_local(bundle: &[u8], wanted: &[String]) -> Result<()> {
    if bundle.is_empty() {
        return Ok(());
    }
    let bundle_path = temp_path("rngit-local-fetch", "bundle");
    fs::write(&bundle_path, bundle)?;
    let mut cmd = Command::new("git");
    cmd.arg("fetch").arg(&bundle_path);
    if wanted.is_empty() {
        cmd.arg("refs/*:refs/*");
    } else {
        cmd.args(wanted);
    }
    let result = run(&mut cmd);
    let _ = fs::remove_file(&bundle_path);
    result.map(|_| ())
}

fn update_ref(path: &Path, refname: &str, new: &str, old: Option<&str>, force: bool) -> Result<()> {
    let mut cmd = Command::new("git");
    cmd.arg("--git-dir")
        .arg(path)
        .arg("update-ref")
        .arg(refname)
        .arg(new);
    if !force {
        if let Some(old) = old {
            cmd.arg(old);
        }
    }
    run(&mut cmd).map(|_| ())
}

fn delete_ref(path: &Path, refname: &str, old: Option<&str>, force: bool) -> Result<()> {
    let mut cmd = Command::new("git");
    cmd.arg("--git-dir")
        .arg(path)
        .arg("update-ref")
        .arg("-d")
        .arg(refname);
    if !force {
        if let Some(old) = old {
            cmd.arg(old);
        }
    }
    run(&mut cmd).map(|_| ())
}

fn require_repository(path: &Path) -> Result<()> {
    if path.join("HEAD").exists() && path.join("objects").is_dir() {
        Ok(())
    } else {
        Err(Error::msg("repository not found"))
    }
}

fn temp_path(prefix: &str, extension: &str) -> PathBuf {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    std::env::temp_dir().join(format!("{prefix}-{}-{now}.{extension}", std::process::id()))
}

fn run(cmd: &mut Command) -> Result<String> {
    let output = cmd.output()?;
    if output.status.success() {
        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
    }
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    Err(Error::msg(if stderr.is_empty() {
        "git command failed".to_string()
    } else {
        stderr
    }))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bare_repo_can_list_refs() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = tmp.path().join("repo.git");
        ensure_bare_repository(&repo).unwrap();
        assert!(list_refs(&repo).unwrap().is_empty());
    }

    #[test]
    fn repository_paths_reject_traversal() {
        assert!(repository_path(Path::new("/tmp/repos"), "../x").is_err());
        assert!(repository_path(Path::new("/tmp/repos"), "group/repo").is_ok());
    }

    #[test]
    fn bundle_push_roundtrip_updates_bare_repository() {
        let tmp = tempfile::tempdir().unwrap();
        let work = tmp.path().join("work");
        let target = tmp.path().join("target.git");
        fs::create_dir_all(&work).unwrap();

        test_git(Command::new("git").arg("init").arg(&work));
        fs::write(work.join("README.md"), "hello\n").unwrap();
        test_git(
            Command::new("git")
                .arg("-C")
                .arg(&work)
                .arg("add")
                .arg("README.md"),
        );
        test_git(
            Command::new("git")
                .arg("-C")
                .arg(&work)
                .arg("-c")
                .arg("user.name=RNS Test")
                .arg("-c")
                .arg("user.email=rns@example.invalid")
                .arg("commit")
                .arg("-m")
                .arg("init"),
        );
        test_git(
            Command::new("git")
                .arg("-C")
                .arg(&work)
                .arg("branch")
                .arg("-M")
                .arg("main"),
        );
        let sha = test_git(
            Command::new("git")
                .arg("-C")
                .arg(&work)
                .arg("rev-parse")
                .arg("refs/heads/main"),
        );
        let sha = sha.trim().to_string();
        let bundle_path = tmp.path().join("push.bundle");
        test_git(
            Command::new("git")
                .arg("-C")
                .arg(&work)
                .arg("bundle")
                .arg("create")
                .arg(&bundle_path)
                .arg("refs/heads/main"),
        );

        apply_push(
            &target,
            &fs::read(&bundle_path).unwrap(),
            &[RefUpdate {
                refname: "refs/heads/main".into(),
                old: None,
                new: Some(sha.clone()),
                force: true,
            }],
        )
        .unwrap();

        assert_eq!(
            list_refs(&target).unwrap(),
            vec![(sha, "refs/heads/main".into())]
        );
        assert!(!create_bundle(&target, &[]).unwrap().is_empty());
    }

    fn test_git(cmd: &mut Command) -> String {
        let output = cmd.output().unwrap();
        assert!(
            output.status.success(),
            "git command failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8_lossy(&output.stdout).into_owned()
    }
}