repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const CACHE_DIR: &str = ".repopilot/cache";

pub(super) fn working_tree_fingerprint(repo_root: &Path) -> Option<String> {
    let head = git(repo_root, &["rev-parse", "HEAD"])?;
    let status_args = ["status", "--porcelain=v1", "-z", "-uall"];
    let status = git_bytes(repo_root, &status_args)?;
    let mut entries = status_entries(&status);
    entries.sort_by(|left, right| {
        (&left.path, &left.status, &left.old_path).cmp(&(
            &right.path,
            &right.status,
            &right.old_path,
        ))
    });

    let mut hasher = Sha256::new();
    hasher.update(b"head:");
    hasher.update(head.as_bytes());
    hasher.update(b"\nstatus:\n");
    for entry in entries {
        if is_cache_path(&entry.path) {
            continue;
        }
        hasher.update(b"status:");
        hasher.update(entry.status.as_bytes());
        hasher.update(b" ");
        hasher.update(entry.path.as_bytes());
        if let Some(old_path) = &entry.old_path {
            hasher.update(b" <- ");
            hasher.update(old_path.as_bytes());
        }
        hasher.update(b"\n");
        if let Ok(bytes) = fs::read(repo_root.join(&entry.path)) {
            hasher.update(b"file:");
            hasher.update(entry.path.as_bytes());
            hasher.update(b"\n");
            hasher.update(&bytes);
        }
    }
    Some(super::hex(&hasher.finalize()))
}

pub(super) fn git(path: &Path, args: &[&str]) -> Option<String> {
    let output = git_output(path, args)?;
    Some(String::from_utf8_lossy(&output).trim().to_string())
}

pub(super) fn git_root(path: &Path) -> Option<PathBuf> {
    let root = git(path, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)?;
    fs::canonicalize(&root).ok().or(Some(root))
}

pub(super) fn git_dir(path: &Path) -> Option<PathBuf> {
    let raw = git(path, &["rev-parse", "--git-dir"])?;
    resolve_git_path(path, &raw)
}

pub(super) fn git_common_dir(path: &Path) -> Option<PathBuf> {
    let raw = git(path, &["rev-parse", "--git-common-dir"])?;
    resolve_git_path(path, &raw)
}

pub(super) fn git_path(path: &Path, repo_relative: &str) -> Option<PathBuf> {
    let repo_root = git_root(path)?;
    let raw = git(&repo_root, &["rev-parse", "--git-path", repo_relative])?;
    Some(if Path::new(&raw).is_absolute() {
        PathBuf::from(raw)
    } else {
        repo_root.join(raw)
    })
}

pub(super) fn resolve_commit(repo_root: &Path, reference: &str) -> Option<String> {
    let spec = format!("{reference}^{{commit}}");
    git(
        repo_root,
        &[
            "rev-parse",
            "--verify",
            "--quiet",
            "--end-of-options",
            spec.as_str(),
        ],
    )
}

fn git_bytes(path: &Path, args: &[&str]) -> Option<Vec<u8>> {
    git_output(path, args)
}

fn git_output(path: &Path, args: &[&str]) -> Option<Vec<u8>> {
    let output = Command::new("git")
        .arg("-C")
        .arg(git_command_dir(path))
        .args(args)
        .output()
        .ok()?;
    output.status.success().then_some(output.stdout)
}

fn resolve_git_path(path: &Path, raw: &str) -> Option<PathBuf> {
    let raw = PathBuf::from(raw);
    Some(if raw.is_absolute() {
        raw
    } else {
        git_command_dir(path).join(raw)
    })
}

fn git_command_dir(path: &Path) -> &Path {
    if path.is_file() {
        path.parent().unwrap_or(path)
    } else {
        path
    }
}

#[derive(Debug, PartialEq, Eq)]
struct StatusEntry {
    status: String,
    path: String,
    old_path: Option<String>,
}

fn status_entries(status: &[u8]) -> Vec<StatusEntry> {
    let mut parts = status
        .split(|byte| *byte == 0)
        .filter(|part| !part.is_empty());
    let mut entries = Vec::new();

    while let Some(raw) = parts.next() {
        if raw.len() < 4 {
            continue;
        }
        let status = String::from_utf8_lossy(&raw[..2]).to_string();
        let path = String::from_utf8_lossy(&raw[3..]).replace('\\', "/");
        let old_path = if raw[0] == b'R' || raw[0] == b'C' || raw[1] == b'R' || raw[1] == b'C' {
            parts
                .next()
                .map(|path| String::from_utf8_lossy(path).replace('\\', "/"))
        } else {
            None
        };
        entries.push(StatusEntry {
            status,
            path,
            old_path,
        });
    }

    entries
}

fn is_cache_path(rel: &str) -> bool {
    rel == CACHE_DIR
        || rel.starts_with(&format!("{CACHE_DIR}/"))
        || rel.ends_with(&format!("/{CACHE_DIR}"))
        || rel.contains(&format!("/{CACHE_DIR}/"))
}