dolly-cli 0.1.3

Like apt, but for GitHub repositories — clone, build, install and update tools from source.
Documentation
use std::io;
use std::path::Path;
use std::process::Command;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum GitError {
    #[error("`git` is not installed or not on PATH")]
    NotInstalled,

    #[error("git {operation} failed (exit {code:?})")]
    Failed {
        operation: &'static str,
        code: Option<i32>,
    },

    #[error(transparent)]
    Io(io::Error),
}

pub fn clone(url: &str, dest: &Path) -> Result<(), GitError> {
    let status = Command::new("git")
        .arg("clone")
        .arg("--quiet")
        .arg(url)
        .arg(dest)
        .status()
        .map_err(spawn_error)?;

    if !status.success() {
        return Err(GitError::Failed {
            operation: "clone",
            code: status.code(),
        });
    }
    Ok(())
}

pub fn pull(repo_dir: &Path) -> Result<(), GitError> {
    let status = Command::new("git")
        .arg("pull")
        .arg("--ff-only")
        .arg("--quiet")
        .current_dir(repo_dir)
        .status()
        .map_err(spawn_error)?;

    if !status.success() {
        return Err(GitError::Failed {
            operation: "pull",
            code: status.code(),
        });
    }
    Ok(())
}

pub fn fetch(repo_dir: &Path) -> Result<(), GitError> {
    let status = Command::new("git")
        .arg("fetch")
        .arg("--quiet")
        .current_dir(repo_dir)
        .status()
        .map_err(spawn_error)?;
    if !status.success() {
        return Err(GitError::Failed {
            operation: "fetch",
            code: status.code(),
        });
    }
    Ok(())
}

