pukbot 0.3.7

Post disclosed GitHub comments through the Pukbot GitHub App
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, anyhow, bail};

use crate::model::CommitFileDocument;

pub fn staged_files(paths: &[PathBuf]) -> Result<Vec<CommitFileDocument>> {
    let root = repository_root()?;
    staged_files_at(&root, paths)
}

fn staged_files_at(root: &Path, paths: &[PathBuf]) -> Result<Vec<CommitFileDocument>> {
    let mut args = vec![
        "-C".to_owned(),
        root.to_str()
            .context("repository path must be valid UTF-8")?
            .to_owned(),
        "diff".to_owned(),
        "--cached".to_owned(),
        "--name-status".to_owned(),
        "--no-renames".to_owned(),
        "-z".to_owned(),
    ];
    if !paths.is_empty() {
        args.push("--".to_owned());
        for path in paths {
            args.push(path.to_string_lossy().into_owned());
        }
    }
    let output = Command::new("git")
        .args(&args)
        .output()
        .context("failed to run git diff --cached")?;
    if !output.status.success() {
        bail!("failed to read the staged git changes");
    }
    let raw = String::from_utf8(output.stdout).context("git diff output was not UTF-8")?;
    let mut fields = raw.split('\0').filter(|field| !field.is_empty());
    let mut entries = Vec::new();
    while let Some(status) = fields.next() {
        let path = fields
            .next()
            .context("git diff produced an unexpected record")?
            .to_owned();
        match status.chars().next() {
            Some('D') => entries.push(CommitFileDocument {
                path,
                content: None,
                delete: true,
            }),
            Some('A' | 'M' | 'T') => {
                let content = staged_content(root, &path)?;
                entries.push(CommitFileDocument {
                    path,
                    content: Some(content),
                    delete: false,
                });
            }
            _ => bail!(
                "{path} has an unsupported git status ({status}); resolve it before committing"
            ),
        }
    }
    if entries.is_empty() {
        bail!("nothing is staged; run git add before pukbot commit create");
    }
    Ok(entries)
}

fn repository_root() -> Result<PathBuf> {
    let output = Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .context("failed to run git; install git and run inside a repository")?;
    if !output.status.success() {
        bail!("not inside a git repository");
    }
    let root = String::from_utf8(output.stdout)
        .context("git returned a non-UTF-8 repository path")?
        .trim()
        .to_owned();
    Ok(PathBuf::from(root))
}

fn staged_content(root: &Path, path: &str) -> Result<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(root)
        .args(["show", &format!(":{path}")])
        .output()
        .with_context(|| format!("failed to read the staged content of {path}"))?;
    if !output.status.success() {
        bail!("failed to read the staged content of {path}");
    }
    String::from_utf8(output.stdout)
        .map_err(|_| anyhow!("{path} is binary; commit binary files with local git for now"))
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::process::Command;

    use super::staged_files_at;

    fn init_repo() -> tempfile::TempDir {
        let directory = tempfile::tempdir().expect("temporary directory should be created");
        let run = |args: &[&str]| {
            let status = Command::new("git")
                .arg("-C")
                .arg(directory.path())
                .args(args)
                .status()
                .expect("git should run");
            assert!(status.success());
        };
        run(&["init", "-q"]);
        run(&["config", "user.email", "test@example.com"]);
        run(&["config", "user.name", "Test"]);
        directory
    }

    #[test]
    fn reads_added_and_deleted_staged_files() {
        let directory = init_repo();
        fs::write(directory.path().join("kept.txt"), "old\n").expect("file should be written");
        Command::new("git")
            .arg("-C")
            .arg(directory.path())
            .args(["add", "kept.txt"])
            .status()
            .expect("git add should run");
        Command::new("git")
            .arg("-C")
            .arg(directory.path())
            .args(["commit", "-q", "-m", "init"])
            .status()
            .expect("git commit should run");

        fs::remove_file(directory.path().join("kept.txt")).expect("file should be removed");
        fs::write(directory.path().join("new.txt"), "hello\n").expect("file should be written");
        Command::new("git")
            .arg("-C")
            .arg(directory.path())
            .args(["add", "-A"])
            .status()
            .expect("git add should run");

        let files = staged_files_at(directory.path(), &[]).expect("staged files should resolve");
        assert!(
            files
                .iter()
                .any(|file| file.path == "new.txt" && file.content.as_deref() == Some("hello\n"))
        );
        assert!(
            files
                .iter()
                .any(|file| file.path == "kept.txt" && file.delete)
        );
    }

    #[test]
    fn fails_when_nothing_is_staged() {
        let directory = init_repo();
        assert!(staged_files_at(directory.path(), &[]).is_err());
    }
}