differential_engine/
invariants.rs1use crate::EngineError;
5use crate::gitio::Repo;
6use crate::model::{DiffView, Disposition, Hunk};
7use crate::tree::build_tree;
8
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct InvariantReport {
11 pub files_total: usize,
12 pub applier_total: usize,
14 pub applier_ok: usize,
15 pub applier_mismatches: Vec<String>,
16 pub binary_oid_checked: usize,
18 pub hunks_total: usize,
19 pub accounting_ok: bool,
20 pub built_tree: Option<String>,
21 pub head_tree: String,
22 pub tree_ok: bool,
23 pub recount: usize,
24 pub recount_ok: bool,
25}
26
27impl InvariantReport {
28 pub fn all_ok(&self) -> bool {
29 self.applier_mismatches.is_empty()
30 && self.applier_ok == self.applier_total
31 && self.accounting_ok
32 && self.tree_ok
33 && self.recount_ok
34 }
35
36 pub fn applier_exact(&self) -> String {
38 format!("{}/{}", self.applier_ok, self.applier_total)
39 }
40}
41
42pub fn check_all(
45 repo: &Repo,
46 base: &str,
47 head: &str,
48 view: &DiffView,
49) -> Result<InvariantReport, EngineError> {
50 let mut applier_total = 0usize;
52 let mut applier_ok = 0usize;
53 let mut mismatches = Vec::new();
54 let mut binary_checked = 0usize;
55
56 for f in &view.files {
57 if f.submodule.is_some() {
58 continue;
59 }
60 if f.binary {
61 if let Some(oid) = &f.new_oid {
63 repo.run(["cat-file", "-e", oid], None)?;
64 }
65 binary_checked += 1;
66 continue;
67 }
68 applier_total += 1;
69 let hunks: Vec<&Hunk> = f.hunks.iter().map(|&i| &view.hunks[i]).collect();
70 let base_content = repo.blob(base, &f.path)?;
71 let got = crate::apply::apply_hunks(base_content.as_deref(), &hunks);
72 let want = if f.disposition == Disposition::Deleted {
73 Vec::new()
74 } else {
75 repo.blob(head, &f.path)?.unwrap_or_default()
76 };
77 if got == want {
78 applier_ok += 1;
79 } else {
80 mismatches.push(format!(
81 "{}: reconstructed {}B, expected {}B",
82 String::from_utf8_lossy(&f.path),
83 got.len(),
84 want.len()
85 ));
86 }
87 }
88
89 let mut seen = vec![false; view.hunks.len()];
91 let mut accounting_ok = true;
92 let mut carried = 0usize;
93 for (fi, f) in view.files.iter().enumerate() {
94 for &hi in &f.hunks {
95 if hi >= seen.len() || seen[hi] || view.hunks[hi].file != fi {
96 accounting_ok = false;
97 continue;
98 }
99 seen[hi] = true;
100 carried += 1;
101 }
102 }
103 accounting_ok &= carried == view.hunks.len();
104
105 let head_tree = repo.rev_parse_raw(&format!("{head}^{{tree}}"))?;
106
107 let (built_tree, tree_ok) = if mismatches.is_empty() && applier_ok == applier_total {
110 let built = build_tree(repo, base, view)?;
111 let ok = built == head_tree;
112 (Some(built), ok)
113 } else {
114 (None, false)
115 };
116
117 let (recount, recount_ok) = match &built_tree {
121 Some(t) => {
122 let out = repo.run(
123 ["diff-tree", "-r", "-U0", "--no-renames", base, t.as_str()],
124 None,
125 )?;
126 let n = dumb_hunk_count(&out);
127 (n, n == view.hunks.len())
128 }
129 None => (0, false),
130 };
131
132 Ok(InvariantReport {
133 files_total: view.files.len(),
134 applier_total,
135 applier_ok,
136 applier_mismatches: mismatches,
137 binary_oid_checked: binary_checked,
138 hunks_total: view.hunks.len(),
139 accounting_ok,
140 built_tree,
141 head_tree,
142 tree_ok,
143 recount,
144 recount_ok,
145 })
146}
147
148pub(crate) fn dumb_hunk_count(patch: &[u8]) -> usize {
151 patch
152 .split(|&b| b == b'\n')
153 .filter(|l| l.starts_with(b"@@ -"))
154 .count()
155}
156
157#[cfg(test)]
158mod tests {
159 use super::dumb_hunk_count;
160
161 #[test]
162 fn dumb_counter_counts_headers_only() {
163 let patch = b"diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-a\n+b\n@@ -9 +9 @@\n-x\n+y\n";
164 assert_eq!(dumb_hunk_count(patch), 2);
165 }
166
167 #[test]
168 fn dumb_counter_ignores_content_lines_that_mention_hunks() {
169 let patch = b"@@ -1,0 +1,1 @@\n+@@ -5,5 +5,5 @@\n";
172 assert_eq!(dumb_hunk_count(patch), 1);
173 }
174}