pub fn head_commit(repo_dir: &Path) -> Result<String, GitError> {
    let output = Command::new("git")
        .arg("rev-parse")
        .arg("--short")
        .arg("HEAD")
        .current_dir(repo_dir)
        .output()
        .map_err(spawn_error)?;

    if !output.status.success() {
        return Err(GitError::Failed {
            operation: "rev-parse HEAD",
            code: output.status.code(),
        });
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

pub fn upstream_commit(repo_dir: &Path) -> Result<String, GitError> {
    let output = Command::new("git")
        .arg("rev-parse")
        .arg("--short")
        .arg("@{u}")
        .current_dir(repo_dir)
        .output()
        .map_err(spawn_error)?;
    if !output.status.success() {
        return Err(GitError::Failed {
            operation: "rev-parse @{u}",
            code: output.status.code(),
        });
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

pub fn log_between(repo_dir: &Path, from: &str, to: &str) -> Result<Vec<String>, GitError> {
    let output = Command::new("git")
        .arg("log")
        .arg(format!("{from}..{to}"))
        .arg("--pretty=format:%s")
        .arg("--no-decorate")
        .current_dir(repo_dir)
        .output()
        .map_err(spawn_error)?;

    if !output.status.success() {
        return Err(GitError::Failed {
            operation: "log",
            code: output.status.code(),
        });
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    Ok(stdout.lines().map(str::to_string).collect())
}

pub fn diffstat(repo_dir: &Path, from: &str, to: &str) -> Result<(usize, usize, usize), GitError> {
    let output = Command::new("git")
        .arg("diff")
        .arg("--numstat")
        .arg(format!("{from}..{to}"))
        .current_dir(repo_dir)
        .output()
        .map_err(spawn_error)?;

    if !output.status.success() {
        return Err(GitError::Failed {
            operation: "diff --numstat",
            code: output.status.code(),
        });
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut files = 0usize;
    let mut insertions = 0usize;
    let mut deletions = 0usize;
    for line in stdout.lines() {
        let mut parts = line.split('\t');
        let adds = parts.next().unwrap_or("");
        let dels = parts.next().unwrap_or("");
        // numstat shows "-" for binary files; skip those line counts but still count the file.
        if let Ok(n) = adds.parse::<usize>() {
            insertions += n;
        }
        if let Ok(n) = dels.parse::<usize>() {
            deletions += n;
        }
        files += 1;
    }

    Ok((files, insertions, deletions))
}

fn spawn_error(e: io::Error) -> GitError {
    if e.kind() == io::ErrorKind::NotFound {
        GitError::NotInstalled
    } else {
        GitError::Io(e)
    }
}

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

    fn init_repo() -> tempfile::TempDir {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path();

        let run = |args: &[&str]| {
            let status = Command::new("git")
                .args(args)
                .current_dir(p)
                .status()
                .unwrap();
            assert!(status.success(), "git {args:?} failed");
        };

        run(&["init", "--quiet", "--initial-branch=main"]);
        run(&["config", "user.email", "test@example.com"]);
        run(&["config", "user.name", "test"]);
        run(&["config", "commit.gpgsign", "false"]);
        std::fs::write(p.join("README"), "hello").unwrap();
        run(&["add", "."]);
        run(&["commit", "--quiet", "-m", "initial"]);

        dir
    }

    #[test]
    fn head_commit_returns_short_hash() {
        let repo = init_repo();
        let commit = head_commit(repo.path()).unwrap();
        assert!(
            commit.len() >= 7 && commit.len() <= 40,
            "expected short hash, got {commit:?}"
        );
        assert!(
            commit.chars().all(|c| c.is_ascii_hexdigit()),
            "expected hex, got {commit:?}"
        );
    }

    #[test]
    fn head_commit_fails_outside_a_repo() {
        let dir = tempfile::tempdir().unwrap();
        let err = head_commit(dir.path()).unwrap_err();
        assert!(matches!(
            err,
            GitError::Failed {
                operation: "rev-parse HEAD",
                ..
            }
        ));
    }

    #[test]
    fn upstream_commit_fails_without_upstream() {
        let repo = init_repo();
        let err = upstream_commit(repo.path()).unwrap_err();
        assert!(matches!(
            err,
            GitError::Failed {
                operation: "rev-parse @{u}",
                ..
            }
        ));
    }

    fn git_in(p: &Path, args: &[&str]) {
        let status = Command::new("git")
            .args(args)
            .current_dir(p)
            .status()
            .unwrap();
        assert!(status.success(), "git {args:?} failed");
    }

    fn commit_file(p: &Path, name: &str, contents: &str, message: &str) -> String {
        std::fs::write(p.join(name), contents).unwrap();
        git_in(p, &["add", "."]);
        git_in(p, &["commit", "--quiet", "-m", message]);
        head_commit(p).unwrap()
    }

    #[test]
    fn log_between_is_empty_when_from_equals_to() {
        let repo = init_repo();
        let head = head_commit(repo.path()).unwrap();
        let commits = log_between(repo.path(), &head, &head).unwrap();
        assert!(commits.is_empty());
    }

    #[test]
    fn log_between_returns_subjects_in_reverse_chronological_order() {
        let repo = init_repo();
        let initial = head_commit(repo.path()).unwrap();
        let _ = commit_file(repo.path(), "a.txt", "a\n", "second");
        let third = commit_file(repo.path(), "b.txt", "b\n", "third");

        let subjects = log_between(repo.path(), &initial, &third).unwrap();
        assert_eq!(subjects, vec!["third".to_string(), "second".to_string()]);
    }

    #[test]
    fn diffstat_is_all_zeros_when_from_equals_to() {
        let repo = init_repo();
        let head = head_commit(repo.path()).unwrap();
        let (files, ins, dels) = diffstat(repo.path(), &head, &head).unwrap();
        assert_eq!((files, ins, dels), (0, 0, 0));
    }

    #[test]
    fn diffstat_counts_added_files_and_lines() {
        let repo = init_repo();
        let initial = head_commit(repo.path()).unwrap();
        let head = commit_file(repo.path(), "new.txt", "line1\nline2\nline3\n", "add new");

        let (files, ins, dels) = diffstat(repo.path(), &initial, &head).unwrap();
        assert_eq!(files, 1);
        assert_eq!(ins, 3);
        assert_eq!(dels, 0);
    }
}