Skip to main content

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}