mati 0.1.4

An enforcement layer for codebase knowledge: confirmed gotchas gate what AI agents read and edit at the hook level. Not a passive memory store.
Documentation
use super::*;

// ── Git helper functions ────────────────────────────────────────────────────

/// Get the SHA string of HEAD commit, if available.
pub(crate) fn head_commit_sha(repo: &git2::Repository) -> Option<String> {
    repo.head().ok()?.target().map(|oid| oid.to_string())
}

/// Get the blob SHA for a file at HEAD.
pub(crate) fn blob_sha_at_head(repo: &git2::Repository, path: &str) -> Option<String> {
    let head_ref = repo.head().ok()?;
    let commit = head_ref.peel_to_commit().ok()?;
    let tree = commit.tree().ok()?;
    let entry = tree.get_path(Path::new(path)).ok()?;
    Some(entry.id().to_string())
}

/// Get the blob SHA for a file at a specific commit.
pub(crate) fn blob_sha_at_commit(
    repo: &git2::Repository,
    path: &str,
    commit_sha: &str,
) -> Option<String> {
    let oid = git2::Oid::from_str(commit_sha).ok()?;
    let commit = repo.find_commit(oid).ok()?;
    let tree = commit.tree().ok()?;
    let entry = tree.get_path(Path::new(path)).ok()?;
    Some(entry.id().to_string())
}

/// Count recent commits touching a file path, with a total iteration limit
/// for consistent merge-commit handling.
#[allow(dead_code)]
pub(super) fn count_recent_commits(repo: &git2::Repository, path: &str, limit: usize) -> u32 {
    let head_oid = match repo.head().ok().and_then(|h| h.target()) {
        Some(oid) => oid,
        None => return 0,
    };

    let mut revwalk = match repo.revwalk() {
        Ok(rw) => rw,
        Err(_) => return 0,
    };

    if revwalk.push(head_oid).is_err() {
        return 0;
    }

    revwalk.set_sorting(git2::Sort::TOPOLOGICAL).ok();

    let mut count: u32 = 0;
    let mut total_iterations: usize = 0;

    for oid_result in revwalk {
        total_iterations += 1;
        if total_iterations > limit {
            break;
        }

        let oid = match oid_result {
            Ok(o) => o,
            Err(_) => continue,
        };

        if commit_touches_file(repo, oid, path) {
            count += 1;
        }
    }

    count
}

/// Map a commit count to a staleness factor.
///
/// ```text
/// 0 -> 0.00
/// 1 -> 0.15
/// 2 -> 0.30
/// 3 -> 0.50
/// 4 -> 0.70
/// 5+ -> 1.00
/// ```
pub(crate) fn commits_to_factor(commits: u32) -> f32 {
    match commits {
        0 => 0.0,
        1 => 0.15,
        2 => 0.30,
        3 => 0.50,
        4 => 0.70,
        _ => 1.0,
    }
}

/// Check whether a commit touches a specific file by comparing tree entries
/// with the parent commit's tree.
pub(crate) fn commit_touches_file(
    repo: &git2::Repository,
    commit_oid: git2::Oid,
    path: &str,
) -> bool {
    let commit = match repo.find_commit(commit_oid) {
        Ok(c) => c,
        Err(_) => return false,
    };

    let tree = match commit.tree() {
        Ok(t) => t,
        Err(_) => return false,
    };

    let file_entry = tree.get_path(Path::new(path)).ok();

    // If no parents, this is the initial commit — file is touched if it exists.
    if commit.parent_count() == 0 {
        return file_entry.is_some();
    }

    // Compare with first parent.
    let parent = match commit.parent(0) {
        Ok(p) => p,
        Err(_) => return file_entry.is_some(),
    };

    let parent_tree = match parent.tree() {
        Ok(t) => t,
        Err(_) => return file_entry.is_some(),
    };

    let parent_entry = parent_tree.get_path(Path::new(path)).ok();

    match (file_entry, parent_entry) {
        (Some(cur), Some(par)) => cur.id() != par.id(),
        (Some(_), None) => true, // File added.
        (None, Some(_)) => true, // File deleted.
        (None, None) => false,
    }
}

/// Determine whether an old and new staleness state differ enough to warrant
/// writing the updated record.
///
/// Checks: value delta > 0.01, tier change, signal count change, AND
/// last_record_sha change.
pub(crate) fn staleness_changed(old: &Record, new: &Record) -> bool {
    let value_delta = (old.staleness.value - new.staleness.value).abs();
    if value_delta > 0.01 {
        return true;
    }

    if old.staleness.tier != new.staleness.tier {
        return true;
    }

    if old.staleness.signals.len() != new.staleness.signals.len() {
        return true;
    }

    if old.staleness.last_record_sha != new.staleness.last_record_sha {
        return true;
    }

    false
}

/// Returns true if a signal is one generated by the reparse (M-12) pipeline.
pub(crate) fn is_reparse_signal(signal: &StalenessSignal) -> bool {
    matches!(
        signal,
        StalenessSignal::EntryPointsChanged(_)
            | StalenessSignal::ImportsChanged(_)
            | StalenessSignal::TodosChanged
            | StalenessSignal::UnsafeCountChanged(_)
            | StalenessSignal::UnwrapCountChanged(_)
    )
}