1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
//! The `git diff --name-status` read behind the `co-change` rule.
use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
/// The diff status of a changed file, narrowed to what the rule acts on.
pub(crate) enum Status {
/// `M` — content changed; a subject if the file still declares behavior.
Modified,
/// `D` — removed; a subject only if the source had a colocated test in base.
Deleted,
/// `A` (added) and the rest (`T`, …) — not a co-change subject.
Other,
}
impl Status {
/// The status from a `git diff --name-status` field; `--no-renames` makes it one letter.
fn from_code(code: &str) -> Status {
match code.chars().next() {
Some('M') => Status::Modified,
Some('D') => Status::Deleted,
_ => Status::Other,
}
}
}
/// The status + `repo`-relative path of every file changed in `<base>...HEAD`, via
/// `git diff --name-status`. `--no-renames` shows a rename as a delete + an add, so the deleted
/// source is still held to its test; `--relative` scopes the diff to `repo`.
pub(crate) fn changed_entries(repo: &Path, base: &str) -> Result<Vec<(Status, String)>> {
let range = format!("{base}...HEAD");
// `core.quotepath=off` emits a non-ASCII path raw rather than octal-escaped, so a modified
// `src/föö.py` reads back as a real file; `--no-ext-diff` blocks a configured external differ.
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') {
// A name with a `"`, a backslash, or a control byte still comes C-quoted even
// with `core.quotepath=off`.
let path = crate::git_path::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 super::*;
#[test]
fn a_status_code_narrows_to_what_the_rule_acts_on() {
assert!(matches!(Status::from_code("M"), Status::Modified));
assert!(matches!(Status::from_code("D"), Status::Deleted));
assert!(matches!(Status::from_code("A"), Status::Other));
assert!(matches!(Status::from_code(""), Status::Other));
}
#[test]
fn changed_entries_reports_a_spawn_failure() {
let err = changed_entries(Path::new("/nonexistent-tc-git-diff"), "main")
.err()
.expect("the missing repo errors");
assert!(format!("{err:#}").contains("running `git diff`"));
}
}