g1t/services/repos/src/blame.rs
| 1 | //! Which commit last changed each line of a file. |
| 2 | //! |
| 3 | //! History is walked newest first, as `git blame` does: a line still present, |
| 4 | //! unchanged, in one of a commit's parents is handed to that parent; a line in |
| 5 | //! none of them was written by the commit. Following every parent, not just |
| 6 | //! the first, matters here: catch-up merges bring the default branch into a |
| 7 | //! pull request, and their lines belong to whoever wrote them on that branch. |
| 8 | |
| 9 | use std::collections::HashMap; |
| 10 | |
| 11 | use g1t_contracts::repos::{Blame, BlameRange, Commit, EntryKind, TreeEntry}; |
| 12 | use similar::{ChangeTag, TextDiff}; |
| 13 | use worker::Result; |
| 14 | |
| 15 | use crate::store::GitRepo; |
| 16 | |
| 17 | /// How far back the history is searched. Lines older than this are given to |
| 18 | /// the oldest commit reached, and the answer is marked partial. |
| 19 | const MAX_COMMITS: u32 = 400; |
| 20 | /// Files larger than this are not blamed. |
| 21 | const MAX_FILE_BYTES: usize = 512 * 1024; |
| 22 | |
| 23 | /// Reads files out of commits, remembering trees and texts already read: |
| 24 | /// most commits share most of their trees. |
| 25 | struct Reader<'a, R: GitRepo> { |
| 26 | repo: &'a R, |
| 27 | trees: HashMap<String, Vec<TreeEntry>>, |
| 28 | texts: HashMap<String, Option<String>>, |
| 29 | } |
| 30 | |
| 31 | impl<'a, R: GitRepo> Reader<'a, R> { |
| 32 | /// The blob hash of `path` in the tree `root`, if it is a file there. |
| 33 | async fn blob_at(&mut self, root: &str, path: &str) -> Result<Option<String>> { |
| 34 | let mut tree = root.to_owned(); |
| 35 | let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); |
| 36 | for (index, part) in parts.iter().enumerate() { |
| 37 | if !self.trees.contains_key(&tree) { |
| 38 | let entries = self.repo.read_tree(&tree).await?.unwrap_or_default(); |
| 39 | self.trees.insert(tree.clone(), entries); |
| 40 | } |
| 41 | let Some(entry) = self.trees[&tree].iter().find(|entry| entry.name == *part) else { |
| 42 | return Ok(None); |
| 43 | }; |
| 44 | let last = index == parts.len() - 1; |
| 45 | match (last, entry.kind == EntryKind::Tree) { |
| 46 | (true, false) => return Ok(Some(entry.hash.clone())), |
| 47 | (false, true) => tree = entry.hash.clone(), |
| 48 | _ => return Ok(None), |
| 49 | } |
| 50 | } |
| 51 | Ok(None) |
| 52 | } |
| 53 | |
| 54 | /// The lines of a blob, or `None` if it is binary or too large. |
| 55 | async fn lines(&mut self, blob: &str) -> Result<Option<Vec<String>>> { |
| 56 | if !self.texts.contains_key(blob) { |
| 57 | let text = self |
| 58 | .repo |
| 59 | .read_blob(blob) |
| 60 | .await? |
| 61 | .filter(|bytes| bytes.len() <= MAX_FILE_BYTES && !bytes.contains(&0)) |
| 62 | .and_then(|bytes| String::from_utf8(bytes).ok()); |
| 63 | self.texts.insert(blob.to_owned(), text); |
| 64 | } |
| 65 | Ok(self.texts[blob] |
| 66 | .as_ref() |
| 67 | .map(|text| text.lines().map(str::to_owned).collect())) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /// For each line of `new`, the index of the same, unchanged line in `old`. |
| 72 | fn carried(old: &[String], new: &[String]) -> Vec<Option<usize>> { |
| 73 | // Every line ends in a newline, so a last line matches the same line elsewhere. |
| 74 | let text = |lines: &[String]| lines.iter().map(|line| format!("{line}\n")).collect::<String>(); |
| 75 | let (before, after) = (text(old), text(new)); |
| 76 | let diff = TextDiff::from_lines(&before, &after); |
| 77 | let mut map = vec![None; new.len()]; |
| 78 | for change in diff.iter_all_changes() { |
| 79 | if change.tag() == ChangeTag::Equal |
| 80 | && let (Some(o), Some(n)) = (change.old_index(), change.new_index()) |
| 81 | && let Some(slot) = map.get_mut(n) { |
| 82 | *slot = Some(o); |
| 83 | } |
| 84 | } |
| 85 | map |
| 86 | } |
| 87 | |
| 88 | /// Who last changed each line of `path` as of `head`. `None` if the file is |
| 89 | /// missing or not text. |
| 90 | pub async fn blame<R: GitRepo>( |
| 91 | repo: &R, |
| 92 | head: &str, |
| 93 | path: &str, |
| 94 | ) -> Result<Option<Blame>> { |
| 95 | let history = repo.log(head, MAX_COMMITS).await?; |
| 96 | let Some(first) = history.first() else { |
| 97 | return Ok(None); |
| 98 | }; |
| 99 | let mut reader = Reader { |
| 100 | repo, |
| 101 | trees: HashMap::new(), |
| 102 | texts: HashMap::new(), |
| 103 | }; |
| 104 | let Some(blob) = reader.blob_at(&first.tree_hash, path).await? else { |
| 105 | return Ok(None); |
| 106 | }; |
| 107 | let Some(lines) = reader.lines(&blob).await? else { |
| 108 | return Ok(None); |
| 109 | }; |
| 110 | let total = lines.len(); |
| 111 | let by_hash: HashMap<&str, &Commit> = |
| 112 | history.iter().map(|commit| (commit.hash.as_str(), commit)).collect(); |
| 113 | |
| 114 | // For each commit still to visit: (line in its version, line in the head's). |
| 115 | let mut pending: HashMap<String, Vec<(usize, usize)>> = HashMap::new(); |
| 116 | pending.insert(first.hash.clone(), (0..total).map(|line| (line, line)).collect()); |
| 117 | let mut owner: Vec<Option<String>> = vec![None; total]; |
| 118 | let mut partial = false; |
| 119 | |
| 120 | // The log is newest first, so a commit is reached after its children. |
| 121 | for commit in &history { |
| 122 | let Some(mut lines_here) = pending.remove(&commit.hash) else { |
| 123 | continue; |
| 124 | }; |
| 125 | lines_here.sort_unstable(); |
| 126 | lines_here.dedup_by_key(|(_, final_line)| *final_line); |
| 127 | let Some(blob) = reader.blob_at(&commit.tree_hash, path).await? else { |
| 128 | continue; |
| 129 | }; |
| 130 | let Some(text) = reader.lines(&blob).await? else { |
| 131 | continue; |
| 132 | }; |
| 133 | let mut unexplained = lines_here; |
| 134 | for parent_hash in &commit.parents { |
| 135 | if unexplained.is_empty() { |
| 136 | break; |
| 137 | } |
| 138 | let Some(parent) = by_hash.get(parent_hash.as_str()) else { |
| 139 | // Beyond the history read: these lines are at least this old. |
| 140 | partial = true; |
| 141 | continue; |
| 142 | }; |
| 143 | let Some(parent_blob) = reader.blob_at(&parent.tree_hash, path).await? else { |
| 144 | continue; |
| 145 | }; |
| 146 | let handed: Vec<(usize, usize)> = if parent_blob == blob { |
| 147 | std::mem::take(&mut unexplained) |
| 148 | } else { |
| 149 | let Some(parent_text) = reader.lines(&parent_blob).await? else { |
| 150 | continue; |
| 151 | }; |
| 152 | let map = carried(&parent_text, &text); |
| 153 | let (moved, kept): (Vec<_>, Vec<_>) = unexplained |
| 154 | .into_iter() |
| 155 | .partition(|(here, _)| map.get(*here).copied().flatten().is_some()); |
| 156 | unexplained = kept; |
| 157 | moved |
| 158 | .into_iter() |
| 159 | .map(|(here, final_line)| (map[here].unwrap_or(here), final_line)) |
| 160 | .collect() |
| 161 | }; |
| 162 | if !handed.is_empty() { |
| 163 | pending.entry(parent_hash.clone()).or_default().extend(handed); |
| 164 | } |
| 165 | } |
| 166 | for (_, final_line) in unexplained { |
| 167 | owner[final_line] = Some(commit.hash.clone()); |
| 168 | } |
| 169 | } |
| 170 | // Lines handed to commits older than the history read. |
| 171 | if !pending.is_empty() { |
| 172 | partial = true; |
| 173 | let oldest = history.last().map(|commit| commit.hash.clone()); |
| 174 | for line in owner.iter_mut().filter(|line| line.is_none()) { |
| 175 | *line = oldest.clone(); |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | let mut ranges: Vec<BlameRange> = Vec::new(); |
| 180 | for (index, hash) in owner.into_iter().enumerate() { |
| 181 | let hash = hash.unwrap_or_else(|| first.hash.clone()); |
| 182 | let line = index as u32 + 1; |
| 183 | match ranges.last_mut() { |
| 184 | Some(range) if range.commit == hash => range.end = line, |
| 185 | _ => ranges.push(BlameRange { start: line, end: line, commit: hash }), |
| 186 | } |
| 187 | } |
| 188 | let mut commits: Vec<Commit> = Vec::new(); |
| 189 | for range in &ranges { |
| 190 | if !commits.iter().any(|commit| commit.hash == range.commit) |
| 191 | && let Some(commit) = by_hash.get(range.commit.as_str()) { |
| 192 | commits.push((*commit).clone()); |
| 193 | } |
| 194 | } |
| 195 | Ok(Some(Blame { |
| 196 | head: first.hash.clone(), |
| 197 | ranges, |
| 198 | commits, |
| 199 | partial, |
| 200 | })) |
| 201 | } |
| 202 | |
| 203 | #[cfg(test)] |
| 204 | mod tests { |
| 205 | use super::*; |
| 206 | |
| 207 | fn lines(text: &str) -> Vec<String> { |
| 208 | text.lines().map(str::to_owned).collect() |
| 209 | } |
| 210 | |
| 211 | #[test] |
| 212 | fn unchanged_lines_are_carried_to_the_parent() { |
| 213 | let map = carried(&lines("a\nb\nc"), &lines("a\nx\nb\nc")); |
| 214 | assert_eq!(map, vec![Some(0), None, Some(1), Some(2)]); |
| 215 | } |
| 216 | |
| 217 | #[test] |
| 218 | fn a_changed_line_is_not_carried() { |
| 219 | let map = carried(&lines("a\nb"), &lines("a\nB")); |
| 220 | assert_eq!(map, vec![Some(0), None]); |
| 221 | } |
| 222 | } |