git-ward 0.2.0

Proof-before-delete archival for local Git repositories
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Read;
use std::path::Path;
use std::process::Command;

pub fn create(repo: &Path, dest: &Path) -> Result<()> {
    let output = Command::new("git")
        .args(["bundle", "create"])
        .arg(dest)
        .arg("--all")
        .current_dir(repo)
        .output()
        .context("Failed to run git bundle create")?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr).to_string();
        bail!("git bundle create failed: {err}");
    }
    if !dest.exists() {
        bail!("Bundle was not created at {}", dest.display());
    }
    Ok(())
}

pub fn verify(bundle: &Path) -> Result<()> {
    let output = Command::new("git")
        .args(["bundle", "verify"])
        .arg(bundle)
        .output()
        .context("Failed to run git bundle verify")?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr).to_string();
        bail!("git bundle verify failed: {err}");
    }
    Ok(())
}

pub fn sha256_file(path: &Path) -> Result<String> {
    let mut file = fs::File::open(path)
        .with_context(|| format!("Failed to open {}", path.display()))?;
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 65536];
    loop {
        let n = file.read(&mut buf)?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Ok(hex::encode(hasher.finalize()))
}

pub fn restore_clone(bundle: &Path, target: &Path) -> Result<()> {
    let parent = target
        .parent()
        .context("Could not determine parent directory for restore target")?;
    fs::create_dir_all(parent)?;
    let output = Command::new("git")
        .arg("clone")
        .arg(bundle)
        .arg(target)
        .output()
        .context("Failed to run git clone from bundle")?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr).to_string();
        bail!("git clone from bundle failed: {err}");
    }
    Ok(())
}

pub fn fetch_custom_refs(bundle: &Path, repo: &Path, refspec: &str) -> Result<()> {
    let output = Command::new("git")
        .args(["fetch"])
        .arg(bundle)
        .arg(format!("{refspec}:{refspec}"))
        .current_dir(repo)
        .output()
        .context("Failed to fetch custom refs from bundle")?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr).to_string();
        bail!("fetch custom refs failed: {err}");
    }
    Ok(())
}

pub fn verify_by_clone(bundle: &Path) -> Result<VerifiedRefs> {
    let tmp = tempfile::tempdir().context("Failed to create temp dir for verify")?;
    let target = tmp.path().join("verify");
    let output = Command::new("git")
        .arg("clone")
        .arg("--mirror")
        .arg(bundle)
        .arg(&target)
        .output()
        .context("Failed to run git clone for verification")?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr).to_string();
        bail!("verification clone failed: {err}");
    }

    let refs_output = Command::new("git")
        .args(["for-each-ref", "--format=%(refname) %(objectname)"])
        .current_dir(&target)
        .output()
        .context("Failed to list refs in verification clone")?;
    let head_output = Command::new("git")
        .args(["rev-parse", "HEAD"])
        .current_dir(&target)
        .output()
        .ok();

    let refs_text = String::from_utf8_lossy(&refs_output.stdout).to_string();
    let mut refs = Vec::new();
    for line in refs_text.lines() {
        let mut parts = line.splitn(2, ' ');
        let name = parts.next().unwrap_or("").to_string();
        let sha = parts.next().unwrap_or("").to_string();
        if !name.is_empty() && !sha.is_empty() {
            refs.push((name, sha));
        }
    }

    let head = head_output
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .filter(|s| !s.is_empty());

    Ok(VerifiedRefs { refs, head })
}

pub struct VerifiedRefs {
    pub refs: Vec<(String, String)>,
    pub head: Option<String>,
}