choir_merge/silent_revert.rs
1//! Falsification (c): how often does a landed merge remove work that
2//! neither side removed?
3//!
4//! [`crate::safety::check`] answers that question for one merge. This
5//! module points it at a repository's real merge history so the question
6//! can be answered with a number instead of an argument. The plan it
7//! serves, its kill criterion and the reasoning behind both live outside
8//! the repository; what matters here is that **the criterion was fixed
9//! before the number was known**, so a near-zero result is an answer and
10//! not a prompt to re-cut the measurement.
11//!
12//! # What is compared, and to what
13//!
14//! For a merge commit `M` with first parent `T` (the target branch, the
15//! side that was being merged *onto*) and second parent `P` (the
16//! proposal), with `B = merge-base(T, P)`:
17//!
18//! ```text
19//! base = B:<path> what the author wrote against
20//! target = T:<path> the state being merged onto
21//! proposed = P:<path> what the author wrote
22//! result = M:<path> what actually landed
23//! ```
24//!
25//! which is exactly [`crate::safety::check`]'s signature. A path missing
26//! from a tree reads as empty, so an added file compares as an addition
27//! and a deleted file as a removal, both of which the bag comparison
28//! already handles.
29//!
30//! Only paths where `M` differs from `T` are examined. A path the merge
31//! left identical to the target has an empty landing delta and therefore
32//! always upholds, so scanning it would cost four blob reads to learn
33//! nothing.
34//!
35//! # What a finding is worth, stated before any are counted
36//!
37//! A violation here is **evidence of a silent revert, not proof of a
38//! defect**, and the honest reading is bounded on both sides:
39//!
40//! - *False positives are expected and are the dominant noise.* A merge
41//! whose conflicts a human resolved by hand is free to remove lines
42//! neither parent removed, and that is a correct resolution, not a
43//! silent revert. Automatic merges — a merge queue's, `--no-ff` on a
44//! clean tree — are where a finding means what it says. The report
45//! keeps the two apart only as far as git lets it, which is not far;
46//! any non-zero count wants eyes on the individual findings before it
47//! is quoted at anybody.
48//! - *False negatives dominate the other direction.* The comparison is
49//! over line bags, so a merge that moves a line, or applies an edit at
50//! the wrong occurrence of a repeated line, is bag-neutral and passes.
51//! That is [`crate::safety`]'s stated positional blindness, inherited
52//! here whole.
53//!
54//! So the count is a **lower bound on a noisy signal**. It is worth
55//! having anyway, for the same reason [`choir-queue`'s corpus module]
56//! is: it needs nothing but git — no CI history, no issue tracker, no
57//! human labelling — and an unmeasured rate cannot be argued with at
58//! all.
59//!
60//! [`choir-queue`'s corpus module]: https://docs.rs/choir-queue
61//!
62//! # Shape
63//!
64//! Same split as `choir-queue::corpus`: parsing and scanning are pure
65//! functions over text, and every shell-out is a separate call, so the
66//! tests are hermetic and the expensive part is the caller's problem.
67//!
68//! # Examples
69//!
70//! ```
71//! use choir_merge::silent_revert::{scan_merge, Blobs, ScanReport};
72//!
73//! // The target added a line after the fork; the proposal touched a
74//! // different one; the landed result dropped the target's line.
75//! let blobs = Blobs {
76//! base: "a\n".into(),
77//! target: "a\nkeep\n".into(),
78//! proposed: "a\nb\n".into(),
79//! result: "a\nb\n".into(),
80//! };
81//! let mut report = ScanReport::default();
82//! scan_merge("deadbeef", &[("src/x.rs".to_string(), blobs)], &mut report);
83//!
84//! assert_eq!(report.findings.len(), 1);
85//! assert_eq!(report.findings[0].reverted, vec!["keep".to_string()]);
86//! ```
87
88use crate::safety::{check, SafetyVerdict};
89
90/// The `--format` string [`parse_merges`] expects.
91///
92/// Oid and parents, unit-separated, records separated by 0x1e — the same
93/// framing `choir-queue::corpus` uses, and for the same reason: neither
94/// byte can appear in an oid.
95pub const MERGE_LOG_FORMAT: &str = "--format=%H%x1f%P%x1e";
96
97/// Blobs larger than this are skipped rather than compared.
98///
99/// A minified bundle or a checked-in binary that happens to decode as
100/// UTF-8 would otherwise dominate both the runtime and any finding it
101/// produced, and a line-bag comparison says nothing useful about either.
102pub const MAX_BLOB_BYTES: usize = 1 << 20;
103
104/// One merge commit, reduced to what the scan needs.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct MergeCommit {
107 /// Full object id of the merge.
108 pub id: String,
109 /// Parent oids, first parent first.
110 pub parents: Vec<String>,
111}
112
113/// The four texts [`crate::safety::check`] compares, for one path.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Blobs {
116 /// `merge-base(target, proposed)` at this path; empty if absent.
117 pub base: String,
118 /// First parent at this path; empty if absent.
119 pub target: String,
120 /// Second parent at this path; empty if absent.
121 pub proposed: String,
122 /// The merge commit itself at this path; empty if absent.
123 pub result: String,
124}
125
126/// One path in one merge that removed or added lines neither side did.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Finding {
129 /// Oid of the merge commit.
130 pub merge: String,
131 /// Repository-relative path.
132 pub path: String,
133 /// Lines the landing removed that the proposal never removed.
134 pub reverted: Vec<String>,
135 /// Lines the landing added that the proposal never added.
136 pub injected: Vec<String>,
137}
138
139/// What one repository's scan counted.
140///
141/// Every skip is counted rather than dropped: a rate whose denominator
142/// quietly excluded the hard cases is the failure mode this whole
143/// measurement exists to avoid.
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct ScanReport {
146 /// Merge commits the log produced.
147 pub merges_seen: usize,
148 /// Merge commits actually compared.
149 pub merges_scanned: usize,
150 /// Merges with three or more parents, which this comparison does not
151 /// model: there is no single `proposed` side.
152 pub skipped_octopus: usize,
153 /// Merges whose parents share no merge base, or whose objects are
154 /// missing from a filtered clone.
155 pub skipped_no_base: usize,
156 /// Paths compared.
157 pub paths_scanned: usize,
158 /// Paths where some blob was not valid UTF-8.
159 pub skipped_binary: usize,
160 /// Paths where some blob exceeded [`MAX_BLOB_BYTES`].
161 pub skipped_large: usize,
162 /// Distinct lines cancelled as moved between paths within one
163 /// merge rather than lost. See [`scan_merge`].
164 pub lines_relocated: usize,
165 /// Paths whose whole apparent violation was relocation, and which
166 /// therefore produced no finding.
167 pub paths_all_relocated: usize,
168 /// Distinct lines reported as reverted that are nonetheless present
169 /// somewhere in the merge result. Counted, never cancelled: see
170 /// [`scan_merge`] on why this is a second number and not a filter.
171 pub lines_surviving_elsewhere: usize,
172 /// Findings every one of whose reverted lines survives somewhere in
173 /// the result, and which carry no injection either. These are the
174 /// candidates for refactor noise rather than lost work.
175 pub findings_all_surviving: usize,
176 /// The violations, in the order found.
177 pub findings: Vec<Finding>,
178}
179
180impl ScanReport {
181 /// Merges carrying at least one finding, over merges scanned.
182 ///
183 /// Returns 0.0 for an empty scan rather than a NaN: "nothing was
184 /// examined" and "nothing was found" must not print the same way.
185 #[must_use]
186 pub fn merge_violation_rate(&self) -> f64 {
187 if self.merges_scanned == 0 {
188 return 0.0;
189 }
190 let mut ids: Vec<&str> = self.findings.iter().map(|f| f.merge.as_str()).collect();
191 ids.sort_unstable();
192 ids.dedup();
193 ids.len() as f64 / self.merges_scanned as f64
194 }
195
196 /// Folds another repository's report into this one.
197 pub fn absorb(&mut self, other: Self) {
198 self.merges_seen += other.merges_seen;
199 self.merges_scanned += other.merges_scanned;
200 self.skipped_octopus += other.skipped_octopus;
201 self.skipped_no_base += other.skipped_no_base;
202 self.paths_scanned += other.paths_scanned;
203 self.skipped_binary += other.skipped_binary;
204 self.skipped_large += other.skipped_large;
205 self.lines_relocated += other.lines_relocated;
206 self.paths_all_relocated += other.paths_all_relocated;
207 self.lines_surviving_elsewhere += other.lines_surviving_elsewhere;
208 self.findings_all_surviving += other.findings_all_surviving;
209 self.findings.extend(other.findings);
210 }
211}
212
213/// Parses `git log --merges MERGE_LOG_FORMAT` output.
214///
215/// Records with no parents, or unparseable framing, are dropped rather
216/// than erroring: a corpus is not a wire format, and one malformed
217/// record must not cost the other ten thousand.
218#[must_use]
219pub fn parse_merges(log: &str) -> Vec<MergeCommit> {
220 log.split('\u{1e}')
221 .filter_map(|record| {
222 let record = record.trim_start_matches('\n');
223 let mut fields = record.split('\u{1f}');
224 let id = fields.next()?.trim();
225 let parents: Vec<String> = fields
226 .next()?
227 .split_whitespace()
228 .map(str::to_string)
229 .collect();
230 (!id.is_empty() && !parents.is_empty()).then(|| MergeCommit {
231 id: id.to_string(),
232 parents,
233 })
234 })
235 .collect()
236}
237
238/// Whether a line is worth reporting as evidence.
239///
240/// Blank and whitespace-only lines are not. They move between files
241/// constantly, they carry no work, and on the first real corpus this
242/// scanner was pointed at they were the single most common "finding".
243fn is_evidence(line: &str) -> bool {
244 !line.trim().is_empty()
245}
246
247/// Applies the safety check to every path of one merge, counting into
248/// `report`.
249///
250/// Pure: the caller supplies the blobs. `merges_scanned` is incremented
251/// here, so a caller that skips a merge before reaching this function
252/// must count that skip itself.
253///
254/// # Relocation
255///
256/// [`crate::safety::check`] compares one path against itself, so a merge
257/// that *moves* content from one file to another looks like a reversion
258/// in the source and an injection in the destination. That is not a
259/// silent revert: nothing was lost. The first corpus this was pointed at
260/// produced exactly that, a documentation section moved between two
261/// files during the merge, and it was 40% of the raw findings.
262///
263/// So after every path of a merge is checked, a line that appears as
264/// reverted in one path *and* injected in another path of the same merge
265/// is cancelled from both and counted in
266/// [`ScanReport::lines_relocated`]. The cancellation is per merge and
267/// never across merges: content leaving one commit and appearing in
268/// another, later, is not a move, and treating it as one would hide the
269/// exact class this scan exists to count.
270///
271/// # Survival, which is a second number rather than a second filter
272///
273/// That cancellation requires the line to be *unattributable at both
274/// ends*. A refactor that moves a function into a file the author was
275/// already editing does not qualify: the destination addition is
276/// attributable to `base -> proposed`, so it never enters the injected
277/// set, so it cannot cancel anything, and the source removal is reported
278/// as a reversion of work that is sitting in the result untouched. On
279/// `git/git` that is most of what the raw findings are -- the object
280/// database refactor moving blocks out of `object-file.c` reads as
281/// fourteen reverted lines.
282///
283/// [`ScanReport::lines_surviving_elsewhere`] counts reverted lines that
284/// are present somewhere in this merge's result, and
285/// [`ScanReport::findings_all_surviving`] counts findings made entirely
286/// of them.
287///
288/// **They are counted and still reported.** Cancelling them would be the
289/// stronger detector and the weaker measurement: line presence anywhere
290/// in a result is a cheap test that a short or idiomatic line passes by
291/// accident, so silently dropping on it would remove true findings with
292/// no way to see how many. The kill criterion for this measurement was
293/// fixed before any number was known, and a filter added after seeing
294/// the data is exactly the move that discipline forbids. Two numbers let
295/// a reader bound the answer from both sides; one number chosen after
296/// the fact lets them do neither.
297pub fn scan_merge(merge: &str, paths: &[(String, Blobs)], report: &mut ScanReport) {
298 use std::collections::BTreeSet;
299
300 report.merges_scanned += 1;
301 let mut raw: Vec<Finding> = Vec::new();
302 for (path, blobs) in paths {
303 report.paths_scanned += 1;
304 if let SafetyVerdict::Violation(v) =
305 check(&blobs.base, &blobs.target, &blobs.proposed, &blobs.result)
306 {
307 raw.push(Finding {
308 merge: merge.to_string(),
309 path: path.clone(),
310 reverted: v.reverted.into_iter().filter(|l| is_evidence(l)).collect(),
311 injected: v.injected.into_iter().filter(|l| is_evidence(l)).collect(),
312 });
313 }
314 }
315
316 let reverted_anywhere: BTreeSet<&str> = raw
317 .iter()
318 .flat_map(|f| f.reverted.iter().map(String::as_str))
319 .collect();
320 let injected_anywhere: BTreeSet<&str> = raw
321 .iter()
322 .flat_map(|f| f.injected.iter().map(String::as_str))
323 .collect();
324 let relocated: BTreeSet<String> = reverted_anywhere
325 .intersection(&injected_anywhere)
326 .map(|l| (*l).to_string())
327 .collect();
328 report.lines_relocated += relocated.len();
329
330 // Every line the merge result holds, across the paths this merge
331 // touched. A reverted line found here left its file and did not
332 // leave the tree.
333 let survives: BTreeSet<&str> = paths
334 .iter()
335 .flat_map(|(_, blobs)| blobs.result.lines())
336 .filter(|l| is_evidence(l))
337 .collect();
338
339 let mut surviving_lines: BTreeSet<String> = BTreeSet::new();
340 for mut finding in raw {
341 finding.reverted.retain(|l| !relocated.contains(l));
342 finding.injected.retain(|l| !relocated.contains(l));
343 if finding.reverted.is_empty() && finding.injected.is_empty() {
344 report.paths_all_relocated += 1;
345 continue;
346 }
347 let outlives: Vec<&String> = finding
348 .reverted
349 .iter()
350 .filter(|l| survives.contains(l.as_str()))
351 .collect();
352 if finding.injected.is_empty() && outlives.len() == finding.reverted.len() {
353 report.findings_all_surviving += 1;
354 }
355 surviving_lines.extend(outlives.into_iter().cloned());
356 report.findings.push(finding);
357 }
358 report.lines_surviving_elsewhere += surviving_lines.len();
359}
360
361/// Runs `git` in `repo` with lazy fetching disabled, returning stdout.
362///
363/// Lazy fetching is off for the same reason `choir-queue::corpus` turns
364/// it off: on a partial clone, any command touching a missing object
365/// otherwise reaches for the network, which turns an offline measurement
366/// into a failed fetch that aborts the run.
367fn git(repo: &std::path::Path, args: &[&str]) -> Result<Vec<u8>, String> {
368 let out = std::process::Command::new("git")
369 .args(args)
370 .current_dir(repo)
371 .env("GIT_NO_LAZY_FETCH", "1")
372 .output()
373 .map_err(|e| format!("git {}: {e}", args.join(" ")))?;
374 if !out.status.success() {
375 return Err(format!(
376 "git {} failed: {}",
377 args.join(" "),
378 String::from_utf8_lossy(&out.stderr).trim()
379 ));
380 }
381 Ok(out.stdout)
382}
383
384/// The merge commits on `repo`'s first-parent mainline, newest first.
385///
386/// `max` caps them; 0 means all of them.
387///
388/// # Errors
389///
390/// Git failing to run, or exiting nonzero.
391pub fn merge_log(repo: &std::path::Path, max: usize) -> Result<Vec<MergeCommit>, String> {
392 let cap = format!("-{max}");
393 let mut args = vec!["log", "--first-parent", "--merges", MERGE_LOG_FORMAT];
394 if max > 0 {
395 args.push(&cap);
396 }
397 // Lossy: oids and the framing bytes are ASCII, and this format
398 // carries no message text for a replacement character to land in.
399 Ok(parse_merges(&String::from_utf8_lossy(&git(repo, &args)?)))
400}
401
402/// `git merge-base a b`, or `None` when they share none.
403///
404/// The first base is taken when there are several. Criss-cross histories
405/// have more than one and git's own `recursive` strategy synthesises a
406/// merge of them; approximating that with the first is a known
407/// imprecision, and it biases toward *findings* rather than away, which
408/// is the direction that gets looked at rather than believed.
409///
410/// # Errors
411///
412/// Never: a merge base git cannot produce is `None`, which the caller
413/// counts as a skip.
414pub fn merge_base(repo: &std::path::Path, a: &str, b: &str) -> Result<Option<String>, String> {
415 let out = std::process::Command::new("git")
416 .args(["merge-base", a, b])
417 .current_dir(repo)
418 .env("GIT_NO_LAZY_FETCH", "1")
419 .output()
420 .map_err(|e| format!("git merge-base: {e}"))?;
421 match out.status.code() {
422 Some(0) => Ok(String::from_utf8_lossy(&out.stdout)
423 .split_whitespace()
424 .next()
425 .map(str::to_string)),
426 // 1 is "no merge base"; anything else is a missing object on a
427 // filtered clone, and both mean the same thing to the caller.
428 _ => Ok(None),
429 }
430}
431
432/// Paths where `to` differs from `from`.
433///
434/// NUL-separated, because a repository is free to contain a path with a
435/// newline in it and `--name-only` alone would quote it.
436///
437/// # Errors
438///
439/// Git failing to run, or exiting nonzero.
440pub fn changed_paths(repo: &std::path::Path, from: &str, to: &str) -> Result<Vec<String>, String> {
441 let out = git(repo, &["diff", "--name-only", "-z", from, to])?;
442 Ok(out
443 .split(|b| *b == 0)
444 .filter(|p| !p.is_empty())
445 .map(|p| String::from_utf8_lossy(p).into_owned())
446 .collect())
447}
448
449/// Reads many `<rev>:<path>` blobs in one `cat-file --batch`.
450///
451/// Results are positional -- `None` where git said `missing` -- so no
452/// path parsing is needed on the way back, which is what makes paths
453/// with spaces or newlines in them safe here.
454///
455/// # Errors
456///
457/// Git failing to spawn, or its output not matching the batch format.
458fn cat_file_batch(repo: &std::path::Path, revs: &[String]) -> Result<Vec<Option<Vec<u8>>>, String> {
459 use std::io::Write;
460 let mut child = std::process::Command::new("git")
461 .args(["cat-file", "--batch"])
462 .current_dir(repo)
463 .env("GIT_NO_LAZY_FETCH", "1")
464 .stdin(std::process::Stdio::piped())
465 .stdout(std::process::Stdio::piped())
466 .stderr(std::process::Stdio::null())
467 .spawn()
468 .map_err(|e| format!("git cat-file: {e}"))?;
469 let mut stdin = child.stdin.take().ok_or("git cat-file: no stdin")?;
470 let input: Vec<String> = revs.to_vec();
471 // The writer runs on its own thread. Blob output can be megabytes,
472 // so writing the whole request before reading any of it deadlocks
473 // as soon as the pipe buffer fills -- which is why this is not the
474 // write-then-wait shape `corpus::existing_commits` uses for
475 // --batch-check, whose output is one short line per request.
476 let writer = std::thread::spawn(move || {
477 for rev in &input {
478 if writeln!(stdin, "{rev}").is_err() {
479 return;
480 }
481 }
482 });
483 let out = child
484 .wait_with_output()
485 .map_err(|e| format!("git cat-file: {e}"))?;
486 writer.join().map_err(|_| "git cat-file: writer panicked")?;
487 parse_batch(&out.stdout, revs.len())
488}
489
490/// Splits `cat-file --batch` output into `count` positional results.
491///
492/// # Errors
493///
494/// A header that is neither `<oid> <type> <size>` nor `... missing`, or
495/// output that ends mid-blob.
496pub fn parse_batch(out: &[u8], count: usize) -> Result<Vec<Option<Vec<u8>>>, String> {
497 let mut results = Vec::with_capacity(count);
498 let mut at = 0usize;
499 while results.len() < count {
500 let end = out
501 .get(at..)
502 .and_then(|rest| rest.iter().position(|b| *b == b'\n'))
503 .ok_or("git cat-file: output ended before the last request")?
504 + at;
505 let header = String::from_utf8_lossy(&out[at..end]).into_owned();
506 at = end + 1;
507 if header.ends_with(" missing") {
508 results.push(None);
509 continue;
510 }
511 let size: usize = header
512 .rsplit(' ')
513 .next()
514 .and_then(|s| s.parse().ok())
515 .ok_or_else(|| format!("git cat-file: unparseable header {header:?}"))?;
516 if at + size > out.len() {
517 return Err("git cat-file: output ended mid-blob".to_string());
518 }
519 results.push(Some(out[at..at + size].to_vec()));
520 // The blob is followed by a newline git added, not one the blob
521 // contains.
522 at += size + 1;
523 }
524 Ok(results)
525}
526
527/// Scans one repository's merge history.
528///
529/// `max` caps the merges examined; 0 means all of them. One
530/// `cat-file --batch` runs per merge, carrying every blob that merge
531/// needs, which is what keeps a fifty-repository sweep to one subprocess
532/// per merge rather than four per path.
533///
534/// # Errors
535///
536/// Git failing to run at all. A merge git cannot answer for is counted
537/// as a skip and the scan continues; a directory that is not a
538/// repository is an error.
539pub fn scan_repo(repo: &std::path::Path, max: usize) -> Result<ScanReport, String> {
540 let merges = merge_log(repo, max)?;
541 let mut report = ScanReport {
542 merges_seen: merges.len(),
543 ..ScanReport::default()
544 };
545
546 for merge in &merges {
547 if merge.parents.len() != 2 {
548 report.skipped_octopus += 1;
549 continue;
550 }
551 let (target, proposed) = (&merge.parents[0], &merge.parents[1]);
552 let Some(base) = merge_base(repo, target, proposed)? else {
553 report.skipped_no_base += 1;
554 continue;
555 };
556 let Ok(paths) = changed_paths(repo, target, &merge.id) else {
557 report.skipped_no_base += 1;
558 continue;
559 };
560 if paths.is_empty() {
561 report.merges_scanned += 1;
562 continue;
563 }
564
565 let mut revs = Vec::with_capacity(paths.len() * 4);
566 for path in &paths {
567 for rev in [&base, target, proposed, &merge.id] {
568 revs.push(format!("{rev}:{path}"));
569 }
570 }
571 let blobs = cat_file_batch(repo, &revs)?;
572
573 let mut scannable = Vec::with_capacity(paths.len());
574 for (i, path) in paths.iter().enumerate() {
575 let four = &blobs[i * 4..i * 4 + 4];
576 if four.iter().flatten().any(|b| b.len() > MAX_BLOB_BYTES) {
577 report.skipped_large += 1;
578 continue;
579 }
580 let mut texts = Vec::with_capacity(4);
581 for blob in four {
582 match blob {
583 // A path absent from a tree reads as empty, which is
584 // what makes an added or deleted file compare
585 // correctly rather than being skipped.
586 None => texts.push(String::new()),
587 Some(bytes) => match std::str::from_utf8(bytes) {
588 Ok(s) => texts.push(s.to_string()),
589 Err(_) => break,
590 },
591 }
592 }
593 if texts.len() != 4 {
594 report.skipped_binary += 1;
595 continue;
596 }
597 let mut it = texts.into_iter();
598 scannable.push((
599 path.clone(),
600 Blobs {
601 base: it.next().unwrap_or_default(),
602 target: it.next().unwrap_or_default(),
603 proposed: it.next().unwrap_or_default(),
604 result: it.next().unwrap_or_default(),
605 },
606 ));
607 }
608 scan_merge(&merge.id, &scannable, &mut report);
609 }
610 Ok(report)
611}