repoverse 0.1.1

Multi-repo workspace tool: keep many git repos in sync and roll changes up across dependency boundaries
//! Typed wrapper over the system `git` binary. We never reimplement git.

use anyhow::{bail, Context, Result};
use std::path::Path;
use std::process::Command;

#[derive(Debug, Clone)]
pub struct Output {
    pub status: i32,
    pub stdout: String,
    pub stderr: String,
}

impl Output {
    pub fn ok(&self) -> bool {
        self.status == 0
    }
}

/// Run `git` in `dir` with `args`, capturing output. Does not fail on
/// non-zero exit; inspect [`Output`].
pub fn run(dir: &Path, args: &[&str]) -> Result<Output> {
    let out = Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .with_context(|| format!("spawning git {}", args.join(" ")))?;
    Ok(Output {
        status: out.status.code().unwrap_or(-1),
        stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
        stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
    })
}

/// Run `git` and error with stderr if it exits non-zero.
pub fn check(dir: &Path, args: &[&str]) -> Result<String> {
    let o = run(dir, args)?;
    if !o.ok() {
        bail!("git {} failed: {}", args.join(" "), o.stderr.trim());
    }
    Ok(o.stdout)
}

pub fn is_repo(dir: &Path) -> bool {
    dir.join(".git").exists()
        && run(dir, &["rev-parse", "--is-inside-work-tree"])
            .map(|o| o.ok())
            .unwrap_or(false)
}

pub fn current_branch(dir: &Path) -> Result<String> {
    Ok(check(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?
        .trim()
        .to_string())
}

pub fn head_sha(dir: &Path) -> Result<String> {
    Ok(check(dir, &["rev-parse", "HEAD"])?.trim().to_string())
}

pub fn is_dirty(dir: &Path) -> Result<bool> {
    Ok(!check(dir, &["status", "--porcelain"])?.trim().is_empty())
}

/// `(ahead, behind)` of upstream, or `None` if no upstream.
pub fn ahead_behind(dir: &Path) -> Result<Option<(u32, u32)>> {
    let o = run(
        dir,
        &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
    )?;
    if !o.ok() {
        return Ok(None);
    }
    let mut it = o.stdout.split_whitespace();
    let ahead = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
    let behind = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
    Ok(Some((ahead, behind)))
}

pub fn clone(parent: &Path, url: &str, into: &str) -> Result<()> {
    let o = Command::new("git")
        .current_dir(parent)
        .args(["clone", url, into])
        .output()
        .context("spawning git clone")?;
    if !o.status.success() {
        bail!(
            "git clone {url} failed: {}",
            String::from_utf8_lossy(&o.stderr).trim()
        );
    }
    Ok(())
}

pub fn fetch(dir: &Path) -> Result<()> {
    check(
        dir,
        &[
            "-c",
            "submodule.recurse=false",
            "-c",
            "fetch.recurseSubmodules=false",
            "fetch",
            "--all",
            "--prune",
            "--tags",
            "--recurse-submodules=no",
        ],
    )?;
    Ok(())
}

pub fn checkout(dir: &Path, rev: &str) -> Result<()> {
    check(dir, &["checkout", rev])?;
    Ok(())
}

pub fn checkout_new_branch(dir: &Path, branch: &str) -> Result<()> {
    check(dir, &["checkout", "-B", branch])?;
    Ok(())
}

#[allow(dead_code)]
pub fn branch_exists(dir: &Path, branch: &str) -> bool {
    run(
        dir,
        &[
            "show-ref",
            "--verify",
            "--quiet",
            &format!("refs/heads/{branch}"),
        ],
    )
    .map(|o| o.ok())
    .unwrap_or(false)
}

/// Stamp a submodule gitlink at `path` to `sha` via plumbing — no checkout
/// required. The committed tree entry stays mode 160000.
pub fn set_gitlink(repo: &Path, path: &str, sha: &str) -> Result<()> {
    check(
        repo,
        &[
            "update-index",
            "--add",
            "--cacheinfo",
            &format!("160000,{sha},{path}"),
        ],
    )?;
    Ok(())
}

/// Read the gitlink SHA recorded for `path` in the index/HEAD, if any.
pub fn gitlink_sha(repo: &Path, path: &str) -> Option<String> {
    let o = run(repo, &["ls-files", "--stage", "--", path]).ok()?;
    // format: "160000 <sha> 0\t<path>"
    let line = o.stdout.lines().next()?;
    let mut it = line.split_whitespace();
    if it.next()? != "160000" {
        return None;
    }
    Some(it.next()?.to_string())
}

/// Keep git quiet about a worktree path that no longer matches its index
/// entry (used when a deinited submodule is replaced by a symlink overlay).
#[allow(dead_code)] // wired by `rv adopt --step` (in progress)
pub fn set_skip_worktree(repo: &Path, path: &str, skip: bool) -> Result<()> {
    let flag = if skip {
        "--skip-worktree"
    } else {
        "--no-skip-worktree"
    };
    check(repo, &["update-index", flag, path])?;
    Ok(())
}

#[allow(dead_code)] // wired by `rv adopt --step` (in progress)
pub fn submodule_deinit(repo: &Path, path: &str) -> Result<()> {
    check(repo, &["submodule", "deinit", "-f", path])?;
    Ok(())
}

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

    /// Create an initialized repo with one commit at `dir`.
    pub fn init_repo(dir: &Path) {
        check(dir, &["init", "-q", "-b", "main"]).unwrap();
        check(dir, &["config", "user.email", "t@t.t"]).unwrap();
        check(dir, &["config", "user.name", "t"]).unwrap();
        check(dir, &["config", "commit.gpgsign", "false"]).unwrap();
        std::fs::write(dir.join("README.md"), "init\n").unwrap();
        check(dir, &["add", "-A"]).unwrap();
        check(dir, &["commit", "-q", "-m", "init"]).unwrap();
    }
}

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

    #[test]
    fn detects_repo_and_state() {
        let d = tempdir().unwrap();
        assert!(!is_repo(d.path()));
        testutil::init_repo(d.path());
        assert!(is_repo(d.path()));
        assert_eq!(current_branch(d.path()).unwrap(), "main");
        assert!(!is_dirty(d.path()).unwrap());
        std::fs::write(d.path().join("x"), "y").unwrap();
        assert!(is_dirty(d.path()).unwrap());
        assert_eq!(head_sha(d.path()).unwrap().len(), 40);
    }

    #[test]
    fn gitlink_set_without_checkout() {
        let d = tempdir().unwrap();
        testutil::init_repo(d.path());
        // a real-looking sha; no submodule checkout exists at "dep"
        let sha = head_sha(d.path()).unwrap();
        set_gitlink(d.path(), "dep", &sha).unwrap();
        check(d.path(), &["commit", "-q", "-m", "add gitlink"]).unwrap();
        assert_eq!(gitlink_sha(d.path(), "dep").as_deref(), Some(sha.as_str()));
        // re-stamp to a different real sha (the rollup bump path)
        std::fs::write(d.path().join("x2"), "z").unwrap();
        check(d.path(), &["add", "-A"]).unwrap();
        check(d.path(), &["commit", "-q", "-m", "c2"]).unwrap();
        let other = head_sha(d.path()).unwrap();
        assert_ne!(other, sha);
        set_gitlink(d.path(), "dep", &other).unwrap();
        assert_eq!(
            gitlink_sha(d.path(), "dep").as_deref(),
            Some(other.as_str())
        );
    }
}