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())
}
pub fn head_tree_oid(root: &Path) -> Option<String> {
git(root, &["rev-parse", "HEAD^{tree}"]).map(|s| s.trim().to_string())
}
pub fn worktree_clean(root: &Path) -> Option<bool> {
git(root, &["status", "--porcelain", "-z"]).map(|s| s.is_empty())
}
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)
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct HistoryCoChange {
pub path: String,
pub commits: usize,
pub latest_commit: String,
pub latest_timestamp: i64,
}
#[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,
}
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);
}
}