Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
583 linesCodeBlameRaw
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//! Reading is what costs: each commit's file is found by reading the trees
10//! on the way to it, and each read is a round trip to the store or its cache.
11//! So the walk reads ahead: the trees of the next [`READ_AHEAD`] commits,
12//! level by level and each level at once, and then, at once, the texts of the
13//! versions it will compare. The walk stops when every line has its commit,
14//! after [`MAX_COMMITS`] commits, before more than the caller's reads of the
15//! store, or once the caller's time is up; stopped early, a line goes to the
16//! oldest commit the walk reached it in, and the answer is marked partial.
17//!
18//! A blame of a commit and path never changes, so `lib.rs` keeps the answer
19//! by repository, commit and path ([`cache_url`]).
20
21use std::collections::{HashMap, HashSet};
22use std::rc::Rc;
23
24use g1t_contracts::repos::{Blame, BlameRange, Commit, EntryKind, TreeEntry};
25use similar::{ChangeTag, TextDiff};
26use worker::Result;
27
28use crate::store::GitRepo;
29
30/// How far back the history is searched. Lines older than this are given to
31/// the oldest commit reached, and the answer is marked partial.
32pub const MAX_COMMITS: u32 = 400;
33/// Files larger than this are not blamed.
34const MAX_FILE_BYTES: usize = 512 * 1024;
35/// Commits whose trees and texts are read together, ahead of the walk.
36const READ_AHEAD: usize = 32;
37/// Reads of the store one blame makes at most. Each costs up to three
38/// subrequests (a Cache API look, the store, a Cache API put), so this keeps
39/// a call well inside the 10,000 a Worker invocation may make.
40pub const MAX_READS: u32 = 2_500;
41
42/// Where a blame is kept: by repository, the commit it was read at, and a
43/// hash of the path. `v1`: a change to how lines are credited moves it.
44pub fn cache_url(repo_id: &str, head: &str, path: &str) -> String {
45 format!("https://blame.g1t.internal/v1/{repo_id}/{head}/{}", g1t_secrets::sha256_hex(path))
46}
47
48/// How long a blame is kept. One that ran to its end (the first commit, or
49/// [`MAX_COMMITS`]) is the same every time, so for good; one cut short by
50/// time or reads could do better on a quieter store, so briefly.
51pub fn cache_control(blamed: &Blamed) -> &'static str {
52 if blamed.cut_short { "max-age=600" } else { "max-age=31536000, immutable" }
53}
54
55/// A blame, and whether time or reads cut it short.
56#[derive(Debug)]
57pub struct Blamed {
58 pub blame: Blame,
59 pub cut_short: bool,
60}
61
62/// A version of the file: its lines, each ended by a newline, as the diff
63/// compares them, and how many there are.
64struct Text {
65 joined: String,
66 lines: usize,
67}
68
69impl Text {
70 fn of(text: &str) -> Text {
71 let mut joined = String::with_capacity(text.len() + 1);
72 let mut lines = 0;
73 // Every line ends in a newline, so a last line matches the same line elsewhere.
74 for line in text.lines() {
75 joined.push_str(line);
76 joined.push('\n');
77 lines += 1;
78 }
79 Text { joined, lines }
80 }
81}
82
83/// Reads files out of commits, remembering trees and texts already read:
84/// most commits share most of their trees.
85struct Reader<'a, R: GitRepo> {
86 repo: &'a R,
87 trees: HashMap<String, Option<Vec<TreeEntry>>>,
88 texts: HashMap<String, Option<Rc<Text>>>,
89 /// Reads of the store so far, trees, texts and history.
90 reads: u32,
91}
92
93impl<'a, R: GitRepo> Reader<'a, R> {
94 /// Reads the trees not read yet, all at once.
95 async fn read_trees(&mut self, hashes: Vec<String>) -> Result<()> {
96 let wanted: Vec<String> = hashes
97 .into_iter()
98 .filter(|hash| !self.trees.contains_key(hash))
99 .collect::<HashSet<_>>()
100 .into_iter()
101 .collect();
102 self.reads += wanted.len() as u32;
103 let found = futures_util::future::join_all(wanted.iter().map(|hash| self.repo.read_tree(hash))).await;
104 for (hash, tree) in wanted.into_iter().zip(found) {
105 self.trees.insert(hash, tree?);
106 }
107 Ok(())
108 }
109
110 /// Reads, level by level and each level at once, the trees on the way
111 /// to `path` in each of `roots`.
112 async fn read_paths(&mut self, roots: Vec<String>, path: &str) -> Result<()> {
113 let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
114 let mut level = roots;
115 for (index, part) in parts.iter().enumerate() {
116 self.read_trees(level.clone()).await?;
117 if index + 1 == parts.len() {
118 break;
119 }
120 level = level
121 .iter()
122 .filter_map(|hash| {
123 let entries = self.trees.get(hash)?.as_ref()?;
124 entries.iter().find(|entry| entry.name == *part && entry.kind == EntryKind::Tree).map(|entry| entry.hash.clone())
125 })
126 .collect();
127 }
128 Ok(())
129 }
130
131 /// Reads the texts not read yet, all at once.
132 async fn read_texts(&mut self, blobs: Vec<String>) -> Result<()> {
133 let wanted: Vec<String> =
134 blobs.into_iter().filter(|blob| !self.texts.contains_key(blob)).collect::<HashSet<_>>().into_iter().collect();
135 self.reads += wanted.len() as u32;
136 let found = futures_util::future::join_all(wanted.iter().map(|blob| self.repo.read_blob(blob))).await;
137 for (blob, bytes) in wanted.into_iter().zip(found) {
138 let text = bytes?
139 .filter(|bytes| bytes.len() <= MAX_FILE_BYTES && !bytes.contains(&0))
140 .and_then(|bytes| String::from_utf8(bytes).ok())
141 .map(|text| Rc::new(Text::of(&text)));
142 self.texts.insert(blob, text);
143 }
144 Ok(())
145 }
146
147 /// The blob hash of `path` in the tree `root`, if it is a file there.
148 async fn blob_at(&mut self, root: &str, path: &str) -> Result<Option<String>> {
149 let mut tree = root.to_owned();
150 let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
151 for (index, part) in parts.iter().enumerate() {
152 if !self.trees.contains_key(&tree) {
153 self.read_trees(vec![tree.clone()]).await?;
154 }
155 let Some(entry) = self.trees[&tree].as_ref().and_then(|entries| entries.iter().find(|entry| entry.name == *part)) else {
156 return Ok(None);
157 };
158 let last = index == parts.len() - 1;
159 match (last, entry.kind == EntryKind::Tree) {
160 (true, false) => return Ok(Some(entry.hash.clone())),
161 (false, true) => tree = entry.hash.clone(),
162 _ => return Ok(None),
163 }
164 }
165 Ok(None)
166 }
167
168 /// A version's text, or `None` if it is binary or too large.
169 async fn text(&mut self, blob: &str) -> Result<Option<Rc<Text>>> {
170 if !self.texts.contains_key(blob) {
171 self.read_texts(vec![blob.to_owned()]).await?;
172 }
173 Ok(self.texts[blob].clone())
174 }
175
176 /// Reads ahead for `commits`: the trees to `path` in each, then the
177 /// texts of each version that differs from the next commit's (its
178 /// parent on the walk), which are the ones the walk compares.
179 async fn read_ahead(&mut self, commits: &[Commit], path: &str) -> Result<()> {
180 self.read_paths(commits.iter().map(|commit| commit.tree_hash.clone()).collect(), path).await?;
181 let mut blobs = Vec::with_capacity(commits.len());
182 for commit in commits {
183 blobs.push(self.blob_at(&commit.tree_hash, path).await?);
184 }
185 let mut wanted = Vec::new();
186 for pair in blobs.windows(2) {
187 if let [Some(child), Some(parent)] = pair
188 && child != parent
189 {
190 wanted.push(child.clone());
191 wanted.push(parent.clone());
192 }
193 }
194 self.read_texts(wanted).await
195 }
196}
197
198/// For each line of `new`, the index of the same, unchanged line in `old`.
199fn carried(old: &Text, new: &Text) -> Vec<Option<usize>> {
200 let diff = TextDiff::from_lines(&old.joined, &new.joined);
201 let mut map = vec![None; new.lines];
202 for change in diff.iter_all_changes() {
203 if change.tag() == ChangeTag::Equal
204 && let (Some(o), Some(n)) = (change.old_index(), change.new_index())
205 && let Some(slot) = map.get_mut(n)
206 {
207 *slot = Some(o);
208 }
209 }
210 map
211}
212
213/// Who last changed each line of `path` as of `head`. `None` if the file is
214/// missing or not text. The walk stops before more than `max_reads` reads of
215/// the store, or once `out_of_time` says so (asked between read-aheads,
216/// after the first).
217pub async fn blame<R: GitRepo>(
218 repo: &R,
219 head: &str,
220 path: &str,
221 out_of_time: &dyn Fn() -> bool,
222 max_reads: u32,
223) -> Result<Option<Blamed>> {
224 let history = repo.log(head, MAX_COMMITS).await?;
225 let Some(first) = history.first() else {
226 return Ok(None);
227 };
228 let mut reader = Reader {
229 repo,
230 trees: HashMap::new(),
231 texts: HashMap::new(),
232 reads: 1,
233 };
234 // The first read-ahead also finds the file at the head.
235 let first_window = &history[..history.len().min(READ_AHEAD + 1)];
236 reader.read_ahead(first_window, path).await?;
237 let mut read_ahead_to = first_window.len();
238 let Some(blob) = reader.blob_at(&first.tree_hash, path).await? else {
239 return Ok(None);
240 };
241 let Some(text) = reader.text(&blob).await? else {
242 return Ok(None);
243 };
244 let total = text.lines;
245 let by_hash: HashMap<&str, &Commit> = history.iter().map(|commit| (commit.hash.as_str(), commit)).collect();
246 let segments = path.split('/').filter(|part| !part.is_empty()).count();
247 // The most one read-ahead reads: every tree on the way, two texts each.
248 let window_reads = (READ_AHEAD * (segments + 2)) as u32;
249
250 // For each commit still to visit: (line in its version, line in the head's).
251 let mut pending: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
252 pending.insert(first.hash.clone(), (0..total).map(|line| (line, line)).collect());
253 let mut owner: Vec<Option<String>> = vec![None; total];
254 let mut partial = false;
255 let mut cut_short = false;
256
257 // The log is newest first, so a commit is reached after its children.
258 for (index, commit) in history.iter().enumerate() {
259 if pending.is_empty() {
260 break;
261 }
262 if index + 1 >= read_ahead_to && read_ahead_to < history.len() {
263 if out_of_time() || reader.reads + window_reads > max_reads {
264 cut_short = true;
265 break;
266 }
267 let window = &history[index..history.len().min(index + READ_AHEAD + 1)];
268 reader.read_ahead(window, path).await?;
269 read_ahead_to = index + window.len();
270 }
271 let Some(mut lines_here) = pending.remove(&commit.hash) else {
272 continue;
273 };
274 lines_here.sort_unstable();
275 lines_here.dedup_by_key(|(_, final_line)| *final_line);
276 let Some(blob) = reader.blob_at(&commit.tree_hash, path).await? else {
277 continue;
278 };
279 // Read only when a parent's version differs.
280 let mut text: Option<Rc<Text>> = None;
281 let mut unexplained = lines_here;
282 for parent_hash in &commit.parents {
283 if unexplained.is_empty() {
284 break;
285 }
286 let Some(parent) = by_hash.get(parent_hash.as_str()) else {
287 // Beyond the history read: these lines are at least this old.
288 partial = true;
289 continue;
290 };
291 let Some(parent_blob) = reader.blob_at(&parent.tree_hash, path).await? else {
292 continue;
293 };
294 let handed: Vec<(usize, usize)> = if parent_blob == blob {
295 std::mem::take(&mut unexplained)
296 } else {
297 if text.is_none() {
298 text = reader.text(&blob).await?;
299 }
300 let (Some(here), Some(parent_text)) = (&text, reader.text(&parent_blob).await?) else {
301 // A version that is not text: its lines are taken as
302 // this commit's.
303 continue;
304 };
305 let map = carried(&parent_text, here);
306 let (moved, kept): (Vec<_>, Vec<_>) = unexplained
307 .into_iter()
308 .partition(|(here, _)| map.get(*here).copied().flatten().is_some());
309 unexplained = kept;
310 moved
311 .into_iter()
312 .map(|(here, final_line)| (map[here].unwrap_or(here), final_line))
313 .collect()
314 };
315 if !handed.is_empty() {
316 pending.entry(parent_hash.clone()).or_default().extend(handed);
317 }
318 }
319 for (_, final_line) in unexplained {
320 owner[final_line] = Some(commit.hash.clone());
321 }
322 }
323 // Lines the walk did not finish with: each goes to the oldest commit it
324 // was followed to.
325 if !pending.is_empty() {
326 partial = true;
327 for (hash, lines) in pending {
328 for (_, final_line) in lines {
329 if owner[final_line].is_none() {
330 owner[final_line] = Some(hash.clone());
331 }
332 }
333 }
334 }
335
336 let mut ranges: Vec<BlameRange> = Vec::new();
337 for (index, hash) in owner.into_iter().enumerate() {
338 let hash = hash.unwrap_or_else(|| first.hash.clone());
339 let line = index as u32 + 1;
340 match ranges.last_mut() {
341 Some(range) if range.commit == hash => range.end = line,
342 _ => ranges.push(BlameRange { start: line, end: line, commit: hash }),
343 }
344 }
345 let mut commits: Vec<Commit> = Vec::new();
346 for range in &ranges {
347 if !commits.iter().any(|commit| commit.hash == range.commit)
348 && let Some(commit) = by_hash.get(range.commit.as_str())
349 {
350 commits.push((*commit).clone());
351 }
352 }
353 Ok(Some(Blamed {
354 blame: Blame {
355 head: first.hash.clone(),
356 ranges,
357 commits,
358 partial,
359 },
360 cut_short,
361 }))
362}
363
364#[cfg(test)]
365mod tests {
366 use std::cell::{Cell, RefCell};
367 use std::future::Future;
368 use std::pin::pin;
369 use std::task::{Context, Poll, Waker};
370
371 use g1t_contracts::repos::{Branch, GitAccess, Signature};
372
373 use super::*;
374 use crate::store::Scope;
375
376 fn run<F: Future>(future: F) -> F::Output {
377 match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
378 Poll::Ready(output) => output,
379 Poll::Pending => panic!("the fake store never waits"),
380 }
381 }
382
383 fn text(text: &str) -> Text {
384 Text::of(text)
385 }
386
387 #[test]
388 fn unchanged_lines_are_carried_to_the_parent() {
389 let map = carried(&text("a\nb\nc"), &text("a\nx\nb\nc"));
390 assert_eq!(map, vec![Some(0), None, Some(1), Some(2)]);
391 }
392
393 #[test]
394 fn a_changed_line_is_not_carried() {
395 let map = carried(&text("a\nb"), &text("a\nB"));
396 assert_eq!(map, vec![Some(0), None]);
397 }
398
399 #[test]
400 fn a_last_line_without_a_newline_matches_one_with() {
401 let map = carried(&text("a\nb\n"), &text("a\nb"));
402 assert_eq!(map, vec![Some(0), Some(1)]);
403 }
404
405 /// A repository whose history is `src/f.txt` growing a line per commit.
406 #[derive(Default)]
407 struct Fake {
408 trees: HashMap<String, Vec<TreeEntry>>,
409 blobs: HashMap<String, String>,
410 history: Vec<Commit>,
411 trees_read: RefCell<Vec<String>>,
412 blobs_read: RefCell<Vec<String>>,
413 }
414
415 impl GitRepo for Fake {
416 async fn access(&self, _scope: Scope) -> Result<GitAccess> {
417 unimplemented!()
418 }
419 async fn branches(&self) -> Result<Vec<Branch>> {
420 Ok(Vec::new())
421 }
422 async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> {
423 let start = if git_ref == "main" { Some(0) } else { self.history.iter().position(|commit| commit.hash == git_ref) };
424 Ok(start.map(|start| self.history.iter().skip(start).take(limit as usize).cloned().collect()).unwrap_or_default())
425 }
426 async fn parents(&self, _commit_hash: &str) -> Result<Option<Vec<String>>> {
427 Ok(None)
428 }
429 async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
430 self.trees_read.borrow_mut().push(tree_hash.to_owned());
431 Ok(self.trees.get(tree_hash).cloned())
432 }
433 async fn read_blob(&self, blob_hash: &str) -> Result<Option<Vec<u8>>> {
434 self.blobs_read.borrow_mut().push(blob_hash.to_owned());
435 Ok(self.blobs.get(blob_hash).map(|text| text.as_bytes().to_vec()))
436 }
437 async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
438 Ok(None)
439 }
440 async fn fork(&self, _target_key: &str, _default_branch_only: bool) -> Result<()> {
441 Ok(())
442 }
443 }
444
445 fn commit(hash: &str, tree: &str, parent: Option<&str>) -> Commit {
446 Commit {
447 hash: hash.into(),
448 tree_hash: tree.into(),
449 message: format!("commit {hash}"),
450 author: Signature { name: "a".into(), email: "a@example.com".into() },
451 parents: parent.map(|p| vec![p.to_owned()]).unwrap_or_default(),
452 authored_at: String::new(),
453 }
454 }
455
456 /// `commits` commits, newest first: commit n appends line n to
457 /// `src/f.txt`. Every tenth commit only touches `README.md` beside it,
458 /// so `src` stays the same tree there. The repository root changes in
459 /// every commit.
460 fn growing(commits: usize) -> Fake {
461 let mut fake = Fake::default();
462 let mut lines: Vec<String> = Vec::new();
463 for n in 0..commits {
464 let changes_file = n == 0 || n % 10 != 0;
465 if changes_file {
466 lines.push(format!("line {n}"));
467 }
468 let blob = format!("b{}", lines.len());
469 fake.blobs.insert(blob.clone(), lines.join("\n"));
470 let src = format!("s{}", lines.len());
471 fake.trees.insert(src.clone(), vec![TreeEntry { name: "f.txt".into(), hash: blob, kind: EntryKind::Blob }]);
472 let root = format!("r{n}");
473 fake.trees.insert(
474 root.clone(),
475 vec![
476 TreeEntry { name: "README.md".into(), hash: format!("readme{n}"), kind: EntryKind::Blob },
477 TreeEntry { name: "src".into(), hash: src, kind: EntryKind::Tree },
478 ],
479 );
480 let parent = (n > 0).then(|| format!("c{}", n - 1));
481 fake.history.insert(0, commit(&format!("c{n}"), &root, parent.as_deref()));
482 }
483 fake
484 }
485
486 fn owners(blame: &Blame) -> Vec<(u32, u32, String)> {
487 blame.ranges.iter().map(|range| (range.start, range.end, range.commit.clone())).collect()
488 }
489
490 #[test]
491 fn each_line_goes_to_the_commit_that_wrote_it() {
492 let fake = growing(5);
493 let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, MAX_READS)).unwrap().unwrap();
494 assert!(!blamed.cut_short && !blamed.blame.partial);
495 assert_eq!(blamed.blame.head, "c4");
496 assert_eq!(
497 owners(&blamed.blame),
498 (0..5).map(|n| (n + 1, n + 1, format!("c{n}"))).collect::<Vec<_>>()
499 );
500 assert_eq!(blamed.blame.commits.len(), 5);
501 }
502
503 #[test]
504 fn a_missing_or_binary_file_has_no_blame() {
505 let mut fake = growing(3);
506 assert!(run(blame(&fake, "main", "src/nope.txt", &|| false, MAX_READS)).unwrap().is_none());
507 assert!(run(blame(&fake, "unknown", "src/f.txt", &|| false, MAX_READS)).unwrap().is_none());
508 fake.blobs.insert("b3".into(), "a\0b".into());
509 assert!(run(blame(&fake, "main", "src/f.txt", &|| false, MAX_READS)).unwrap().is_none());
510 }
511
512 #[test]
513 fn each_tree_and_text_is_read_once() {
514 let fake = growing(60);
515 let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, MAX_READS)).unwrap().unwrap();
516 assert!(!blamed.cut_short);
517 let trees = fake.trees_read.borrow();
518 assert_eq!(trees.len(), trees.iter().collect::<HashSet<_>>().len(), "no tree is read twice");
519 let blobs = fake.blobs_read.borrow();
520 assert_eq!(blobs.len(), blobs.iter().collect::<HashSet<_>>().len(), "no text is read twice");
521 // Each root, and each distinct `src`, once.
522 assert_eq!(trees.len(), 60 + 55);
523 }
524
525 #[test]
526 fn the_walk_stops_once_every_line_has_its_commit() {
527 // The newest 30 commits rewrite the whole file, so nothing older is needed.
528 let mut fake = growing(200);
529 fake.blobs.insert("b181".into(), "only line".into());
530 let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, MAX_READS)).unwrap().unwrap();
531 assert_eq!(owners(&blamed.blame), vec![(1, 1, "c199".to_owned())]);
532 assert!(fake.trees_read.borrow().len() < 2 * (READ_AHEAD + 1) * 2, "read only the first read-ahead or two");
533 }
534
535 #[test]
536 fn out_of_time_stops_the_walk_and_credits_the_oldest_commit_reached() {
537 let fake = growing(100);
538 let asked = Cell::new(0);
539 let out_of_time = || {
540 asked.set(asked.get() + 1);
541 true
542 };
543 let blamed = run(blame(&fake, "main", "src/f.txt", &out_of_time, MAX_READS)).unwrap().unwrap();
544 assert!(blamed.cut_short && blamed.blame.partial);
545 assert_eq!(asked.get(), 1, "asked once, after the first read-ahead");
546 // The newest lines still have their own commits; the rest stop at
547 // the last commit of the first read-ahead.
548 let ranges = owners(&blamed.blame);
549 assert_eq!(ranges.last().unwrap().2, "c99");
550 let reached = format!("c{}", 99 - READ_AHEAD);
551 assert_eq!(ranges[0].2, reached);
552 assert_eq!(cache_control(&blamed), "max-age=600");
553 }
554
555 #[test]
556 fn reads_are_bounded() {
557 let fake = growing(300);
558 let max = 150;
559 let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, max)).unwrap().unwrap();
560 assert!(blamed.cut_short && blamed.blame.partial);
561 let reads = fake.trees_read.borrow().len() + fake.blobs_read.borrow().len() + 1;
562 assert!(reads <= max as usize, "{reads} reads, at most {max}");
563 }
564
565 #[test]
566 fn a_history_longer_than_is_read_is_partial_but_kept_for_good() {
567 let fake = growing(MAX_COMMITS as usize + 20);
568 let blamed = run(blame(&fake, "main", "src/f.txt", &|| false, 100_000)).unwrap().unwrap();
569 assert!(blamed.blame.partial && !blamed.cut_short);
570 assert_eq!(cache_control(&blamed), "max-age=31536000, immutable");
571 }
572
573 #[test]
574 fn a_blame_is_kept_by_repository_commit_and_path() {
575 let url = cache_url("repo_1", "abc", "src/f.txt");
576 assert!(url.starts_with("https://blame.g1t.internal/v1/repo_1/abc/"));
577 assert_ne!(url, cache_url("repo_1", "abc", "src/g.txt"));
578 assert_ne!(url, cache_url("repo_1", "abd", "src/f.txt"));
579 assert_ne!(url, cache_url("repo_2", "abc", "src/f.txt"));
580 // A path never reaches the URL as it is.
581 assert!(!cache_url("r", "h", "a/../b?x#y").contains(".."));
582 }
583}