Skip to main content

choir_revert_scan/
choir-revert-scan.rs

1//! `choir-revert-scan`: falsification (c), as a binary with a number.
2//!
3//! Points [`choir_merge::silent_revert`] at one or more repositories and
4//! reports how many landed merges removed or added lines that neither
5//! side did. Read-only: it clones nothing, fetches nothing, and writes
6//! nothing anywhere.
7//!
8//! ```text
9//! choir-revert-scan [--max N] [--quiet] <repo>...
10//! ```
11//!
12//! `--max N` caps the merges examined per repository (0, the default, is
13//! all of them). `--quiet` prints the counts without the individual
14//! findings, which is what you want across fifty repositories and not
15//! what you want before quoting the number at anybody: the findings are
16//! where the false positives are visible.
17//!
18//! Exit status is 0 whether or not anything is found. **Both outcomes
19//! are answers** -- the kill criterion was fixed before the number was
20//! known -- so a zero here is a result, not a failure, and must not be
21//! wired to a gate that treats it as one.
22
23use choir_merge::silent_revert::{scan_repo, ScanReport};
24
25fn main() {
26    let args: Vec<String> = std::env::args().skip(1).collect();
27    let mut max = 0usize;
28    let mut quiet = false;
29    let mut repos: Vec<String> = Vec::new();
30    let mut it = args.iter();
31    while let Some(arg) = it.next() {
32        match arg.as_str() {
33            "--max" => match it.next().and_then(|n| n.parse().ok()) {
34                Some(n) => max = n,
35                None => fail("--max wants a number"),
36            },
37            "--quiet" => quiet = true,
38            other if other.starts_with("--") => fail(&format!("unknown flag {other}")),
39            other => repos.push(other.to_string()),
40        }
41    }
42    if repos.is_empty() {
43        fail("usage: choir-revert-scan [--max N] [--quiet] <repo>...");
44    }
45
46    let mut total = ScanReport::default();
47    for repo in &repos {
48        match scan_repo(std::path::Path::new(repo), max) {
49            Ok(report) => {
50                println!(
51                    "{repo}: {} merges scanned of {} seen, {} findings across {} paths",
52                    report.merges_scanned,
53                    report.merges_seen,
54                    report.findings.len(),
55                    report.paths_scanned
56                );
57                total.absorb(report);
58            }
59            // One unreadable repository must not cost the other
60            // forty-nine. It is counted by being named here and by not
61            // appearing in the totals.
62            Err(e) => eprintln!("{repo}: skipped, {e}"),
63        }
64    }
65
66    if !quiet {
67        for f in &total.findings {
68            println!("\n{} {}", &f.merge[..f.merge.len().min(12)], f.path);
69            for line in &f.reverted {
70                println!("  reverted: {line}");
71            }
72            for line in &f.injected {
73                println!("  injected: {line}");
74            }
75        }
76    }
77
78    println!("\n  repositories       {}", repos.len());
79    println!("  merges seen        {}", total.merges_seen);
80    println!("  merges scanned     {}", total.merges_scanned);
81    println!("  paths scanned      {}", total.paths_scanned);
82    println!("  skipped octopus    {}", total.skipped_octopus);
83    println!("  skipped no base    {}", total.skipped_no_base);
84    println!("  skipped binary     {}", total.skipped_binary);
85    println!("  skipped large      {}", total.skipped_large);
86    println!("  lines relocated    {}", total.lines_relocated);
87    println!("  paths all moved    {}", total.paths_all_relocated);
88    println!("  lines surviving    {}", total.lines_surviving_elsewhere);
89    println!("  findings           {}", total.findings.len());
90    println!("  of those, surviving {}", total.findings_all_surviving);
91    println!("  merges violating   {:.4}", total.merge_violation_rate());
92    // Said every run, because the number is the part that travels and
93    // this is the part that keeps it honest.
94    println!(
95        "\n  A finding is evidence, not proof: a merge whose conflicts a\n  \
96         human resolved by hand may legitimately drop lines neither\n  \
97         parent dropped. \"Surviving\" counts reverted lines still\n  \
98         present somewhere in the result, which is what a refactor that\n  \
99         moves code looks like; they are counted and still reported,\n  \
100         because presence is a cheap test a short line passes by\n  \
101         accident. The true rate is between the two.\n  \
102         Look at the findings before quoting either number."
103    );
104}
105
106/// Prints `msg` and exits 2, the way a usage error should.
107fn fail(msg: &str) -> ! {
108    eprintln!("choir-revert-scan: {msg}");
109    std::process::exit(2);
110}