pushkin 0.2.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())
}

/// The repository root — `git rev-parse --show-toplevel`.
///
/// Three outcomes, deliberately distinct (F73):
///
/// - `Ok(Some(root))` — inside a repository.
/// - `Ok(None)` — **not** a repository. Not an error: outside a repo there is
///   no root, so there is nothing for a nested manifest to shadow and the
///   caller may fall back to the working directory. A tarball checkout, a
///   container image, or an extracted crate must keep working.
/// - `Err(_)` — git could not be RUN at all. Kept distinct from `Ok(None)` so
///   the caller can SAY which happened; both fall back to the working
///   directory, because they are the same epistemic state (the tool probed and
///   cannot tell whether a root exists), but only this one is worth a notice.
///
/// # Errors
/// When the `git` binary is absent from `PATH` or cannot be executed.
pub fn repo_root() -> Result<Option<std::path::PathBuf>> {
    let output = std::process::Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .map_err(|error| {
            anyhow::anyhow!("`git` could not be run ({error}), so the repository root is unknown.")
        })?;
    if !output.status.success() {
        // Not a repository — a state, not a failure.
        return Ok(None);
    }
    let root = String::from_utf8_lossy(&output.stdout).trim().to_owned();
    if root.is_empty() {
        return Ok(None);
    }
    Ok(Some(std::path::PathBuf::from(root)))
}