Skip to main content

omni_dev/coverage/
diff.rs

1//! Unified-diff model built from `git2`.
2//!
3//! [`DiffModel`] captures exactly what coverage attribution needs from a diff:
4//! per file, the set of *added* (new-side) line numbers, and a deterministic
5//! base↔head alignment for *unchanged* lines so coverage can be compared across
6//! versions without heuristics. Added lines come from the `+` lines of the diff;
7//! the alignment is derived from hunk offsets (`@@ -a,b +c,d @@`) plus the exact
8//! context-line pairs `git2` reports inside each hunk.
9//!
10//! Rename detection is enabled so a moved file is diffed against its original
11//! content — added lines and "lost coverage" are attributed correctly instead of
12//! the whole file looking new.
13
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet};
16
17use anyhow::{Context, Result};
18use git2::{Delta, DiffFindOptions, DiffOptions, Repository};
19
20/// One hunk's line ranges, used to compute the unchanged-line offset.
21#[derive(Debug, Clone, Copy)]
22struct Hunk {
23    old_start: u32,
24    old_lines: u32,
25    new_lines: u32,
26}
27
28/// Per-file diff information.
29#[derive(Debug, Clone)]
30pub struct FileDiff {
31    /// Old-side (base) path. Differs from `new_path` on a rename; `None` for an
32    /// added file.
33    pub old_path: Option<String>,
34    /// New-side (head) path.
35    pub new_path: String,
36    /// Whether the file is newly added in head.
37    pub is_new: bool,
38    /// Whether the file was renamed (and possibly modified).
39    pub is_rename: bool,
40    /// New-side line numbers added or modified by the diff.
41    pub added: BTreeSet<u32>,
42    /// Old-side line numbers removed by the diff.
43    pub removed: BTreeSet<u32>,
44    /// Exact `(old_line, new_line)` pairs for context lines inside hunks.
45    context: BTreeMap<u32, u32>,
46    /// Hunk ranges, ordered by old-side start.
47    hunks: Vec<Hunk>,
48}
49
50impl FileDiff {
51    /// Builds a [`FileDiff`] with only added/removed line sets and no hunk
52    /// alignment. Used to construct diffs programmatically and in tests; the
53    /// `git2`-driven path uses [`DiffModel::from_diff`] which also records hunks.
54    pub fn new(
55        new_path: impl Into<String>,
56        old_path: Option<String>,
57        is_new: bool,
58        is_rename: bool,
59        added: BTreeSet<u32>,
60        removed: BTreeSet<u32>,
61    ) -> Self {
62        Self {
63            old_path,
64            new_path: new_path.into(),
65            is_new,
66            is_rename,
67            added,
68            removed,
69            context: BTreeMap::new(),
70            hunks: Vec::new(),
71        }
72    }
73
74    /// Maps an unchanged base-side line to its head-side line number.
75    ///
76    /// Returns `None` when the base line was removed or modified (i.e. it has no
77    /// unchanged counterpart in head). Context lines inside hunks use the exact
78    /// pairs `git2` reported; lines outside every hunk use the cumulative hunk
79    /// offset.
80    pub fn map_base_to_head(&self, old_line: u32) -> Option<u32> {
81        if let Some(&new_line) = self.context.get(&old_line) {
82            return Some(new_line);
83        }
84        // Inside a hunk's old-range but not a recorded context line ⇒ removed/modified.
85        if self
86            .hunks
87            .iter()
88            .any(|h| old_line >= h.old_start && old_line < h.old_start + h.old_lines)
89        {
90            return None;
91        }
92        // Outside all hunks: shift by the net line delta of all preceding hunks.
93        let delta: i64 = self
94            .hunks
95            .iter()
96            .filter(|h| h.old_start + h.old_lines <= old_line)
97            .map(|h| i64::from(h.new_lines) - i64::from(h.old_lines))
98            .sum();
99        let mapped = i64::from(old_line) + delta;
100        u32::try_from(mapped).ok().filter(|&n| n >= 1)
101    }
102}
103
104/// A whole diff: the set of changed files between two revisions.
105#[derive(Debug, Clone, Default)]
106pub struct DiffModel {
107    /// Changed files, keyed by new-side path.
108    pub files: BTreeMap<String, FileDiff>,
109}
110
111impl DiffModel {
112    /// Builds a diff model between `base_ref` and `head_ref` in `repo`.
113    ///
114    /// `head_ref` defaults to `HEAD` when `None`. Both revisions are resolved
115    /// with `revparse_single` (so branch names, tags, and SHAs all work).
116    pub fn between(repo: &Repository, base_ref: &str, head_ref: Option<&str>) -> Result<Self> {
117        let base_tree = repo
118            .revparse_single(base_ref)
119            .with_context(|| format!("could not resolve base ref `{base_ref}`"))?
120            .peel_to_tree()
121            .with_context(|| format!("base ref `{base_ref}` is not a tree-ish"))?;
122        let head_ref = head_ref.unwrap_or("HEAD");
123        let head_tree = repo
124            .revparse_single(head_ref)
125            .with_context(|| format!("could not resolve head ref `{head_ref}`"))?
126            .peel_to_tree()
127            .with_context(|| format!("head ref `{head_ref}` is not a tree-ish"))?;
128
129        let mut opts = DiffOptions::new();
130        opts.context_lines(3);
131        let mut diff = repo
132            .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))
133            .context("failed to diff base against head")?;
134        let mut find = DiffFindOptions::new();
135        find.renames(true).copies(true);
136        diff.find_similar(Some(&mut find))
137            .context("failed to run rename detection")?;
138
139        Self::from_diff(&diff)
140    }
141
142    /// Builds a diff model from an already-computed `git2` diff.
143    pub fn from_diff(diff: &git2::Diff) -> Result<Self> {
144        // Interior mutability so the three foreach callbacks can share the
145        // accumulator (git2 hands each a separate `&mut FnMut`).
146        let builders: RefCell<BTreeMap<String, FileDiff>> = RefCell::new(BTreeMap::new());
147
148        let new_path_of = |delta: &git2::DiffDelta| -> Option<String> {
149            delta
150                .new_file()
151                .path()
152                .and_then(|p| p.to_str())
153                .map(str::to_string)
154        };
155
156        diff.foreach(
157            &mut |delta, _progress| {
158                if let Some(new_path) = new_path_of(&delta) {
159                    let old_path = delta
160                        .old_file()
161                        .path()
162                        .and_then(|p| p.to_str())
163                        .map(str::to_string);
164                    let status = delta.status();
165                    builders
166                        .borrow_mut()
167                        .entry(new_path.clone())
168                        .or_insert(FileDiff {
169                            old_path: old_path.filter(|_| status != Delta::Added),
170                            new_path,
171                            is_new: status == Delta::Added,
172                            is_rename: status == Delta::Renamed || status == Delta::Copied,
173                            added: BTreeSet::new(),
174                            removed: BTreeSet::new(),
175                            context: BTreeMap::new(),
176                            hunks: Vec::new(),
177                        });
178                }
179                true
180            },
181            None,
182            Some(&mut |delta, hunk| {
183                if let Some(new_path) = new_path_of(&delta) {
184                    if let Some(file) = builders.borrow_mut().get_mut(&new_path) {
185                        file.hunks.push(Hunk {
186                            old_start: hunk.old_start(),
187                            old_lines: hunk.old_lines(),
188                            new_lines: hunk.new_lines(),
189                        });
190                    }
191                }
192                true
193            }),
194            Some(&mut |delta, _hunk, line| {
195                if let Some(new_path) = new_path_of(&delta) {
196                    if let Some(file) = builders.borrow_mut().get_mut(&new_path) {
197                        match line.origin() {
198                            '+' => {
199                                if let Some(n) = line.new_lineno() {
200                                    file.added.insert(n);
201                                }
202                            }
203                            '-' => {
204                                if let Some(n) = line.old_lineno() {
205                                    file.removed.insert(n);
206                                }
207                            }
208                            ' ' => {
209                                if let (Some(o), Some(n)) = (line.old_lineno(), line.new_lineno()) {
210                                    file.context.insert(o, n);
211                                }
212                            }
213                            _ => {}
214                        }
215                    }
216                }
217                true
218            }),
219        )
220        .context("failed to walk diff")?;
221
222        let mut files = builders.into_inner();
223        for file in files.values_mut() {
224            file.hunks.sort_by_key(|h| h.old_start);
225        }
226        Ok(Self { files })
227    }
228}
229
230/// Resolves the default base ref: the merge-base of `origin/main` (falling back
231/// to `main`) and `HEAD`, returned as a hex SHA.
232pub fn default_base_ref(repo: &Repository) -> Result<String> {
233    let head = repo
234        .head()
235        .context("could not resolve HEAD")?
236        .peel_to_commit()
237        .context("HEAD is not a commit")?
238        .id();
239    let main = repo
240        .revparse_single("origin/main")
241        .or_else(|_| repo.revparse_single("main"))
242        .context("could not resolve `origin/main` or `main` for the default base ref")?
243        .peel_to_commit()
244        .context("base branch is not a commit")?
245        .id();
246    let base = repo
247        .merge_base(main, head)
248        .context("could not compute merge-base of base branch and HEAD")?;
249    Ok(base.to_string())
250}
251
252#[cfg(test)]
253#[allow(clippy::unwrap_used, clippy::expect_used)]
254mod tests {
255    use super::*;
256
257    /// Builds a `FileDiff` with the given hunks and context pairs for testing the
258    /// alignment function in isolation.
259    fn file_diff(hunks: Vec<Hunk>, context: &[(u32, u32)]) -> FileDiff {
260        FileDiff {
261            old_path: Some("f".into()),
262            new_path: "f".into(),
263            is_new: false,
264            is_rename: false,
265            added: BTreeSet::new(),
266            removed: BTreeSet::new(),
267            context: context.iter().copied().collect(),
268            hunks,
269        }
270    }
271
272    #[test]
273    fn lines_before_first_hunk_map_identically() {
274        let fd = file_diff(
275            vec![Hunk {
276                old_start: 10,
277                old_lines: 2,
278                new_lines: 5,
279            }],
280            &[],
281        );
282        assert_eq!(fd.map_base_to_head(1), Some(1));
283        assert_eq!(fd.map_base_to_head(9), Some(9));
284    }
285
286    #[test]
287    fn lines_after_hunk_shift_by_net_delta() {
288        // Hunk replaces 2 old lines with 5 new lines: +3 shift afterwards.
289        let fd = file_diff(
290            vec![Hunk {
291                old_start: 10,
292                old_lines: 2,
293                new_lines: 5,
294            }],
295            &[],
296        );
297        // old line 12 is the first line after the hunk's old-range [10,12).
298        assert_eq!(fd.map_base_to_head(12), Some(15));
299        assert_eq!(fd.map_base_to_head(20), Some(23));
300    }
301
302    #[test]
303    fn modified_lines_inside_hunk_have_no_mapping() {
304        let fd = file_diff(
305            vec![Hunk {
306                old_start: 10,
307                old_lines: 2,
308                new_lines: 5,
309            }],
310            &[],
311        );
312        assert_eq!(fd.map_base_to_head(10), None);
313        assert_eq!(fd.map_base_to_head(11), None);
314    }
315
316    #[test]
317    fn context_pairs_take_precedence() {
318        let fd = file_diff(
319            vec![Hunk {
320                old_start: 10,
321                old_lines: 4,
322                new_lines: 4,
323            }],
324            &[(10, 10), (13, 13)],
325        );
326        // Recorded context lines map exactly even though inside the hunk range.
327        assert_eq!(fd.map_base_to_head(10), Some(10));
328        assert_eq!(fd.map_base_to_head(13), Some(13));
329        // 11/12 are inside the hunk and not context ⇒ no mapping.
330        assert_eq!(fd.map_base_to_head(11), None);
331    }
332
333    /// Commits `files` to `repo`, returning the new commit's id.
334    fn commit(
335        repo: &Repository,
336        path: &std::path::Path,
337        files: &[(&str, &str)],
338        parent: Option<git2::Oid>,
339    ) -> git2::Oid {
340        let mut index = repo.index().unwrap();
341        index.clear().unwrap();
342        for (name, content) in files {
343            std::fs::write(path.join(name), content).unwrap();
344            index.add_path(std::path::Path::new(name)).unwrap();
345        }
346        index.write().unwrap();
347        let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
348        let sig = git2::Signature::now("T", "t@e.com").unwrap();
349        let parent = parent.map(|id| repo.find_commit(id).unwrap());
350        let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect();
351        repo.commit(Some("HEAD"), &sig, &sig, "c", &tree, &parents)
352            .unwrap()
353    }
354
355    #[test]
356    fn default_base_ref_is_merge_base_with_main() {
357        let dir = tempfile::tempdir().unwrap();
358        let path = dir.path();
359        let repo = Repository::init(path).unwrap();
360        let first = commit(&repo, path, &[("a.rs", "1\n")], None);
361        // Mark `main` at the first commit, then advance HEAD.
362        repo.branch("main", &repo.find_commit(first).unwrap(), false)
363            .unwrap();
364        commit(&repo, path, &[("a.rs", "1\n2\n")], Some(first));
365
366        let base = default_base_ref(&repo).unwrap();
367        assert_eq!(base, first.to_string());
368    }
369
370    #[test]
371    fn between_records_removed_lines() {
372        let dir = tempfile::tempdir().unwrap();
373        let path = dir.path();
374        let repo = Repository::init(path).unwrap();
375        let first = commit(&repo, path, &[("a.rs", "a\nb\nc\n")], None);
376        commit(&repo, path, &[("a.rs", "a\nc\n")], Some(first)); // remove line "b"
377        let diff = DiffModel::between(&repo, &first.to_string(), Some("HEAD")).unwrap();
378        let fd = diff.files.get("a.rs").unwrap();
379        assert!(fd.removed.contains(&2), "removed old line 2 (b)");
380    }
381
382    #[test]
383    fn between_errors_on_bad_base_ref() {
384        let dir = tempfile::tempdir().unwrap();
385        let path = dir.path();
386        let repo = Repository::init(path).unwrap();
387        commit(&repo, path, &[("a.rs", "1\n")], None);
388        assert!(DiffModel::between(&repo, "does-not-exist", Some("HEAD")).is_err());
389    }
390}