1use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet};
16
17use anyhow::{Context, Result};
18use git2::{Delta, DiffFindOptions, DiffOptions, Repository};
19
20#[derive(Debug, Clone, Copy)]
22struct Hunk {
23 old_start: u32,
24 old_lines: u32,
25 new_lines: u32,
26}
27
28#[derive(Debug, Clone)]
30pub struct FileDiff {
31 pub old_path: Option<String>,
34 pub new_path: String,
36 pub is_new: bool,
38 pub is_rename: bool,
40 pub added: BTreeSet<u32>,
42 pub removed: BTreeSet<u32>,
44 context: BTreeMap<u32, u32>,
46 hunks: Vec<Hunk>,
48}
49
50impl FileDiff {
51 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 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 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 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#[derive(Debug, Clone, Default)]
106pub struct DiffModel {
107 pub files: BTreeMap<String, FileDiff>,
109}
110
111impl DiffModel {
112 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 pub fn from_diff(diff: &git2::Diff) -> Result<Self> {
144 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
230pub 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 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 let fd = file_diff(
290 vec![Hunk {
291 old_start: 10,
292 old_lines: 2,
293 new_lines: 5,
294 }],
295 &[],
296 );
297 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 assert_eq!(fd.map_base_to_head(10), Some(10));
328 assert_eq!(fd.map_base_to_head(13), Some(13));
329 assert_eq!(fd.map_base_to_head(11), None);
331 }
332
333 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 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)); 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}