rdar 0.6.13

radar - the repository cartographer for AI agents: compiles a repo into tiny committed MAP.md routers, with measured token benchmarks
Documentation
//! Git facts via shell-out: git is an optional
//! ACCELERATOR - every caller has a git-less fallback. No gix, no libgit2;
//! porcelain formats are stable public contracts.

use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};

const MAX_HISTORY_BYTES: usize = 16 * 1024 * 1024;
const MAX_HISTORY_FILES_PER_COMMIT: usize = 1024;

fn git(root: &Path, args: &[&str]) -> Option<String> {
    let out = Command::new("git")
        .arg("-C")
        .arg(root)
        .args(args)
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&out.stdout).into_owned())
}

/// The root tree OID of HEAD - changes iff any committed content changes.
pub fn head_tree_oid(root: &Path) -> Option<String> {
    git(root, &["rev-parse", "HEAD^{tree}"]).map(|s| s.trim().to_string())
}

/// True when the working tree has NO uncommitted changes (tracked or
/// untracked, ignoring ignored files). None = git unavailable.
pub fn worktree_clean(root: &Path) -> Option<bool> {
    git(root, &["status", "--porcelain", "-z"]).map(|s| s.is_empty())
}

/// Paths changed between REV and the working tree (names only, / separators).
pub fn changed_since(root: &Path, rev: &str) -> Option<Vec<String>> {
    let committed = git(root, &["diff", "--name-only", "-z", rev])?;
    let untracked = git(root, &["ls-files", "--others", "--exclude-standard", "-z"])?;
    let mut out: Vec<String> = committed
        .split('\0')
        .chain(untracked.split('\0'))
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .collect();
    out.sort();
    out.dedup();
    Some(out)
}

/// One path historically changed in the same commits as the current change
/// set. This is evidence of co-change, not proof of a causal dependency.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct HistoryCoChange {
    pub path: String,
    pub commits: usize,
    pub latest_commit: String,
    pub latest_timestamp: i64,
}

/// Bounded history facts for an explicit review-impact request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HistoryFacts {
    pub commits_scanned: usize,
    pub seed_commits: usize,
    pub candidates: Vec<HistoryCoChange>,
    pub omitted: usize,
    pub bulk_commits_skipped: usize,
}

/// Read bounded first-parent history and return files that co-change with
/// the seeds. Git history is optional and never participates in map emission.
pub fn history_cochanges(
    root: &Path,
    seeds: &[String],
    max_commits: usize,
    max_results: usize,
) -> Option<HistoryFacts> {
    if max_commits == 0 {
        return Some(HistoryFacts {
            commits_scanned: 0,
            seed_commits: 0,
            candidates: Vec::new(),
            omitted: 0,
            bulk_commits_skipped: 0,
        });
    }
    let max_arg = format!("--max-count={max_commits}");
    let raw = git_history(
        root,
        &[
            "log",
            "--first-parent",
            "--no-renames",
            max_arg.as_str(),
            "--format=%x1e%H%x00%ct%x00",
            "--name-only",
            "-z",
            "--",
        ],
    )?;
    let parsed = parse_history(&raw);
    let seeds: BTreeSet<&str> = seeds.iter().map(String::as_str).collect();
    let mut seed_commits = 0usize;
    let mut bulk_commits_skipped = 0usize;
    let mut cochanges: BTreeMap<String, (usize, String, i64)> = BTreeMap::new();

    for (commit, timestamp, files) in &parsed {
        if files.len() > MAX_HISTORY_FILES_PER_COMMIT {
            bulk_commits_skipped += 1;
            continue;
        }
        let files: BTreeSet<&str> = files.iter().map(String::as_str).collect();
        if !files.iter().any(|path| seeds.contains(path)) {
            continue;
        }
        seed_commits += 1;
        for path in files {
            if seeds.contains(path) {
                continue;
            }
            let entry = cochanges
                .entry(path.to_string())
                .or_insert_with(|| (0, commit.clone(), *timestamp));
            entry.0 += 1;
        }
    }

    let mut candidates: Vec<HistoryCoChange> = cochanges
        .into_iter()
        .map(
            |(path, (commits, latest_commit, latest_timestamp))| HistoryCoChange {
                path,
                commits,
                latest_commit,
                latest_timestamp,
            },
        )
        .collect();
    candidates.sort_by(|left, right| {
        right
            .commits
            .cmp(&left.commits)
            .then_with(|| left.path.cmp(&right.path))
    });
    let omitted = candidates.len().saturating_sub(max_results);
    candidates.truncate(max_results);
    Some(HistoryFacts {
        commits_scanned: parsed.len(),
        seed_commits,
        candidates,
        omitted,
        bulk_commits_skipped,
    })
}

fn git_history(root: &Path, args: &[&str]) -> Option<String> {
    let mut child = Command::new("git")
        .arg("-C")
        .arg(root)
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .ok()?;
    let stdout = child.stdout.take()?;
    let mut bytes = Vec::new();
    let read_result = stdout
        .take((MAX_HISTORY_BYTES + 1) as u64)
        .read_to_end(&mut bytes);
    if read_result.is_err() {
        let _ = child.kill();
        let _ = child.wait();
        return None;
    }
    if bytes.len() > MAX_HISTORY_BYTES {
        let _ = child.kill();
        let _ = child.wait();
        return None;
    }
    if !child.wait().ok()?.success() {
        return None;
    }
    String::from_utf8(bytes).ok()
}

fn parse_history(raw: &str) -> Vec<(String, i64, Vec<String>)> {
    raw.split('\x1e')
        .filter_map(|record| {
            let mut fields = record.split('\0');
            let commit = fields.next()?.trim();
            if commit.len() != 40 {
                return None;
            }
            let timestamp = fields.next()?.trim().parse().ok()?;
            let files: Vec<String> = fields
                .map(|path| path.trim_start_matches(['\n', '\r']).to_string())
                .filter(|path| !path.is_empty())
                .collect();
            Some((commit.to_string(), timestamp, files))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn non_git_dir_degrades_to_none() {
        let dir = std::env::temp_dir().join(format!("radar-gitless-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("mkdir");
        assert_eq!(head_tree_oid(&dir), None);
        assert_eq!(worktree_clean(&dir), None);
        assert_eq!(changed_since(&dir, "HEAD"), None);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn parses_nul_delimited_history_records() {
        let raw = format!(
            "\x1e{}\0{}\0\0src/a.rs\0src/b.rs\0\x1e{}\0{}\0\0src/a.rs\0",
            "a".repeat(40),
            100,
            "b".repeat(40),
            90
        );
        let parsed = parse_history(&raw);
        assert_eq!(parsed.len(), 2);
        assert_eq!(parsed[0].2, ["src/a.rs", "src/b.rs"]);
        assert_eq!(parsed[1].1, 90);
    }
}