g1t/services/repos/src/diff.rs
| 1 | //! Comparing two commits: which files changed, and how. |
| 2 | //! |
| 3 | //! Trees are walked together and identical subtrees are skipped by hash, so |
| 4 | //! the cost follows the size of the change rather than the repository. |
| 5 | |
| 6 | use std::collections::BTreeMap; |
| 7 | |
| 8 | use g1t_contracts::repos::{DiffLine, EntryKind, FileDiff, FileStatus, Hunk, LineKind, TreeEntry}; |
| 9 | use futures_util::future::{try_join, try_join_all}; |
| 10 | use similar::{ChangeTag, TextDiff}; |
| 11 | use worker::Result; |
| 12 | |
| 13 | use crate::store::GitRepo; |
| 14 | |
| 15 | /// Beyond these the comparison is cut short and marked truncated. |
| 16 | const MAX_FILES: usize = 300; |
| 17 | const MAX_LINES: usize = 20_000; |
| 18 | /// Files larger than this are listed without their lines. |
| 19 | const MAX_FILE_BYTES: usize = 512 * 1024; |
| 20 | const CONTEXT_LINES: usize = 3; |
| 21 | |
| 22 | /// A file that differs between the two trees. |
| 23 | struct Change { |
| 24 | path: String, |
| 25 | old: Option<String>, |
| 26 | new: Option<String>, |
| 27 | } |
| 28 | |
| 29 | async fn entries<R: GitRepo>(repo: &R, tree: Option<&str>) -> Result<BTreeMap<String, TreeEntry>> { |
| 30 | let Some(tree) = tree else { |
| 31 | return Ok(BTreeMap::new()); |
| 32 | }; |
| 33 | Ok(repo |
| 34 | .read_tree(tree) |
| 35 | .await? |
| 36 | .unwrap_or_default() |
| 37 | .into_iter() |
| 38 | .map(|entry| (entry.name.clone(), entry)) |
| 39 | .collect()) |
| 40 | } |
| 41 | |
| 42 | /// How many files' contents are read at once. |
| 43 | const READS_AT_ONCE: usize = 16; |
| 44 | |
| 45 | /// Collects the files that differ between two trees. Each level of the |
| 46 | /// trees is read at once, since every read is a round trip to the store |
| 47 | /// and the levels' trees do not depend on each other. |
| 48 | async fn changed_files<R: GitRepo>( |
| 49 | repo: &R, |
| 50 | old_root: Option<&str>, |
| 51 | new_root: &str, |
| 52 | ) -> Result<(Vec<Change>, bool)> { |
| 53 | let mut changes = Vec::new(); |
| 54 | let mut level = vec![( |
| 55 | String::new(), |
| 56 | old_root.map(str::to_owned), |
| 57 | Some(new_root.to_owned()), |
| 58 | )]; |
| 59 | while !level.is_empty() { |
| 60 | let read = try_join_all(level.iter().map(|(_, old_tree, new_tree)| async move { |
| 61 | let (old, new) = try_join( |
| 62 | entries(repo, old_tree.as_deref()), |
| 63 | entries(repo, new_tree.as_deref()), |
| 64 | ) |
| 65 | .await?; |
| 66 | Ok::<_, worker::Error>((old, new)) |
| 67 | })) |
| 68 | .await?; |
| 69 | let mut next = Vec::new(); |
| 70 | for ((prefix, _, _), (old, new)) in level.iter().zip(read) { |
| 71 | let names: std::collections::BTreeSet<&String> = old.keys().chain(new.keys()).collect(); |
| 72 | for name in names { |
| 73 | let (before, after) = (old.get(name), new.get(name)); |
| 74 | // A file made executable keeps its hash, and is still a change. |
| 75 | if before.map(|e| (&e.hash, e.kind)) == after.map(|e| (&e.hash, e.kind)) { |
| 76 | continue; |
| 77 | } |
| 78 | let path = format!("{prefix}{name}"); |
| 79 | let subtree = |entry: Option<&TreeEntry>| { |
| 80 | entry |
| 81 | .filter(|entry| entry.kind == EntryKind::Tree) |
| 82 | .map(|entry| entry.hash.clone()) |
| 83 | }; |
| 84 | let file = |entry: Option<&TreeEntry>| { |
| 85 | entry |
| 86 | .filter(|entry| entry.kind != EntryKind::Tree) |
| 87 | .map(|entry| entry.hash.clone()) |
| 88 | }; |
| 89 | let (old_dir, new_dir) = (subtree(before), subtree(after)); |
| 90 | if old_dir.is_some() || new_dir.is_some() { |
| 91 | next.push((format!("{path}/"), old_dir, new_dir)); |
| 92 | } |
| 93 | let (old_file, new_file) = (file(before), file(after)); |
| 94 | if old_file.is_some() || new_file.is_some() { |
| 95 | if changes.len() >= MAX_FILES { |
| 96 | changes.sort_by(|a: &Change, b: &Change| a.path.cmp(&b.path)); |
| 97 | return Ok((changes, true)); |
| 98 | } |
| 99 | changes.push(Change { |
| 100 | path, |
| 101 | old: old_file, |
| 102 | new: new_file, |
| 103 | }); |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | level = next; |
| 108 | } |
| 109 | changes.sort_by(|a, b| a.path.cmp(&b.path)); |
| 110 | Ok((changes, false)) |
| 111 | } |
| 112 | |
| 113 | /// The paths of the files that differ between two trees, without reading |
| 114 | /// any file: what is needed to see whether two changes touch the same |
| 115 | /// files. Returns the paths and whether the list was cut short. |
| 116 | pub async fn changed_paths<R: GitRepo>( |
| 117 | repo: &R, |
| 118 | old_tree: Option<&str>, |
| 119 | new_tree: &str, |
| 120 | ) -> Result<(Vec<String>, bool)> { |
| 121 | let (changes, truncated) = changed_files(repo, old_tree, new_tree).await?; |
| 122 | Ok((changes.into_iter().map(|change| change.path).collect(), truncated)) |
| 123 | } |
| 124 | |
| 125 | /// The text of a blob, or `None` if it is binary, too large or missing. |
| 126 | async fn text<R: GitRepo>(repo: &R, hash: Option<&str>) -> Result<Option<String>> { |
| 127 | let Some(hash) = hash else { |
| 128 | return Ok(Some(String::new())); |
| 129 | }; |
| 130 | let Some(bytes) = repo.read_blob(hash).await? else { |
| 131 | return Ok(None); |
| 132 | }; |
| 133 | if bytes.len() > MAX_FILE_BYTES || bytes.contains(&0) { |
| 134 | return Ok(None); |
| 135 | } |
| 136 | Ok(String::from_utf8(bytes).ok()) |
| 137 | } |
| 138 | |
| 139 | fn line_diff(old: &str, new: &str) -> (Vec<Hunk>, u32, u32) { |
| 140 | let diff = TextDiff::from_lines(old, new); |
| 141 | let (mut additions, mut deletions) = (0, 0); |
| 142 | let hunks = diff |
| 143 | .grouped_ops(CONTEXT_LINES) |
| 144 | .iter() |
| 145 | .map(|group| Hunk { |
| 146 | lines: group |
| 147 | .iter() |
| 148 | .flat_map(|op| diff.iter_changes(op)) |
| 149 | .map(|change| { |
| 150 | let kind = match change.tag() { |
| 151 | ChangeTag::Equal => LineKind::Context, |
| 152 | ChangeTag::Insert => { |
| 153 | additions += 1; |
| 154 | LineKind::Add |
| 155 | } |
| 156 | ChangeTag::Delete => { |
| 157 | deletions += 1; |
| 158 | LineKind::Delete |
| 159 | } |
| 160 | }; |
| 161 | DiffLine { |
| 162 | kind, |
| 163 | old: change.old_index().map(|index| index as u32 + 1), |
| 164 | new: change.new_index().map(|index| index as u32 + 1), |
| 165 | text: change.value().trim_end_matches(['\r', '\n']).to_owned(), |
| 166 | } |
| 167 | }) |
| 168 | .collect(), |
| 169 | }) |
| 170 | .collect(); |
| 171 | (hunks, additions, deletions) |
| 172 | } |
| 173 | |
| 174 | /// The files that differ between two trees, with their line changes. |
| 175 | /// Returns the files and whether the result was cut short. |
| 176 | pub async fn compare_trees<R: GitRepo>( |
| 177 | repo: &R, |
| 178 | old_tree: Option<&str>, |
| 179 | new_tree: &str, |
| 180 | ) -> Result<(Vec<FileDiff>, bool)> { |
| 181 | let (changes, mut truncated) = changed_files(repo, old_tree, new_tree).await?; |
| 182 | let mut files = Vec::with_capacity(changes.len()); |
| 183 | let mut lines = 0; |
| 184 | // Contents are read a batch at a time, all of a batch at once. |
| 185 | let mut texts_read: Vec<Option<(String, String)>> = Vec::with_capacity(changes.len()); |
| 186 | for batch in changes.chunks(READS_AT_ONCE) { |
| 187 | let read = try_join_all(batch.iter().map(|change| async move { |
| 188 | let (old, new) = try_join( |
| 189 | text(repo, change.old.as_deref()), |
| 190 | text(repo, change.new.as_deref()), |
| 191 | ) |
| 192 | .await?; |
| 193 | Ok::<_, worker::Error>(old.zip(new)) |
| 194 | })) |
| 195 | .await?; |
| 196 | lines += read |
| 197 | .iter() |
| 198 | .flatten() |
| 199 | .map(|(old, new)| old.lines().count().max(new.lines().count())) |
| 200 | .sum::<usize>(); |
| 201 | texts_read.extend(read); |
| 202 | if lines >= MAX_LINES { |
| 203 | break; |
| 204 | } |
| 205 | } |
| 206 | let mut texts_read = texts_read.into_iter(); |
| 207 | lines = 0; |
| 208 | for change in changes { |
| 209 | let status = match (&change.old, &change.new) { |
| 210 | (None, _) => FileStatus::Added, |
| 211 | (_, None) => FileStatus::Deleted, |
| 212 | _ => FileStatus::Modified, |
| 213 | }; |
| 214 | let texts = match texts_read.next() { |
| 215 | Some(texts) if lines < MAX_LINES => texts, |
| 216 | _ => { |
| 217 | truncated = true; |
| 218 | None |
| 219 | } |
| 220 | }; |
| 221 | let (hunks, additions, deletions, binary) = match texts { |
| 222 | Some((old, new)) => { |
| 223 | let (hunks, additions, deletions) = line_diff(&old, &new); |
| 224 | (hunks, additions, deletions, false) |
| 225 | } |
| 226 | None => (Vec::new(), 0, 0, true), |
| 227 | }; |
| 228 | lines += hunks.iter().map(|hunk| hunk.lines.len()).sum::<usize>(); |
| 229 | files.push(FileDiff { |
| 230 | path: change.path, |
| 231 | status, |
| 232 | additions, |
| 233 | deletions, |
| 234 | binary, |
| 235 | hunks, |
| 236 | }); |
| 237 | } |
| 238 | Ok((files, truncated)) |
| 239 | } |