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//!
4//! Convention: when a source file is **modified** (e.g. a function removed from
5//! `foo.py`) or **deleted** in a commit range, its colocated test — the
6//! pairing, `foo.py` → `foo_test.py`, `foo.ts` → `foo.test.ts` — must also be in
7//! that diff. This catches edits and removals that leave the test silently stale.
8//! A modification counts when it changes the code the compiler sees: the file at the
9//! merge base and the file at HEAD are compared with comments and formatting
10//! whitespace normalized away ([`Language::same_code`]), so a comment reword or a
11//! whitespace sweep leaves the test current and passes.
12//! *Added* source files are not subjects: brand-new code is the coverage floor's
13//! job, not this one. A **deletion** is a subject only if the source *had* a
14//! colocated test in the base tree — a package barrel (`__init__.py`, `index.ts`)
15//! with no sibling test can be deleted without one appearing in the diff, so it is
16//! not flagged and needs no exemption.
17//!
18//! [`stale_sources`] walks `git diff --name-status <base>...HEAD` for a
19//! [`Language`] and returns every changed source file whose colocated test did
20//! not co-change. A file listed in the config `exempt` table (rule `co-change`)
21//! is a deliberate, reason-required omission. Rust has no sibling test file —
22//! units are inline `#[cfg(test)]` in the same `.rs` — so the rule is
23//! Python/TypeScript only (the CLI rejects `--language rust`).
24
25use std::collections::BTreeSet;
26use std::path::{Path, PathBuf};
27use std::process::Command;
28
29use anyhow::{bail, Context, Result};
30
31use crate::colocated_test::Language;
32
33/// Every source file changed in `repo`'s `<base>...HEAD` diff whose colocated
34/// test did not also change — the stale-test risks — sorted for deterministic
35/// output.
36///
37/// A source file is a subject when it was **modified** into a different program while
38/// still declaring behavior ([`Language::is_subject`], the predicate the presence rule
39/// reads, and [`Language::same_code`], which reads what the edit changed), or **deleted**
40/// while it *had* a colocated test in the base tree (the test now at risk of being
41/// orphaned); an **added** file is not (new code is the coverage floor's concern),
42/// nor is a deleted barrel that never had a sibling test.
43/// A subject whose `repo`-relative path is in `exempt` is a deliberate omission and
44/// is skipped. Everything else must have its colocated test (`foo.py` →
45/// `foo_test.py`, per `language`) somewhere in the same diff.
46///
47/// Returns an error if `git diff` fails — e.g. `base` names no resolvable ref —
48/// so an un-diffable range surfaces rather than silently passing as "clean".
49pub fn stale_sources(
50    repo: &Path,
51    base: &str,
52    language: Language,
53    exempt: &BTreeSet<String>,
54) -> Result<Vec<PathBuf>> {
55    let entries = changed_entries(repo, base)?;
56    let fork_point = merge_base(repo, base)?;
57    // Every changed path, so a subject's expected test is a set lookup rather
58    // than a second walk of the diff.
59    let changed: BTreeSet<&str> = entries.iter().map(|(_, path)| path.as_str()).collect();
60    // `<package root>/tests/` belongs to the suite tiers (integration / e2e),
61    // so nothing under it is a co-change subject.
62    let suite_tests = match language {
63        Language::Python => crate::tiers::suite_tests_dir(repo, "pyproject.toml"),
64        Language::TypeScript => crate::tiers::suite_tests_dir(repo, "package.json"),
65        Language::Rust => None,
66    };
67
68    let mut stale = Vec::new();
69    for (status, rel) in &entries {
70        let path = Path::new(rel);
71        // A test file, a support file (Python `conftest.py`), or anything this
72        // language doesn't track is never a co-change subject.
73        if !language.tracks(path) || language.is_test(path) || language.is_support(path) {
74            continue;
75        }
76        if suite_tests
77            .as_ref()
78            .is_some_and(|tests| repo.join(path).starts_with(tests))
79        {
80            continue;
81        }
82        let expected = language
83            .expected_test_path(path)
84            .to_string_lossy()
85            .replace('\\', "/");
86        // Only an edit or a removal can leave a test stale; a brand-new source is
87        // the coverage floor's concern, not this rule's.
88        let is_subject = match status {
89            Status::Modified => {
90                // The file's own contents decide, through the predicate presence reads:
91                // an empty / comment-only file and a type-only TypeScript module hold no
92                // behavior, so editing one cannot leave a test stale. Deciding on the
93                // diff's shape alone would flag a module for having no colocated test —
94                // the fact presence uses to skip it.
95                let contents = std::fs::read_to_string(repo.join(path))
96                    .with_context(|| format!("reading changed source `{rel}`"))?;
97                // What the edit did decides the rest: a comment reword or a whitespace
98                // sweep leaves the compiler the same program, so the colocated test still
99                // pins the behavior the file has.
100                language.is_subject(&contents, path)
101                    && !language.same_code(&blob_at(repo, &fork_point, rel)?, &contents, path)
102            }
103            // A deletion is a subject only if the source *had* a colocated test in
104            // the base tree — the test now at risk of being orphaned. A source that
105            // never had a sibling test (a package barrel: `__init__.py`, `index.ts`)
106            // can be removed without a test appearing in the diff, so it is not
107            // flagged and needs no exemption to delete it. HEAD can't answer
108            // this — the file is gone — so we ask `base`.
109            Status::Deleted => test_exists_in_base(repo, base, &expected)?,
110            Status::Other => false,
111        };
112        if !is_subject || exempt.contains(rel) {
113            continue;
114        }
115        if !changed.contains(expected.as_str()) {
116            stale.push(path.to_path_buf());
117        }
118    }
119    stale.sort();
120    Ok(stale)
121}
122
123/// The diff status of a changed file, narrowed to what the rule acts on.
124enum Status {
125    /// `M` — content changed; a subject if the file still declares behavior.
126    Modified,
127    /// `D` — removed; a subject only if the source had a colocated test in base
128    /// (its test should go too), never for a barrel that never had one.
129    Deleted,
130    /// `A` (added) and the rest (`T`, …) — not a co-change subject.
131    Other,
132}
133
134impl Status {
135    /// The status from a `git diff --name-status` status field. With
136    /// `--no-renames` it is a single letter, so only the first char matters.
137    fn from_code(code: &str) -> Status {
138        match code.chars().next() {
139            Some('M') => Status::Modified,
140            Some('D') => Status::Deleted,
141            _ => Status::Other,
142        }
143    }
144}
145
146/// `true` when `rel` (a `repo`-relative path) exists as a blob in the `base` tree.
147///
148/// Used to tell a deleted source that once had a colocated test — its test should
149/// be removed too, so a stale leftover is worth flagging — from a barrel that never
150/// had one, which can be deleted without a test co-changing. Runs
151/// `git cat-file -e <base>:./<rel>`: the `./` makes git resolve the path relative to
152/// `repo` (the diff's `--relative` root), matching the paths [`changed_entries`]
153/// returns, rather than the repo's top level. A missing blob exits non-zero (→
154/// `false`); the `base` ref itself already resolved for [`changed_entries`], so a
155/// non-zero exit here means "no such path in base", not a bad ref.
156fn test_exists_in_base(repo: &Path, base: &str, rel: &str) -> Result<bool> {
157    let spec = format!("{base}:./{rel}");
158    let output = Command::new("git")
159        .current_dir(repo)
160        .args(["cat-file", "-e", &spec])
161        .output()
162        .with_context(|| format!("running `git cat-file` in `{}`", repo.display()))?;
163    Ok(output.status.success())
164}
165
166/// The commit `<base>...HEAD` diffs from — the merge base of `base` and HEAD.
167///
168/// The modify arm compares a subject against its contents *here*, not at `base`'s tip: the
169/// tip carries commits this branch never saw, so a file the branch only commented would read
170/// as a code change. [`changed_entries`] already resolved the three-dot range, which needs the
171/// same merge base, so a failure here names a repo whose history moved underfoot.
172fn merge_base(repo: &Path, base: &str) -> Result<String> {
173    let output = Command::new("git")
174        .current_dir(repo)
175        .args(["merge-base", base, "HEAD"])
176        .output()
177        .with_context(|| format!("running `git merge-base` in `{}`", repo.display()))?;
178    if !output.status.success() {
179        bail!(
180            "`git merge-base {base} HEAD` failed in `{}`: {}",
181            repo.display(),
182            String::from_utf8_lossy(&output.stderr).trim()
183        );
184    }
185    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
186}
187
188/// The contents of `rel` (a `repo`-relative path) at `commit`.
189///
190/// `git show <commit>:./<rel>` resolves the path relative to `repo` — the diff's `--relative`
191/// root — matching the paths [`changed_entries`] returns. The diff named `rel` as modified, so
192/// a blob that fails to read is a real error, never a file to skip quietly.
193fn blob_at(repo: &Path, commit: &str, rel: &str) -> Result<String> {
194    let spec = format!("{commit}:./{rel}");
195    let output = Command::new("git")
196        .current_dir(repo)
197        .args(["show", &spec])
198        .output()
199        .with_context(|| format!("running `git show {spec}` in `{}`", repo.display()))?;
200    if !output.status.success() {
201        bail!(
202            "reading `{rel}` at `{commit}` in `{}`: {}",
203            repo.display(),
204            String::from_utf8_lossy(&output.stderr).trim()
205        );
206    }
207    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
208}
209
210/// The status + `repo`-relative path of every file changed in `<base>...HEAD`,
211/// via `git diff --name-status`.
212///
213/// `<base>...HEAD` is the merge-base diff — the changes this branch introduced
214/// (what a PR shows), not whatever else moved on `base`. Rename detection is off
215/// (`--no-renames`), so a rename shows as a delete + an add (each its own line of
216/// `<status>\t<path>`) and the deleted source is still held to its test;
217/// `--relative` scopes the diff to `repo` and reports paths relative to it.
218fn changed_entries(repo: &Path, base: &str) -> Result<Vec<(Status, String)>> {
219    let range = format!("{base}...HEAD");
220    // `-c core.quotepath=off --no-ext-diff` pins the walk against the caller's git
221    // config (#392): a non-ASCII path is emitted raw rather than octal-escaped, so a
222    // `Modified` `src/föö.py` keys correctly (and reads back as a real file) instead of
223    // hard-erroring; a configured external differ is blocked. `--name-status` carries no
224    // `a/`/`b/` prefix, so no prefix pinning is needed here.
225    let output = Command::new("git")
226        .current_dir(repo)
227        .args([
228            "-c",
229            "core.quotepath=off",
230            "diff",
231            "--name-status",
232            "--no-ext-diff",
233            "--no-renames",
234            "--relative",
235            &range,
236        ])
237        .output()
238        .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
239    if !output.status.success() {
240        bail!(
241            "`git diff {range}` failed in `{}`: {}",
242            repo.display(),
243            String::from_utf8_lossy(&output.stderr).trim()
244        );
245    }
246    let stdout = String::from_utf8_lossy(&output.stdout);
247    let mut entries = Vec::new();
248    for line in stdout.lines() {
249        // `<status>\t<path>` — the status is a single letter with `--no-renames`.
250        if let Some((status, path)) = line.split_once('\t') {
251            // Decode a residual C-quoted path (a name with a `"` / backslash / control
252            // byte still comes quoted even with `core.quotepath=off`) before normalizing
253            // separators (#392).
254            let path = crate::patch_coverage::unquote_c_path(path.trim_end_matches('\r'));
255            let path = path.replace('\\', "/");
256            entries.push((Status::from_code(status), path));
257        }
258    }
259    Ok(entries)
260}
261
262#[cfg(test)]
263mod tests {
264    use std::sync::atomic::{AtomicU64, Ordering};
265
266    use super::*;
267
268    /// A throwaway git repo, removed on drop.
269    struct TempRepo(PathBuf);
270
271    impl TempRepo {
272        fn new(slug: &str) -> Self {
273            static COUNTER: AtomicU64 = AtomicU64::new(0);
274            let root = std::env::temp_dir().join(format!(
275                "tc-co-change-git-{}-{}-{}",
276                slug,
277                std::process::id(),
278                COUNTER.fetch_add(1, Ordering::Relaxed),
279            ));
280            std::fs::create_dir_all(&root).unwrap();
281            let repo = TempRepo(root);
282            repo.git(&["init", "-q"]);
283            repo.git(&["config", "user.email", "test@example.com"]);
284            repo.git(&["config", "user.name", "Test"]);
285            repo
286        }
287
288        fn git(&self, args: &[&str]) {
289            let status = Command::new("git")
290                .args(args)
291                .current_dir(&self.0)
292                .status()
293                .expect("git should run");
294            assert!(status.success(), "git {args:?} failed");
295        }
296
297        /// Write `contents` to `rel` and commit it, advancing HEAD.
298        fn commit(&self, rel: &str, contents: &str) {
299            std::fs::write(self.0.join(rel), contents).unwrap();
300            self.git(&["add", "-A"]);
301            self.git(&["-c", "commit.gpgsign=false", "commit", "-q", "-m", rel]);
302        }
303
304        fn head(&self) -> String {
305            let out = Command::new("git")
306                .args(["rev-parse", "HEAD"])
307                .current_dir(&self.0)
308                .output()
309                .expect("git rev-parse should run");
310            assert!(out.status.success(), "git rev-parse failed");
311            String::from_utf8(out.stdout).unwrap().trim().to_string()
312        }
313    }
314
315    impl Drop for TempRepo {
316        fn drop(&mut self) {
317            let _ = std::fs::remove_dir_all(&self.0);
318        }
319    }
320
321    #[test]
322    fn merge_base_answers_where_the_branch_left_trunk() {
323        let repo = TempRepo::new("mb");
324        repo.commit("widget.py", "x = 1\n");
325        repo.git(&["checkout", "-q", "-b", "trunk"]);
326        let fork_point = repo.head();
327        repo.git(&["checkout", "-q", "-b", "feature"]);
328        repo.commit("widget.py", "x = 2\n");
329        repo.git(&["checkout", "-q", "trunk"]);
330        repo.commit("widget.py", "x = 3\n");
331        let trunk_tip = repo.head();
332        repo.git(&["checkout", "-q", "feature"]);
333
334        // The commit the branch forked from, not the tip trunk has since reached.
335        assert_eq!(merge_base(&repo.0, "trunk").unwrap(), fork_point);
336        assert_ne!(fork_point, trunk_tip);
337    }
338
339    #[test]
340    fn merge_base_errors_when_the_histories_never_met() {
341        let repo = TempRepo::new("mb-orphan");
342        repo.commit("widget.py", "x = 1\n");
343        repo.git(&["checkout", "-q", "-b", "trunk"]);
344        repo.git(&["checkout", "-q", "--orphan", "stranger"]);
345        repo.commit("widget.py", "x = 2\n");
346
347        let err = merge_base(&repo.0, "trunk").unwrap_err();
348        assert!(err.to_string().contains("git merge-base"), "got: {err}");
349    }
350
351    #[test]
352    fn blob_at_reads_the_file_as_it_stood() {
353        let repo = TempRepo::new("blob");
354        repo.commit("widget.py", "x = 1\n");
355        let first = repo.head();
356        repo.commit("widget.py", "x = 2\n");
357
358        assert_eq!(blob_at(&repo.0, &first, "widget.py").unwrap(), "x = 1\n");
359        assert_eq!(
360            blob_at(&repo.0, &repo.head(), "widget.py").unwrap(),
361            "x = 2\n"
362        );
363    }
364
365    #[test]
366    fn blob_at_errors_when_the_path_is_absent() {
367        let repo = TempRepo::new("blob-missing");
368        repo.commit("widget.py", "x = 1\n");
369
370        let err = blob_at(&repo.0, &repo.head(), "ghost.py").unwrap_err();
371        assert!(err.to_string().contains("ghost.py"), "got: {err}");
372    }
373}