use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::colocated_test::Language;
pub fn stale_sources(
repo: &Path,
base: &str,
language: Language,
exempt: &BTreeSet<String>,
) -> Result<Vec<PathBuf>> {
let entries = changed_entries(repo, base)?;
let fork_point = merge_base(repo, base)?;
let changed: BTreeSet<&str> = entries.iter().map(|(_, path)| path.as_str()).collect();
let suite_tests = match language {
Language::Python => crate::tiers::suite_tests_dir(repo, "pyproject.toml"),
Language::TypeScript => crate::tiers::suite_tests_dir(repo, "package.json"),
Language::Rust => None,
};
let mut stale = Vec::new();
for (status, rel) in &entries {
let path = Path::new(rel);
if !language.tracks(path) || language.is_test(path) || language.is_support(path) {
continue;
}
if suite_tests
.as_ref()
.is_some_and(|tests| repo.join(path).starts_with(tests))
{
continue;
}
let expected = language
.expected_test_path(path)
.to_string_lossy()
.replace('\\', "/");
let is_subject = match status {
Status::Modified => {
let contents = std::fs::read_to_string(repo.join(path))
.with_context(|| format!("reading changed source `{rel}`"))?;
language.is_subject(&contents, path)
&& !language.same_code(&blob_at(repo, &fork_point, rel)?, &contents, path)
}
Status::Deleted => test_exists_in_base(repo, base, &expected)?,
Status::Other => false,
};
if !is_subject || exempt.contains(rel) {
continue;
}
if !changed.contains(expected.as_str()) {
stale.push(path.to_path_buf());
}
}
stale.sort();
Ok(stale)
}
enum Status {
Modified,
Deleted,
Other,
}
impl Status {
fn from_code(code: &str) -> Status {
match code.chars().next() {
Some('M') => Status::Modified,
Some('D') => Status::Deleted,
_ => Status::Other,
}
}
}
fn test_exists_in_base(repo: &Path, base: &str, rel: &str) -> Result<bool> {
let spec = format!("{base}:./{rel}");
let output = Command::new("git")
.current_dir(repo)
.args(["cat-file", "-e", &spec])
.output()
.with_context(|| format!("running `git cat-file` in `{}`", repo.display()))?;
Ok(output.status.success())
}
fn merge_base(repo: &Path, base: &str) -> Result<String> {
let output = Command::new("git")
.current_dir(repo)
.args(["merge-base", base, "HEAD"])
.output()
.with_context(|| format!("running `git merge-base` in `{}`", repo.display()))?;
if !output.status.success() {
bail!(
"`git merge-base {base} HEAD` failed in `{}`: {}",
repo.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn blob_at(repo: &Path, commit: &str, rel: &str) -> Result<String> {
let spec = format!("{commit}:./{rel}");
let output = Command::new("git")
.current_dir(repo)
.args(["show", &spec])
.output()
.with_context(|| format!("running `git show {spec}` in `{}`", repo.display()))?;
if !output.status.success() {
bail!(
"reading `{rel}` at `{commit}` in `{}`: {}",
repo.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn changed_entries(repo: &Path, base: &str) -> Result<Vec<(Status, String)>> {
let range = format!("{base}...HEAD");
let output = Command::new("git")
.current_dir(repo)
.args([
"-c",
"core.quotepath=off",
"diff",
"--name-status",
"--no-ext-diff",
"--no-renames",
"--relative",
&range,
])
.output()
.with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
if !output.status.success() {
bail!(
"`git diff {range}` failed in `{}`: {}",
repo.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
for line in stdout.lines() {
if let Some((status, path)) = line.split_once('\t') {
let path = crate::patch_coverage::unquote_c_path(path.trim_end_matches('\r'));
let path = path.replace('\\', "/");
entries.push((Status::from_code(status), path));
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
struct TempRepo(PathBuf);
impl TempRepo {
fn new(slug: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"tc-co-change-git-{}-{}-{}",
slug,
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
std::fs::create_dir_all(&root).unwrap();
let repo = TempRepo(root);
repo.git(&["init", "-q"]);
repo.git(&["config", "user.email", "test@example.com"]);
repo.git(&["config", "user.name", "Test"]);
repo
}
fn git(&self, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(&self.0)
.status()
.expect("git should run");
assert!(status.success(), "git {args:?} failed");
}
fn commit(&self, rel: &str, contents: &str) {
std::fs::write(self.0.join(rel), contents).unwrap();
self.git(&["add", "-A"]);
self.git(&["-c", "commit.gpgsign=false", "commit", "-q", "-m", rel]);
}
fn head(&self) -> String {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&self.0)
.output()
.expect("git rev-parse should run");
assert!(out.status.success(), "git rev-parse failed");
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn merge_base_answers_where_the_branch_left_trunk() {
let repo = TempRepo::new("mb");
repo.commit("widget.py", "x = 1\n");
repo.git(&["checkout", "-q", "-b", "trunk"]);
let fork_point = repo.head();
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.commit("widget.py", "x = 2\n");
repo.git(&["checkout", "-q", "trunk"]);
repo.commit("widget.py", "x = 3\n");
let trunk_tip = repo.head();
repo.git(&["checkout", "-q", "feature"]);
assert_eq!(merge_base(&repo.0, "trunk").unwrap(), fork_point);
assert_ne!(fork_point, trunk_tip);
}
#[test]
fn merge_base_errors_when_the_histories_never_met() {
let repo = TempRepo::new("mb-orphan");
repo.commit("widget.py", "x = 1\n");
repo.git(&["checkout", "-q", "-b", "trunk"]);
repo.git(&["checkout", "-q", "--orphan", "stranger"]);
repo.commit("widget.py", "x = 2\n");
let err = merge_base(&repo.0, "trunk").unwrap_err();
assert!(err.to_string().contains("git merge-base"), "got: {err}");
}
#[test]
fn blob_at_reads_the_file_as_it_stood() {
let repo = TempRepo::new("blob");
repo.commit("widget.py", "x = 1\n");
let first = repo.head();
repo.commit("widget.py", "x = 2\n");
assert_eq!(blob_at(&repo.0, &first, "widget.py").unwrap(), "x = 1\n");
assert_eq!(
blob_at(&repo.0, &repo.head(), "widget.py").unwrap(),
"x = 2\n"
);
}
#[test]
fn blob_at_errors_when_the_path_is_absent() {
let repo = TempRepo::new("blob-missing");
repo.commit("widget.py", "x = 1\n");
let err = blob_at(&repo.0, &repo.head(), "ghost.py").unwrap_err();
assert!(err.to_string().contains("ghost.py"), "got: {err}");
}
}