Skip to main content

testing_conventions/
co_change.rs

1//! The commit-scoped `co-change` check: a source file that changed in a
2//! git diff must change its colocated test too.
3
4use 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
12/// Every source file changed in `repo`'s `<base>...HEAD` diff whose colocated test did not
13/// also change, sorted. A subject whose `repo`-relative path is in `exempt` is skipped. Errors
14/// when `git diff` fails, so an un-diffable range surfaces rather than passing as "clean".
15pub 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    // `<package root>/tests/` belongs to the suite tiers, so nothing under it is a subject.
25    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                // Deciding on the diff's shape alone would flag a type-only module for
50                // having no colocated test — the fact presence uses to skip it.
51                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            // HEAD cannot answer this — the file is gone — so `base` is asked instead.
57            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
71/// The diff status of a changed file, narrowed to what the rule acts on.
72enum Status {
73    /// `M` — content changed; a subject if the file still declares behavior.
74    Modified,
75    /// `D` — removed; a subject only if the source had a colocated test in base.
76    Deleted,
77    /// `A` (added) and the rest (`T`, …) — not a co-change subject.
78    Other,
79}
80
81impl Status {
82    /// The status from a `git diff --name-status` field; `--no-renames` makes it one letter.
83    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
92/// `true` when `rel` (a `repo`-relative path) exists as a blob in the `base` tree. The `./` in
93/// `git cat-file -e <base>:./<rel>` resolves the path relative to `repo`, matching the diff's
94/// `--relative` root; a non-zero exit means "no such path in base".
95fn 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
105/// The commit `<base>...HEAD` diffs from — the merge base of `base` and HEAD. Comparing against
106/// `base`'s tip instead would read a file the branch only commented as a code change, because the
107/// tip carries commits this branch never saw.
108fn 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
124/// The contents of `rel` (a `repo`-relative path) at `commit`. The `./` in
125/// `git show <commit>:./<rel>` resolves the path relative to `repo`, matching the diff's
126/// `--relative` root.
127fn 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
144/// The status + `repo`-relative path of every file changed in `<base>...HEAD`, via
145/// `git diff --name-status`. `--no-renames` shows a rename as a delete + an add, so the deleted
146/// source is still held to its test; `--relative` scopes the diff to `repo`.
147fn changed_entries(repo: &Path, base: &str) -> Result<Vec<(Status, String)>> {
148    let range = format!("{base}...HEAD");
149    // `core.quotepath=off` emits a non-ASCII path raw rather than octal-escaped, so a modified
150    // `src/föö.py` reads back as a real file; `--no-ext-diff` blocks a configured external differ.
151    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            // A name with a `"`, a backslash, or a control byte still comes C-quoted even
177            // with `core.quotepath=off`.
178            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}