steid

@jamesgill /

24.0 KBCode·Blame·Raw
5727ed7feat: who last changed each line, as a thing the port can answer20h
1//! Who last changed each line of a file.
2//!
3//! Two halves. [`parse_blame`] is a pure parser for `git blame --porcelain`, which is
4//! where all of the fiddly knowledge about that format lives and where it is tested.
5//! [`blame_file`] is the use case, and it does not re-implement authorization: it goes
6//! through [`view_repo`](super::repo::view_repo), so a repository invisible on its page
7//! is invisible here.
8//!
9//! Blame is displayed in *groups* — consecutive lines that arrived in the same commit —
10//! because a file's blame is mostly runs, and repeating the same commit on forty
11//! adjacent lines is noise rather than information.
12
13use std::{
14 collections::HashMap,
15 time::{Duration, SystemTime, UNIX_EPOCH},
16};
17
18use crate::domain::{
19 Actor, ObjectId, OrgName, RefName, RepoName, RepoPath,
20 repository::{MembershipRepository, OrgRepository, RepoRepository},
21};
22
23use super::{browse::MAX_BLOB_BYTES, error::Result, port::GitQuery, repo::view_repo};
24
25/// How many steps of age the tint has.
26///
27/// Five, because the tint is texture rather than a heat map: it should say "this part of
28/// the file is older than that part" at a glance and nothing more precise. A continuous
29/// gradient would invite reading a value off it that blame cannot support.
30pub const AGE_STEPS: u8 = 5;
31
32/// One commit, as blame describes it.
33///
34/// The author rather than the committer, because blame answers "who wrote this line".
35/// A rebase rewrites committer information and leaves authorship alone, which is the
36/// behaviour that makes the answer stable.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct BlameCommit {
39 pub id: ObjectId,
40 pub summary: String,
41 pub author_name: String,
42 pub authored_at: SystemTime,
43 /// Whether git stopped walking here — the commit is at the edge of the range it was
44 /// given, so the lines it is credited with may well be older than it is.
45 pub boundary: bool,
46 /// What the file was called in that commit.
47 ///
48 /// git names it once per commit in the porcelain stream, so it is carried on the
49 /// commit rather than on the run of lines. Differs from the path being browsed
50 /// exactly when the file was renamed or the lines moved.
51 pub filename: String,
52}
53
54/// A run of consecutive lines that arrived in the same commit.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct BlameGroup {
57 pub commit: BlameCommit,
58 /// The 1-based line number this run starts at.
59 pub start_line: usize,
60 pub lines: Vec<String>,
61 /// `0` for the file's oldest commit, `AGE_STEPS - 1` for its newest.
62 ///
63 /// Decided here rather than in the view because it is arithmetic over the whole
64 /// file, which a component rendering one row cannot see.
65 pub age: u8,
66}
67
68/// A file, line by line, grouped by the commit each line came from.
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct Blame {
71 pub groups: Vec<BlameGroup>,
72}
73
74impl Blame {
75 /// An empty file blames to nothing at all, which the page says rather than showing
76 /// an empty table.
77 pub fn is_empty(&self) -> bool {
78 self.groups.is_empty()
79 }
80
81 pub fn line_count(&self) -> usize {
82 self.groups.iter().map(|group| group.lines.len()).sum()
83 }
84}
85
86/// What blaming a path produced.
87///
88/// The same three outcomes the blob page has, decided the same way and from the same
89/// cap, so the two views of a file agree about what they can show.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum BlameContent {
92 Ready(Blame),
93 /// Not valid UTF-8, so there are no lines to attribute.
94 Binary,
95 /// Larger than [`MAX_BLOB_BYTES`].
96 TooLarge,
97}
98
99/// A blamed file, with the size the page reports whatever the outcome.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct BlameFile {
102 pub size: u64,
103 pub content: BlameContent,
104}
105
106/// Blames a file at a revision.
107///
108/// `Ok(None)` means the repository is invisible, absent, or has nothing blameable at
109/// that path — an unknown revision, a path that is not there, and a directory are all
110/// one answer, for the reason [`view_repo`](super::repo::view_repo) gives.
111///
112/// **Two `git` processes before blame itself.** The blob is read first, to decide
113/// binary, too-large and not-there exactly as the blob page decides them rather than
114/// with a second set of rules that could disagree. The alternative — deriving those from
115/// blame's own output — cannot tell a binary file from a text one, because the porcelain
116/// stream carries the bytes without saying what they are.
117#[allow(clippy::too_many_arguments)]
118pub async fn blame_file(
119 handle: &OrgName,
120 name: &RepoName,
121 rev: &RefName,
122 path: &RepoPath,
123 actor: &Actor,
124 orgs: &impl OrgRepository,
125 memberships: &impl MembershipRepository,
126 repos: &impl RepoRepository,
127 queries: &impl GitQuery,
128) -> Result<Option<BlameFile>> {
129 if view_repo(handle, name, actor, orgs, memberships, repos)
130 .await?
131 .is_none()
132 {
133 return Ok(None);
134 }
135
136 let Some(blob) = queries
137 .read_blob(handle, name, rev, path, MAX_BLOB_BYTES)
138 .await?
139 else {
140 return Ok(None);
141 };
142
143 let content = match &blob.content {
144 // The port reports the size and withholds the bytes past the cap, so this is
145 // "too large" rather than "unreadable".
146 None => BlameContent::TooLarge,
147 Some(bytes) if std::str::from_utf8(bytes).is_err() => BlameContent::Binary,
148 Some(_) => match queries.blame(handle, name, rev, path).await? {
149 Some(blame) => BlameContent::Ready(blame),
150 // The path resolved a moment ago, so this is a repository that changed
151 // underneath the request. A 404 is the honest answer to "blame what?".
152 None => return Ok(None),
153 },
154 };
155
156 Ok(Some(BlameFile {
157 size: blob.size,
158 content,
159 }))
160}
161
162/// What one line of the porcelain stream said, before lines are grouped.
163struct Attributed {
164 commit: ObjectId,
165 line: usize,
166 content: String,
167}
168
169/// The header block git writes once per commit.
170#[derive(Default, Clone)]
171struct CommitHeader {
172 summary: String,
173 author_name: String,
174 authored_at: i64,
175 boundary: bool,
176 filename: String,
177}
178
179/// Parses `git blame --porcelain` output into runs of lines.
180///
181/// The format is a header line — `<sha> <original line> <final line> [<run length>]` —
182/// then zero or more `<key> <value>` lines, then the line itself prefixed with a tab.
183/// The key block is written **in full only the first time a commit appears**, so commit
184/// details are accumulated by sha and looked up again for every later run.
185///
186/// Nothing here fails. A record git spells in a shape this does not recognise is skipped
187/// rather than turned into an error: blame is a view of a file that renders fine without
188/// one line's attribution, and refusing the whole page over an unexpected key would make
189/// a future git version an outage.
190///
191/// Content is decoded lossily for the same reason a tree listing is: a file that is
192/// almost text still blames usefully, and the alternative is a blank page.
193pub fn parse_blame(stdout: &[u8]) -> Blame {
194 let mut headers: HashMap<String, CommitHeader> = HashMap::new();
195 let mut attributed: Vec<Attributed> = Vec::new();
196 let mut pending: Option<(ObjectId, usize)> = None;
197
198 for record in stdout.split(|byte| *byte == b'\n') {
199 // The content line is the only one that starts with a tab, and it is what ends
200 // an entry. Checked first, because a line of code can look like anything.
201 if let Some((commit, line)) = pending.clone()
202 && record.first() == Some(&b'\t')
203 {
204 attributed.push(Attributed {
205 commit,
206 line,
207 content: String::from_utf8_lossy(&record[1..])
208 .trim_end_matches('\r')
209 .to_owned(),
210 });
211 pending = None;
212 continue;
213 }
214
215 let text = String::from_utf8_lossy(record);
216 let text = text.trim_end_matches('\r');
217
218 if text.is_empty() {
219 continue;
220 }
221
222 let Some((commit, _)) = pending.clone() else {
223 // A header line. Its first three fields are the sha and the line numbers;
224 // the fourth, when present, is the run length, which is re-derived from the
225 // grouping below rather than trusted.
226 let mut fields = text.split(' ');
227 let (Some(sha), Some(_original), Some(line)) =
228 (fields.next(), fields.next(), fields.next())
229 else {
230 continue;
231 };
232
233 let (Ok(sha), Ok(line)) = (ObjectId::new(sha), line.parse::<usize>()) else {
234 continue;
235 };
236
237 pending = Some((sha, line));
238 continue;
239 };
240
241 let (key, value) = text.split_once(' ').unwrap_or((text, ""));
242 let header = headers.entry(commit.as_str().to_owned()).or_default();
243
244 match key {
245 "author" => header.author_name = value.to_owned(),
246 "author-time" => header.authored_at = value.parse().unwrap_or_default(),
247 "summary" => header.summary = value.to_owned(),
248 // A valueless key, so it splits to nothing and is recognised by name alone.
249 "boundary" => header.boundary = true,
250 "filename" => header.filename = value.to_owned(),
251 _ => {}
252 }
253 }
254
255 group(attributed, &headers)
256}
257
258/// Collapses attributed lines into runs and tints them by age.
259fn group(attributed: Vec<Attributed>, headers: &HashMap<String, CommitHeader>) -> Blame {
260 let mut groups: Vec<BlameGroup> = Vec::new();
261 let mut times: Vec<i64> = Vec::new();
262
263 for line in attributed {
264 let continues = groups.last().is_some_and(|last| {
265 last.commit.id == line.commit && last.start_line + last.lines.len() == line.line
266 });
267
268 if continues {
269 groups
270 .last_mut()
271 .expect("a continued run has a last group")
272 .lines
273 .push(line.content);
274 continue;
275 }
276
277 let header = headers
278 .get(line.commit.as_str())
279 .cloned()
280 .unwrap_or_default();
281
282 times.push(header.authored_at);
283 groups.push(BlameGroup {
284 commit: BlameCommit {
285 id: line.commit,
286 summary: header.summary,
287 author_name: header.author_name,
288 authored_at: unix_time(header.authored_at),
289 boundary: header.boundary,
290 filename: header.filename,
291 },
292 start_line: line.line,
293 lines: vec![line.content],
294 age: 0,
295 });
296 }
297
298 tint(&mut groups, &times);
299
300 Blame { groups }
301}
302
303/// Assigns each run an age step between the file's oldest and newest commit.
304///
305/// Relative to *this file* rather than to wall-clock time: a file nobody has touched for
306/// two years still has a most-recently-changed part, and that is the thing worth seeing.
307///
308/// A file whose lines all come from one moment is uniformly as new as it gets, so it
309/// takes the top step rather than the bottom one — there is nothing older to contrast it
310/// with.
311fn tint(groups: &mut [BlameGroup], times: &[i64]) {
312 let (Some(oldest), Some(newest)) = (times.iter().min(), times.iter().max()) else {
313 return;
314 };
315
316 let span = newest - oldest;
317 let last = i64::from(AGE_STEPS - 1);
318
319 for (group, time) in groups.iter_mut().zip(times) {
320 group.age = if span == 0 {
321 AGE_STEPS - 1
322 } else {
323 // Integer arithmetic on seconds: the span of a file's history is far too
324 // small to overflow, and floats would only add rounding to argue about.
325 (((time - oldest) * i64::from(AGE_STEPS) / span).min(last)) as u8
326 };
327 }
328}
329
330/// A unix timestamp as a `SystemTime`, including the negative ones a rewritten or
331/// imported history can carry.
332fn unix_time(seconds: i64) -> SystemTime {
333 match u64::try_from(seconds) {
334 Ok(seconds) => UNIX_EPOCH + Duration::from_secs(seconds),
335 Err(_) => UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs()),
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 /// Captured from `git blame --porcelain` on this repository and then trimmed: two
344 /// commits, a boundary commit, and a run whose lines were moved in under a different
345 /// filename by a rename.
346 const PORCELAIN: &[u8] = b"\
347af126c7704b91896c347742dbf91c9abac20cb99 1 1 2
348author Ada Lovelace
349author-mail <ada@example.com>
350author-time 1786485070
351author-tz +0100
352committer Ada Lovelace
353committer-mail <ada@example.com>
354committer-time 1786485070
355committer-tz +0100
356summary feat: the first components
357boundary
358filename components.toml
359\t# Topcoat UI install state.
360af126c7704b91896c347742dbf91c9abac20cb99 2 2
361\tversion = 1
362285f5fd29aabe8814c7b94bca3a89b3e176410e8 3 3 2
363author Grace Hopper
364author-mail <grace@example.com>
365author-time 1786614911
366author-tz +0100
367committer Grace Hopper
368committer-mail <grace@example.com>
369committer-time 1786614911
370committer-tz +0100
371summary feat: create and view repositories
372previous af126c7704b91896c347742dbf91c9abac20cb99 old-name.toml
373filename old-name.toml
374\t[theme]
375285f5fd29aabe8814c7b94bca3a89b3e176410e8 4 4
376\tname = \"neutral\"
377af126c7704b91896c347742dbf91c9abac20cb99 5 5 1
378filename components.toml
379\tregistry = \"topcoat\"
380";
381
382 fn blame() -> Blame {
383 parse_blame(PORCELAIN)
384 }
385
386 #[test]
387 fn consecutive_lines_from_one_commit_become_one_run() {
388 let blame = blame();
389
390 assert_eq!(blame.groups.len(), 3, "{:#?}", blame.groups);
391 assert_eq!(blame.line_count(), 5);
392 assert_eq!(blame.groups[0].start_line, 1);
393 assert_eq!(
394 blame.groups[0].lines,
395 vec!["# Topcoat UI install state.", "version = 1"]
396 );
397 assert_eq!(blame.groups[1].start_line, 3);
398 assert_eq!(blame.groups[2].start_line, 5);
399 }
400
401 #[test]
402 fn a_commit_returning_later_is_a_second_run_rather_than_a_continuation() {
403 // Lines 1-2 and line 5 are the same commit but not adjacent, so blame must not
404 // merge them — merging would credit line 5's position to the wrong place.
405 let blame = blame();
406
407 assert_eq!(blame.groups[0].commit.id, blame.groups[2].commit.id);
408 assert_eq!(blame.groups[2].lines, vec!["registry = \"topcoat\""]);
409 }
410
411 #[test]
412 fn commit_details_are_remembered_for_later_runs() {
413 // The header block is written once. The third run's record carries only a sha,
414 // so everything it shows has to come from the first appearance.
415 let blame = blame();
416 let third = &blame.groups[2].commit;
417
418 assert_eq!(third.author_name, "Ada Lovelace");
419 assert_eq!(third.summary, "feat: the first components");
420 assert_eq!(third.authored_at, unix_time(1_786_485_070));
421 }
422
423 #[test]
424 fn a_boundary_commit_is_marked_as_one() {
425 let blame = blame();
426
427 assert!(blame.groups[0].commit.boundary);
428 assert!(!blame.groups[1].commit.boundary);
429 }
430
431 #[test]
432 fn a_run_moved_by_a_rename_carries_the_name_it_had() {
433 // What makes the rename visible on the page: the lines are in `components.toml`
434 // now, but that commit changed them in `old-name.toml`.
435 let blame = blame();
436
437 assert_eq!(blame.groups[1].commit.filename, "old-name.toml");
438 assert_eq!(blame.groups[0].commit.filename, "components.toml");
439 }
440
441 #[test]
442 fn the_oldest_and_newest_runs_sit_at_the_ends_of_the_tint() {
443 let blame = blame();
444
445 assert_eq!(blame.groups[0].age, 0, "the oldest commit is the faintest");
446 assert_eq!(
447 blame.groups[2].age, 0,
448 "and so is the same commit's later run"
449 );
450 assert_eq!(
451 blame.groups[1].age,
452 AGE_STEPS - 1,
453 "the newest commit is the strongest"
454 );
455 }
456
457 #[test]
458 fn a_file_written_in_one_commit_is_uniformly_new() {
459 // Nothing to contrast with, so the whole file takes the top step rather than
460 // rendering as uniformly ancient.
461 let single = b"\
4621111111111111111111111111111111111111111 1 1 1
463author Ada Lovelace
464author-time 1786485070
465summary only
466filename notes.md
467\thello
468" as &[u8];
469
470 let blame = parse_blame(single);
471
472 assert_eq!(blame.groups.len(), 1);
473 assert_eq!(blame.groups[0].age, AGE_STEPS - 1);
474 }
475
476 #[test]
477 fn every_step_of_the_tint_is_reachable() {
478 // Five evenly spaced commits should land on five distinct steps; a formula that
479 // is off by one collapses the ends instead.
480 let mut porcelain = Vec::new();
481
482 for index in 0..5u32 {
483 let sha = format!("{index}").repeat(40);
484 let line = index as usize + 1;
485 porcelain.extend_from_slice(
486 format!(
487 "{} {line} {line} 1\nauthor Ada\nauthor-time {}\nsummary s\nfilename f\n\tline\n",
488 &sha[..40],
489 1_700_000_000 + index * 1000
490 )
491 .as_bytes(),
492 );
493 }
494
495 let ages: Vec<u8> = parse_blame(&porcelain)
496 .groups
497 .iter()
498 .map(|group| group.age)
499 .collect();
500
501 assert_eq!(ages, vec![0, 1, 2, 3, 4]);
502 }
503
504 #[test]
505 fn an_empty_file_blames_to_nothing() {
506 assert!(parse_blame(b"").is_empty());
507 }
508
509 #[test]
510 fn a_line_that_is_not_utf8_still_blames() {
511 let mut porcelain = b"\
5121111111111111111111111111111111111111111 1 1 1
513author Ada
514author-time 1786485070
515summary only
516filename notes.md
517\t"
518 .to_vec();
519 porcelain.extend_from_slice(&[0xff, 0xfe]);
520 porcelain.push(b'\n');
521
522 let blame = parse_blame(&porcelain);
523
524 assert_eq!(blame.groups.len(), 1);
525 assert_eq!(blame.groups[0].lines[0], "\u{fffd}\u{fffd}");
526 }
527
528 #[test]
529 fn a_line_of_code_that_looks_like_a_header_is_still_content() {
530 // The tab is what separates the two, and a file full of shas would otherwise
531 // reparse itself into nonsense.
532 let porcelain = b"\
5331111111111111111111111111111111111111111 1 1 1
534author Ada
535author-time 1786485070
536summary only
537filename notes.md
538\t2222222222222222222222222222222222222222 9 9 9
539" as &[u8];
540
541 let blame = parse_blame(porcelain);
542
543 assert_eq!(blame.groups.len(), 1);
544 assert_eq!(
545 blame.groups[0].lines[0],
546 "2222222222222222222222222222222222222222 9 9 9"
547 );
548 }
549
550 // --- the use case ------------------------------------------------------------
551
552 use crate::{
553 domain::{
554 Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
555 },
556 infrastructure::{
557 git::InMemoryGitQuery,
558 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
559 },
560 };
561
562 struct Fixture {
563 orgs: InMemoryOrgRepo,
564 memberships: InMemoryMembershipRepo,
565 repos: InMemoryRepoRepo,
566 handle: OrgName,
567 owner: Actor,
568 stranger: Actor,
569 }
570
571 /// One organisation with an owner, and a `steid` repository of the given visibility.
572 async fn fixture(visibility: Visibility) -> Fixture {
573 let orgs = InMemoryOrgRepo::new();
574 let memberships = InMemoryMembershipRepo::new();
575 let repos = InMemoryRepoRepo::new();
576
577 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
578 orgs.save(&org).await.expect("save org");
579
580 let owner = UserId::generate();
581 memberships
582 .save(&Membership::new(
583 MembershipId::generate(),
584 org.id.clone(),
585 owner.clone(),
586 crate::domain::Role::Owner,
587 ))
588 .await
589 .expect("save membership");
590
591 repos
592 .save(
593 &Repository::new(
594 RepoId::generate(),
595 org.id.clone(),
596 "steid",
597 None,
598 visibility,
599 SystemTime::now(),
600 )
601 .expect("valid repository"),
602 )
603 .await
604 .expect("save repo");
605
606 Fixture {
607 orgs,
608 memberships,
609 repos,
610 handle: org.name,
611 owner: Actor::User(owner),
612 stranger: Actor::Anonymous,
613 }
614 }
615
616 impl Fixture {
617 async fn blame(
618 &self,
619 actor: &Actor,
620 path: &str,
621 queries: &InMemoryGitQuery,
622 ) -> Result<Option<BlameFile>> {
623 blame_file(
624 &self.handle,
625 &RepoName::new("steid").expect("valid repository name"),
626 &RefName::new("main").expect("valid revision"),
627 &RepoPath::new(path).expect("valid path"),
628 actor,
629 &self.orgs,
630 &self.memberships,
631 &self.repos,
632 queries,
633 )
634 .await
635 }
636 }
637
638 fn one_line_blame() -> Blame {
639 parse_blame(
640 b"1111111111111111111111111111111111111111 1 1 1\nauthor Ada\nauthor-time 1786485070\nsummary only\nfilename notes.md\n\thello\n",
641 )
642 }
643
644 #[tokio::test]
645 async fn a_text_file_blames() {
646 let f = fixture(Visibility::Public).await;
647 let queries = InMemoryGitQuery::new()
648 .with_blob("main", "notes.md", b"hello\n")
649 .with_blame("main", "notes.md", one_line_blame());
650
651 let file = f
652 .blame(&f.owner, "notes.md", &queries)
653 .await
654 .expect("should read")
655 .expect("found");
656
657 assert_eq!(file.size, 6);
658 assert_eq!(file.content, BlameContent::Ready(one_line_blame()));
659 }
660
661 #[tokio::test]
662 async fn a_binary_file_has_no_lines_to_attribute() {
663 // Decided from the bytes, exactly as the blob page decides it — and decided
664 // *before* the expensive query, so nothing walks history for a PNG.
665 let f = fixture(Visibility::Public).await;
666 let queries = InMemoryGitQuery::new().with_blob("main", "logo.png", vec![0x00, 0xff, 0xfe]);
667
668 let file = f
669 .blame(&f.owner, "logo.png", &queries)
670 .await
671 .expect("should read")
672 .expect("found");
673
674 assert_eq!(file.content, BlameContent::Binary);
675 }
676
677 #[tokio::test]
678 async fn a_file_past_the_page_cap_says_so_rather_than_blaming() {
679 let f = fixture(Visibility::Public).await;
680 let size = MAX_BLOB_BYTES as usize + 1;
681 let queries = InMemoryGitQuery::new().with_blob("main", "big.txt", vec![b'x'; size]);
682
683 let file = f
684 .blame(&f.owner, "big.txt", &queries)
685 .await
686 .expect("should read")
687 .expect("found");
688
689 assert_eq!(file.content, BlameContent::TooLarge);
690 assert_eq!(file.size, size as u64);
691 }
692
693 #[tokio::test]
694 async fn a_path_that_is_not_a_file_is_not_found() {
695 let f = fixture(Visibility::Public).await;
696 let queries = InMemoryGitQuery::new().with_tree("main", "src", Vec::new());
697
698 for path in ["src", "nope.md"] {
699 assert!(
700 f.blame(&f.owner, path, &queries)
701 .await
702 .expect("should read")
703 .is_none(),
704 "{path} should not blame"
705 );
706 }
707 }
708
709 #[tokio::test]
710 async fn a_private_repositorys_blame_is_invisible_to_a_stranger() {
711 // Blame names commits, authors and every line of the file. Same answer as the
712 // page, for the same reason.
713 let f = fixture(Visibility::Private).await;
714 let queries = InMemoryGitQuery::new()
715 .with_blob("main", "notes.md", b"hello\n")
716 .with_blame("main", "notes.md", one_line_blame());
717
718 assert!(
719 f.blame(&f.stranger, "notes.md", &queries)
720 .await
721 .expect("should read")
722 .is_none()
723 );
724 assert!(
725 f.blame(&f.owner, "notes.md", &queries)
726 .await
727 .expect("should read")
728 .is_some()
729 );
730 }
731}