AkurAI Build
Menu

AkurAI-Build

public

Latest change 266a1aa21e346770b48e96f008ab6c2a5a603961 - Complete PR interfaces, isolated CI and crash-safe merge recovery by Ólafur Búi Ólafsson

use std::{path::Path, process::Command};

use anyhow::{Context, Result, ensure};
use serde::Serialize;

pub(crate) fn command(cwd: &Path) -> Command {
    let mut command = Command::new("git");
    command
        .current_dir(cwd)
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_NOSYSTEM", "1");
    command
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub(crate) struct RemoteBranch {
    pub name: String,
    pub sha: String,
    pub is_default: bool,
}

pub(crate) fn remote_branches(url: &str, default_branch: &str) -> Result<Vec<RemoteBranch>> {
    let output = Command::new("git")
        .args(["ls-remote", "--heads", url])
        .env("GIT_TERMINAL_PROMPT", "0")
        .output()
        .context("failed to list branches")?;
    ensure!(
        output.status.success(),
        "git ls-remote failed: {}",
        String::from_utf8_lossy(&output.stderr)
            .lines()
            .next()
            .unwrap_or("unknown error")
    );
    let mut branches: Vec<RemoteBranch> = String::from_utf8(output.stdout)
        .context("invalid git output")?
        .lines()
        .filter_map(|line| line.split_once('\t'))
        .filter_map(|(sha, reference)| {
            reference
                .strip_prefix("refs/heads/")
                .map(|name| RemoteBranch {
                    name: name.to_owned(),
                    sha: sha.to_owned(),
                    is_default: name == default_branch,
                })
        })
        .collect();
    branches.sort_by(|left, right| left.name.cmp(&right.name));
    Ok(branches)
}

pub(crate) fn output<const N: usize>(cwd: &Path, arguments: [&str; N]) -> Result<String> {
    let output = command(cwd).args(arguments).output()?;
    ensure!(
        output.status.success(),
        "git failed: {}",
        String::from_utf8_lossy(&output.stderr)
            .chars()
            .take(16 * 1024)
            .collect::<String>()
            .trim()
    );
    Ok(String::from_utf8_lossy(&output.stdout)
        .trim_end()
        .to_owned())
}

/// Run git and return raw stdout bytes: no lossy UTF-8 substitution and no
/// trimming, so binary blobs (images, etc.) survive intact.
pub(crate) fn output_bytes<const N: usize>(cwd: &Path, arguments: [&str; N]) -> Result<Vec<u8>> {
    let output = command(cwd).args(arguments).output()?;
    ensure!(
        output.status.success(),
        "git failed: {}",
        String::from_utf8_lossy(&output.stderr)
            .chars()
            .take(16 * 1024)
            .collect::<String>()
            .trim()
    );
    Ok(output.stdout)
}

#[derive(Serialize)]
pub(crate) struct DiffPreview {
    pub patch: String,
    pub truncated: bool,
}

pub(crate) fn diff_preview(cwd: &Path, base: &str, head: &str) -> Result<DiffPreview> {
    use std::io::Read;
    use std::process::Stdio;
    crate::config::validate_commit(base)?;
    crate::config::validate_commit(head)?;
    const LIMIT: usize = 512 * 1024;
    let mut child = command(cwd)
        .args([
            "diff",
            "--no-ext-diff",
            "--no-textconv",
            "--no-color",
            "--unified=3",
            &format!("{base}...{head}"),
            "--",
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()?;
    let mut bytes = Vec::new();
    let read = child
        .stdout
        .take()
        .context("missing Git output")?
        .take((LIMIT + 1) as u64)
        .read_to_end(&mut bytes);
    let truncated = bytes.len() > LIMIT;
    if truncated || read.is_err() {
        let _ = child.kill();
    }
    let status = child.wait()?;
    read?;
    ensure!(
        truncated || status.success(),
        "diff unavailable for these commits"
    );
    bytes.truncate(LIMIT);
    Ok(DiffPreview {
        patch: String::from_utf8_lossy(&bytes).into_owned(),
        truncated,
    })
}

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

    #[test]
    fn reads_git_version_successfully() {
        let dir = tempdir().expect("create temp dir");
        let version = output(dir.path(), ["version"]).expect("git version should succeed");
        assert!(
            version.starts_with("git version"),
            "should start with 'git version'"
        );
    }

    #[test]
    fn errors_on_bad_arguments() {
        let dir = tempdir().expect("create temp dir");
        let result = output(dir.path(), ["--invalid-flag-that-does-not-exist"]);
        assert!(result.is_err(), "bad arguments should error, not panic");
    }

    #[test]
    fn errors_on_non_repository() {
        let dir = tempdir().expect("create temp dir");
        // TMPDIR may live inside a worktree; prevent discovering its parent repo.
        let result = output(dir.path(), ["--git-dir=missing.git", "rev-parse", "HEAD"]);
        assert!(result.is_err(), "non-repo should produce Err");
    }

    #[test]
    fn reads_commit_sha_in_valid_repo() -> Result<()> {
        let dir = tempdir()?;
        let repo = dir.path().join("repo");
        fs::create_dir(&repo)?;
        let init = std::process::Command::new("git")
            .arg("init")
            .args(["-b", "main"])
            .arg(&repo)
            .output()?;
        ensure!(init.status.success(), "git init failed");
        let commit = std::process::Command::new("git")
            .arg("-C")
            .arg(&repo)
            .args([
                "-c",
                "user.name=test",
                "-c",
                "user.email=test@test",
                "commit",
                "--allow-empty",
                "-m",
                "init",
            ])
            .output()?;
        ensure!(commit.status.success(), "git commit failed");
        let sha = output(repo.as_path(), ["rev-parse", "HEAD"])?;
        assert_eq!(sha.len(), 40, "SHA should be 40 hex chars");
        assert!(
            sha.chars().all(|c| c.is_ascii_hexdigit()),
            "SHA should be hex"
        );
        Ok(())
    }

    #[test]
    fn lists_sorted_remote_branches_and_marks_default() -> Result<()> {
        let dir = tempdir()?;
        let repo = dir.path().join("repo");
        fs::create_dir(&repo)?;
        ensure!(
            Command::new("git")
                .arg("init")
                .args(["-b", "main"])
                .arg(&repo)
                .status()?
                .success(),
            "git init failed"
        );
        ensure!(
            Command::new("git")
                .arg("-C")
                .arg(&repo)
                .args([
                    "-c",
                    "user.name=test",
                    "-c",
                    "user.email=test@test",
                    "commit",
                    "--allow-empty",
                    "-m",
                    "init",
                ])
                .status()?
                .success(),
            "git commit failed"
        );
        ensure!(
            Command::new("git")
                .arg("-C")
                .arg(&repo)
                .args(["branch", "feature"])
                .status()?
                .success(),
            "git branch failed"
        );
        let sha = output(&repo, ["rev-parse", "HEAD"])?;
        let branches = remote_branches(repo.to_str().context("utf-8 path")?, "main")?;
        assert_eq!(
            branches,
            vec![
                RemoteBranch {
                    name: "feature".into(),
                    sha: sha.clone(),
                    is_default: false,
                },
                RemoteBranch {
                    name: "main".into(),
                    sha,
                    is_default: true,
                },
            ]
        );
        Ok(())
    }
}