pushkin 0.2.1

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)))
}

/// The commit date of the last commit touching `path`
/// (`git log -1 --format=%cd --date=iso-strict-local -- <path>`), or `None`
/// when the path has no commit yet.
///
/// `TZ=UTC` is load-bearing, not cosmetic: the returned timestamp is compared
/// against UTC event-log rows as the `since` bound (see
/// `pushkin-core::events`), so a local-time value would be wrong by the offset.
/// The `--date=iso-strict-local` format and argument order are part of that
/// contract; do not change them without the event-log comparison in view.
#[must_use]
pub fn last_commit_touching(path: &str) -> Option<String> {
    let out = std::process::Command::new("git")
        .args(["log", "-1", "--format=%cd", "--date=iso-strict-local", "--"])
        .arg(path)
        .env("TZ", "UTC")
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let ts = String::from_utf8_lossy(&out.stdout).trim().to_owned();
    (!ts.is_empty()).then_some(ts)
}

/// Positive evidence that `path` is committed in `HEAD`
/// (`git cat-file -e HEAD:<path>`). No git, no HEAD, or an untracked path all
/// come back `false` — the caller's deny requires the file to actually be in
/// history.
#[must_use]
pub fn committed_in_head(path: &str) -> bool {
    std::process::Command::new("git")
        .args(["cat-file", "-e", &format!("HEAD:{path}")])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .is_ok_and(|status| status.success())
}

/// The short HEAD sha (`git rev-parse --short HEAD`), or `None` when there is
/// no repository or no commit yet. The caller supplies its own no-repo prose.
#[must_use]
pub fn head_sha() -> Option<String> {
    let out = std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let sha = String::from_utf8_lossy(&out.stdout).trim().to_owned();
    (!sha.is_empty()).then_some(sha)
}

/// The configured `git config user.name`, or `None` when git is absent, the
/// value is unset, or it is empty. The caller decides the fallback identity.
#[must_use]
pub fn config_user_name() -> Option<String> {
    let output = std::process::Command::new("git")
        .args(["config", "user.name"])
        .output()
        .ok()?
        .stdout;
    let name = String::from_utf8_lossy(&output).trim().to_owned();
    (!name.is_empty()).then_some(name)
}