g1t/services/repos/src/blame.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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() { | |
| Polish: phones, copy boxes, the plan page, the landing page, a real glide | 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) { | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 { | |
| Polish: phones, copy boxes, the plan page, the landing page, a real glide | 190 | if !commits.iter().any(|commit| commit.hash == range.commit) |
| 191 | && let Some(commit) = by_hash.get(range.commit.as_str()) { | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | } |