pushkin 0.1.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Git index plumbing, shared by `affected` and `check --staged`.
//!
//! One place that shells out to git for the staged set, so the two verbs
//! cannot drift in what they consider "changed" (remediation: lefthook
//! floor L1 — extracted from `affected`, not duplicated).

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

/// Paths staged in the index, added/copied/modified/renamed only.
/// Deletions are excluded by the filter: a deleted path has no content
/// to gate, and `git show :<path>` on one would fail.
pub fn staged_files() -> Result<Vec<String>> {
    run_name_only(&["diff", "--name-only", "--diff-filter=ACMR", "--cached"])
}

/// Paths changed across `<base>...HEAD`, same filter semantics.
pub fn changed_in_range(base: &str) -> Result<Vec<String>> {
    let range = format!("{base}...HEAD");
    run_name_only(&["diff", "--name-only", "--diff-filter=ACMR", &range])
}

/// The STAGED content of a path — `git show :<path>`, the index blob.
/// This is deliberately not a working-tree read: the commit ships what
/// is staged, so the gate must judge the index.
///
/// Binary or non-UTF-8 blobs come back `None`: the contract gate reasons
/// about source text, and a binary file has no mapped contract to check.
pub fn staged_content(path: &str) -> Result<Option<String>> {
    let output = std::process::Command::new("git")
        .args(["show", &format!(":{path}")])
        .output()
        .context("cannot run git show")?;
    if !output.status.success() {
        // Racy vanish between enumeration and read: skip, do not fail the
        // whole commit over one path that is no longer in the index.
        return Ok(None);
    }
    Ok(String::from_utf8(output.stdout).ok())
}

fn run_name_only(args: &[&str]) -> Result<Vec<String>> {
    let output = std::process::Command::new("git")
        .args(args)
        .output()
        .context("cannot run git diff")?;
    if !output.status.success() {
        bail!(
            "git diff failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(str::to_owned)
        .collect())
}

/// The repo's git directory (`git rev-parse --absolute-git-dir`), or
/// `None` outside a repository.
pub fn git_dir() -> Option<std::path::PathBuf> {
    let output = std::process::Command::new("git")
        .args(["rev-parse", "--absolute-git-dir"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8(output.stdout).ok()?;
    let trimmed = text.trim();
    (!trimmed.is_empty()).then(|| std::path::PathBuf::from(trimmed))
}

/// A configured `core.hooksPath`, if any — hooks are owned by a manager
/// (lefthook, husky, …) and `.git/hooks` is inert.
pub fn hooks_path_override() -> Option<String> {
    let output = std::process::Command::new("git")
        .args(["config", "core.hooksPath"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8(output.stdout).ok()?;
    let trimmed = text.trim();
    (!trimmed.is_empty()).then(|| trimmed.to_owned())
}