1use std::collections::BTreeSet;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use anyhow::{bail, Context, Result};
9
10use crate::colocated_test::Language;
11
12pub fn stale_sources(
16 repo: &Path,
17 base: &str,
18 language: Language,
19 exempt: &BTreeSet<String>,
20) -> Result<Vec<PathBuf>> {
21 let entries = changed_entries(repo, base)?;
22 let fork_point = merge_base(repo, base)?;
23 let changed: BTreeSet<&str> = entries.iter().map(|(_, path)| path.as_str()).collect();
24 let suite_tests = match language {
26 Language::Python => crate::tiers::suite_tests_dir(repo, "pyproject.toml"),
27 Language::TypeScript => crate::tiers::suite_tests_dir(repo, "package.json"),
28 Language::Rust => None,
29 };
30
31 let mut stale = Vec::new();
32 for (status, rel) in &entries {
33 let path = Path::new(rel);
34 if !language.tracks(path) || language.is_test(path) || language.is_support(path) {
35 continue;
36 }
37 if suite_tests
38 .as_ref()
39 .is_some_and(|tests| repo.join(path).starts_with(tests))
40 {
41 continue;
42 }
43 let expected = language
44 .expected_test_path(path)
45 .to_string_lossy()
46 .replace('\\', "/");
47 let is_subject = match status {
48 Status::Modified => {
49 let contents = std::fs::read_to_string(repo.join(path))
52 .with_context(|| format!("reading changed source `{rel}`"))?;
53 language.is_subject(&contents, path)
54 && !language.same_code(&blob_at(repo, &fork_point, rel)?, &contents, path)
55 }
56 Status::Deleted => test_exists_in_base(repo, base, &expected)?,
58 Status::Other => false,
59 };
60 if !is_subject || exempt.contains(rel) {
61 continue;
62 }
63 if !changed.contains(expected.as_str()) {
64 stale.push(path.to_path_buf());
65 }
66 }
67 stale.sort();
68 Ok(stale)
69}
70
71enum Status {
73 Modified,
75 Deleted,
77 Other,
79}
80
81impl Status {
82 fn from_code(code: &str) -> Status {
84 match code.chars().next() {
85 Some('M') => Status::Modified,
86 Some('D') => Status::Deleted,
87 _ => Status::Other,
88 }
89 }
90}
91
92fn test_exists_in_base(repo: &Path, base: &str, rel: &str) -> Result<bool> {
96 let spec = format!("{base}:./{rel}");
97 let output = Command::new("git")
98 .current_dir(repo)
99 .args(["cat-file", "-e", &spec])
100 .output()
101 .with_context(|| format!("running `git cat-file` in `{}`", repo.display()))?;
102 Ok(output.status.success())
103}
104
105fn merge_base(repo: &Path, base: &str) -> Result<String> {
109 let output = Command::new("git")
110 .current_dir(repo)
111 .args(["merge-base", base, "HEAD"])
112 .output()
113 .with_context(|| format!("running `git merge-base` in `{}`", repo.display()))?;
114 if !output.status.success() {
115 bail!(
116 "`git merge-base {base} HEAD` failed in `{}`: {}",
117 repo.display(),
118 String::from_utf8_lossy(&output.stderr).trim()
119 );
120 }
121 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
122}
123
124fn blob_at(repo: &Path, commit: &str, rel: &str) -> Result<String> {
128 let spec = format!("{commit}:./{rel}");
129 let output = Command::new("git")
130 .current_dir(repo)
131 .args(["show", &spec])
132 .output()
133 .with_context(|| format!("running `git show {spec}` in `{}`", repo.display()))?;
134 if !output.status.success() {
135 bail!(
136 "reading `{rel}` at `{commit}` in `{}`: {}",
137 repo.display(),
138 String::from_utf8_lossy(&output.stderr).trim()
139 );
140 }
141 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
142}
143
144fn changed_entries(repo: &Path, base: &str) -> Result<Vec<(Status, String)>> {
148 let range = format!("{base}...HEAD");
149 let output = Command::new("git")
152 .current_dir(repo)
153 .args([
154 "-c",
155 "core.quotepath=off",
156 "diff",
157 "--name-status",
158 "--no-ext-diff",
159 "--no-renames",
160 "--relative",
161 &range,
162 ])
163 .output()
164 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
165 if !output.status.success() {
166 bail!(
167 "`git diff {range}` failed in `{}`: {}",
168 repo.display(),
169 String::from_utf8_lossy(&output.stderr).trim()
170 );
171 }
172 let stdout = String::from_utf8_lossy(&output.stdout);
173 let mut entries = Vec::new();
174 for line in stdout.lines() {
175 if let Some((status, path)) = line.split_once('\t') {
176 let path = crate::patch_coverage::unquote_c_path(path.trim_end_matches('\r'));
179 let path = path.replace('\\', "/");
180 entries.push((Status::from_code(status), path));
181 }
182 }
183 Ok(entries)
184}
185
186#[cfg(test)]
187mod tests {
188 use std::sync::atomic::{AtomicU64, Ordering};
189
190 use super::*;
191
192 struct TempRepo(PathBuf);
193
194 impl TempRepo {
195 fn new(slug: &str) -> Self {
196 static COUNTER: AtomicU64 = AtomicU64::new(0);
197 let root = std::env::temp_dir().join(format!(
198 "tc-co-change-git-{}-{}-{}",
199 slug,
200 std::process::id(),
201 COUNTER.fetch_add(1, Ordering::Relaxed),
202 ));
203 std::fs::create_dir_all(&root).unwrap();
204 let repo = TempRepo(root);
205 repo.git(&["init", "-q"]);
206 repo.git(&["config", "user.email", "test@example.com"]);
207 repo.git(&["config", "user.name", "Test"]);
208 repo
209 }
210
211 fn git(&self, args: &[&str]) {
212 let status = Command::new("git")
213 .args(args)
214 .current_dir(&self.0)
215 .status()
216 .expect("git should run");
217 assert!(status.success(), "git {args:?} failed");
218 }
219
220 fn commit(&self, rel: &str, contents: &str) {
221 std::fs::write(self.0.join(rel), contents).unwrap();
222 self.git(&["add", "-A"]);
223 self.git(&["-c", "commit.gpgsign=false", "commit", "-q", "-m", rel]);
224 }
225
226 fn head(&self) -> String {
227 let out = Command::new("git")
228 .args(["rev-parse", "HEAD"])
229 .current_dir(&self.0)
230 .output()
231 .expect("git rev-parse should run");
232 assert!(out.status.success(), "git rev-parse failed");
233 String::from_utf8(out.stdout).unwrap().trim().to_string()
234 }
235 }
236
237 impl Drop for TempRepo {
238 fn drop(&mut self) {
239 let _ = std::fs::remove_dir_all(&self.0);
240 }
241 }
242
243 #[test]
244 fn merge_base_answers_where_the_branch_left_trunk() {
245 let repo = TempRepo::new("mb");
246 repo.commit("widget.py", "x = 1\n");
247 repo.git(&["checkout", "-q", "-b", "trunk"]);
248 let fork_point = repo.head();
249 repo.git(&["checkout", "-q", "-b", "feature"]);
250 repo.commit("widget.py", "x = 2\n");
251 repo.git(&["checkout", "-q", "trunk"]);
252 repo.commit("widget.py", "x = 3\n");
253 let trunk_tip = repo.head();
254 repo.git(&["checkout", "-q", "feature"]);
255
256 assert_eq!(merge_base(&repo.0, "trunk").unwrap(), fork_point);
257 assert_ne!(fork_point, trunk_tip);
258 }
259
260 #[test]
261 fn merge_base_errors_when_the_histories_never_met() {
262 let repo = TempRepo::new("mb-orphan");
263 repo.commit("widget.py", "x = 1\n");
264 repo.git(&["checkout", "-q", "-b", "trunk"]);
265 repo.git(&["checkout", "-q", "--orphan", "stranger"]);
266 repo.commit("widget.py", "x = 2\n");
267
268 let err = merge_base(&repo.0, "trunk").unwrap_err();
269 assert!(err.to_string().contains("git merge-base"), "got: {err}");
270 }
271
272 #[test]
273 fn blob_at_reads_the_file_as_it_stood() {
274 let repo = TempRepo::new("blob");
275 repo.commit("widget.py", "x = 1\n");
276 let first = repo.head();
277 repo.commit("widget.py", "x = 2\n");
278
279 assert_eq!(blob_at(&repo.0, &first, "widget.py").unwrap(), "x = 1\n");
280 assert_eq!(
281 blob_at(&repo.0, &repo.head(), "widget.py").unwrap(),
282 "x = 2\n"
283 );
284 }
285
286 #[test]
287 fn blob_at_errors_when_the_path_is_absent() {
288 let repo = TempRepo::new("blob-missing");
289 repo.commit("widget.py", "x = 1\n");
290
291 let err = blob_at(&repo.0, &repo.head(), "ghost.py").unwrap_err();
292 assert!(err.to_string().contains("ghost.py"), "got: {err}");
293 }
294}