steid

@jamesgill /

feat: who last changed each line, as a thing the port can answer

Blame is the first read that is genuinely expensive — git walks a file's
history rather than answering from an index — so it arrives as its own port
method and its own use case rather than as an option on a browse.

The porcelain parser is pure and lives in the application layer, because all
the fiddly knowledge is in that format and none of it is about git the binary:
the commit header block is written only the first time a commit appears, a
line of code can look exactly like a header, and the tab prefix is the only
thing that tells them apart. It never fails — a record git spells unexpectedly
is skipped, so a future git version is a missing attribution rather than an
outage.

Lines are grouped into runs of one commit because that is how a blame is read,
and each run is tinted by its age relative to the file's own oldest and newest
commit. That arithmetic spans the whole file, so it belongs here rather than
in a component rendering one row.

The use case reads the blob before blaming: binary, too large and not-there
are then decided by exactly the rules the blob page uses, rather than by a
second set free to disagree with them. It costs one `cat-file`, and buys the
two views of a file never contradicting each other about what they can show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4XhTsWXrFyMeN1C12jqV9
JamesPatrickGill authored 15 hours agoparent64f6f0dBrowse files5727ed7c3ef4fb7c8f7c2072484a00d02fb7ea07

5 files changed+987 −4

src/application/blame.rs+731 −0View file
@@ -0,0 +1,731 @@
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+
13+use std::{
14+ collections::HashMap,
15+ time::{Duration, SystemTime, UNIX_EPOCH},
16+};
17+
18+use crate::domain::{
19+ Actor, ObjectId, OrgName, RefName, RepoName, RepoPath,
20+ repository::{MembershipRepository, OrgRepository, RepoRepository},
21+};
22+
23+use 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.
30+pub 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)]
38+pub 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)]
56+pub 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)]
70+pub struct Blame {
71+ pub groups: Vec<BlameGroup>,
72+}
73+
74+impl 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)]
91+pub 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)]
101+pub 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)]
118+pub 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.
163+struct 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)]
171+struct 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.
193+pub 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.
259+fn 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.
311+fn 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.
332+fn 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)]
340+mod 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"\
347+af126c7704b91896c347742dbf91c9abac20cb99 1 1 2
348+author Ada Lovelace
349+author-mail <ada@example.com>
350+author-time 1786485070
351+author-tz +0100
352+committer Ada Lovelace
353+committer-mail <ada@example.com>
354+committer-time 1786485070
355+committer-tz +0100
356+summary feat: the first components
357+boundary
358+filename components.toml
359+\t# Topcoat UI install state.
360+af126c7704b91896c347742dbf91c9abac20cb99 2 2
361+\tversion = 1
362+285f5fd29aabe8814c7b94bca3a89b3e176410e8 3 3 2
363+author Grace Hopper
364+author-mail <grace@example.com>
365+author-time 1786614911
366+author-tz +0100
367+committer Grace Hopper
368+committer-mail <grace@example.com>
369+committer-time 1786614911
370+committer-tz +0100
371+summary feat: create and view repositories
372+previous af126c7704b91896c347742dbf91c9abac20cb99 old-name.toml
373+filename old-name.toml
374+\t[theme]
375+285f5fd29aabe8814c7b94bca3a89b3e176410e8 4 4
376+\tname = \"neutral\"
377+af126c7704b91896c347742dbf91c9abac20cb99 5 5 1
378+filename 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"\
462+1111111111111111111111111111111111111111 1 1 1
463+author Ada Lovelace
464+author-time 1786485070
465+summary only
466+filename 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"\
512+1111111111111111111111111111111111111111 1 1 1
513+author Ada
514+author-time 1786485070
515+summary only
516+filename 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"\
533+1111111111111111111111111111111111111111 1 1 1
534+author Ada
535+author-time 1786485070
536+summary only
537+filename 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+}
src/application/mod.rs+2 −0View file
@@ -5,6 +5,7 @@
55
66 pub mod archive;
77 pub(crate) mod authz;
8+pub mod blame;
89 pub mod browse;
910 pub mod claim;
1011 pub mod commit;
@@ -23,6 +24,7 @@ pub mod summary;
2324 pub mod token;
2425
2526 pub use archive::{ArchiveTarget, archive_repo, open_archive};
27+pub use blame::{Blame, BlameCommit, BlameContent, BlameFile, BlameGroup, blame_file, parse_blame};
2628 pub use browse::{
2729 Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, MAX_RAW_BYTES, RawFile, RefList, RefPage,
2830 browse_repo, list_branches, list_refs, list_tags, read_raw_file, repo_log,
src/application/port.rs+24 −0View file
@@ -12,6 +12,8 @@ use crate::domain::{
1212 PasswordHash, RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
1313 };
1414
15+use super::blame::Blame;
16+
1517 /// Hashes and verifies passwords.
1618 ///
1719 /// A port rather than a direct Argon2 call so tests can substitute a fast stub —
@@ -516,6 +518,28 @@ pub trait GitQuery: Send + Sync {
516518 query: &str,
517519 limit: usize,
518520 ) -> impl Future<Output = Result<Vec<GrepHit>, GitQueryError>> + Send;
521+
522+ /// Which commit last touched each line of a file, grouped into runs.
523+ ///
524+ /// `Ok(None)` for an unknown revision, a path that is not there, and a path that is
525+ /// not a file — a directory has no lines to attribute.
526+ ///
527+ /// **The most expensive read in this port.** git walks history for one file rather
528+ /// than answering from an index, so the cost grows with how much of that history
529+ /// touched it; on a large file with a long history it is the read most likely to
530+ /// meet the adapter's timeout. Nothing calls it except the blame page, which is a
531+ /// page a visitor asks for by name.
532+ ///
533+ /// Says nothing about size. Whether a file is small enough to be worth blaming is
534+ /// the caller's cap, applied through [`read_blob`](Self::read_blob) so the blame
535+ /// page and the blob page cannot disagree about it.
536+ fn blame(
537+ &self,
538+ handle: &OrgName,
539+ name: &RepoName,
540+ rev: &RefName,
541+ path: &RepoPath,
542+ ) -> impl Future<Output = Result<Option<Blame>, GitQueryError>> + Send;
519543 }
520544
521545 /// A patch, plus the per-file counts that outlive truncating it.
src/infrastructure/git.rs+29 −4View file
@@ -20,10 +20,13 @@ use tokio::{
2020 };
2121
2222 use crate::{
23 application::port::{
24 ArchiveRequest, Blob, ByteStream, GitArchive, GitArchiveError, GitMethod, GitProtocolError,
25 GitProtocolServer, GitQuery, GitQueryError, GitRequest, GitResponse, GitStorage,
26 GitStorageError, RawDiff,
23+ application::{
24+ blame::Blame,
25+ port::{
26+ ArchiveRequest, Blob, ByteStream, GitArchive, GitArchiveError, GitMethod,
27+ GitProtocolError, GitProtocolServer, GitQuery, GitQueryError, GitRequest, GitResponse,
28+ GitStorage, GitStorageError, RawDiff,
29+ },
2730 },
2831 domain::{
2932 BranchRow, CommitDetail, CommitSummary, GitRef, GrepHit, ObjectId, OrgName, RefKind,
@@ -645,6 +648,10 @@ pub struct InMemoryGitQuery {
645648 diff: Option<RawDiff>,
646649 /// The merge base of any two commits, because a fake has no graph to walk.
647650 merge_base: Option<ObjectId>,
651+
652+ /// Keyed the same way trees and blobs are, because a blame only means anything at a
653+ /// revision either.
654+ blames: HashMap<String, Blame>,
648655 }
649656
650657 impl InMemoryGitQuery {
@@ -769,6 +776,14 @@ impl InMemoryGitQuery {
769776 self
770777 }
771778
779+ pub fn with_blame(mut self, rev: &str, path: &str, blame: Blame) -> Self {
780+ self.blames.insert(
781+ Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
782+ blame,
783+ );
784+ self
785+ }
786+
772787 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
773788 self.refs.push(GitRef {
774789 name: RefName::from_trusted(name),
@@ -936,6 +951,16 @@ impl GitQuery for InMemoryGitQuery {
936951 ) -> Result<Vec<CommitSummary>, GitQueryError> {
937952 Ok(self.commits.iter().take(limit).cloned().collect())
938953 }
954+
955+ async fn blame(
956+ &self,
957+ _handle: &OrgName,
958+ _name: &RepoName,
959+ rev: &RefName,
960+ path: &RepoPath,
961+ ) -> Result<Option<Blame>, GitQueryError> {
962+ Ok(self.blames.get(&Self::key(rev, path)).cloned())
963+ }
939964 }
940965
941966 #[cfg(test)]
src/infrastructure/git_query.rs+201 −0View file
@@ -39,6 +39,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
3939
4040 use crate::{
4141 application::{
42+ blame::{Blame, parse_blame},
4243 port::{Blob, GitQuery, GitQueryError, RawDiff},
4344 search::parse_grep_output,
4445 },
@@ -591,6 +592,47 @@ impl GitQuery for DiskGitQuery {
591592
592593 parse_log(&output.stdout)
593594 }
595+
596+ async fn blame(
597+ &self,
598+ handle: &OrgName,
599+ name: &RepoName,
600+ rev: &RefName,
601+ path: &RepoPath,
602+ ) -> Result<Option<Blame>, GitQueryError> {
603+ let repo = self.repo_path(handle, name);
604+
605+ // `git blame` is fatal on an unknown revision *and* on a path that is not in it,
606+ // so one `--batch-check` on `{rev}:{path}` settles both before the expensive
607+ // command runs — the module's rule that a non-zero exit is a real fault only
608+ // holds because of checks like this one.
609+ let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
610+ return Ok(None);
611+ };
612+
613+ // A directory has no lines. Blaming one is another fatal error.
614+ if info.kind != ObjectKind::Blob {
615+ return Ok(None);
616+ }
617+
618+ // The revision is named rather than the blob id: blame walks *history*, and a
619+ // blob knows nothing about which commits reached it. `--` separates the revision
620+ // from the path so neither can be read as the other, and `RefName` and
621+ // `RepoPath` have already refused a leading hyphen.
622+ let output = run(
623+ &repo,
624+ [
625+ OsStr::new("blame"),
626+ OsStr::new("--porcelain"),
627+ OsStr::new(rev.as_str()),
628+ OsStr::new("--"),
629+ OsStr::new(path.as_str()),
630+ ],
631+ )
632+ .await?;
633+
634+ Ok(Some(parse_blame(&output.stdout)))
635+ }
594636 }
595637
596638 /// What `cat-file --batch-check` said about one object.
@@ -3093,4 +3135,163 @@ mod tests {
30933135 assert!(numstat.is_empty());
30943136 assert!(patch.is_empty());
30953137 }
3138+
3139+ // --- blame ----------------------------------------------------------------------
3140+
3141+ /// A repository with one file rewritten across three commits, so a blame of it has
3142+ /// several runs rather than one.
3143+ ///
3144+ /// Its own fixture rather than another file in [`populated`], because the shape
3145+ /// blame needs — the same file touched repeatedly, at known times — is exactly what
3146+ /// the other queries do not want.
3147+ fn blamed() -> (TempDir, DiskGitQuery) {
3148+ let (dir, query) = empty();
3149+ let repo = query.repo_path(&handle(), &repo_name());
3150+ let work = dir.path().join("blame-work");
3151+
3152+ std::fs::create_dir_all(work.join("docs")).expect("create work tree");
3153+ git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
3154+
3155+ std::fs::write(work.join("notes.md"), b"one\ntwo\nthree\n").expect("write");
3156+ std::fs::write(work.join("docs/why.md"), b"because\n").expect("write");
3157+ std::fs::write(work.join("logo.png"), BINARY).expect("write");
3158+ git(&work, FIRST_COMMIT, &["add", "-A"]);
3159+ git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
3160+
3161+ std::fs::write(work.join("notes.md"), b"one\nTWO\nthree\n").expect("write");
3162+ git(&work, SECOND_COMMIT, &["add", "-A"]);
3163+ git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
3164+
3165+ std::fs::write(work.join("notes.md"), b"one\nTWO\nthree\nfour\n").expect("write");
3166+ git(&work, THIRD_COMMIT, &["add", "-A"]);
3167+ git(&work, THIRD_COMMIT, &["commit", "--quiet", "-m", "third"]);
3168+
3169+ git(
3170+ &work,
3171+ THIRD_COMMIT,
3172+ &[
3173+ "push",
3174+ "--quiet",
3175+ repo.to_str().expect("utf-8 fixture path"),
3176+ "main",
3177+ ],
3178+ );
3179+
3180+ (dir, query)
3181+ }
3182+
3183+ #[tokio::test]
3184+ async fn blame_splits_a_file_into_runs_by_the_commit_that_wrote_them() {
3185+ let (_dir, query) = blamed();
3186+
3187+ let blame = query
3188+ .blame(&handle(), &repo_name(), &rev("main"), &path("notes.md"))
3189+ .await
3190+ .expect("should read")
3191+ .expect("a blameable file");
3192+
3193+ // Lines 1 and 3 are the first commit's but are not adjacent, so four runs.
3194+ let shape: Vec<(usize, Vec<String>, String)> = blame
3195+ .groups
3196+ .iter()
3197+ .map(|group| {
3198+ (
3199+ group.start_line,
3200+ group.lines.clone(),
3201+ group.commit.summary.clone(),
3202+ )
3203+ })
3204+ .collect();
3205+
3206+ assert_eq!(
3207+ shape,
3208+ vec![
3209+ (1, vec!["one".to_owned()], "first".to_owned()),
3210+ (2, vec!["TWO".to_owned()], "second".to_owned()),
3211+ (3, vec!["three".to_owned()], "first".to_owned()),
3212+ (4, vec!["four".to_owned()], "third".to_owned()),
3213+ ]
3214+ );
3215+ assert_eq!(blame.line_count(), 4);
3216+ }
3217+
3218+ #[tokio::test]
3219+ async fn blame_carries_what_a_page_shows_about_each_commit() {
3220+ let (_dir, query) = blamed();
3221+
3222+ let blame = query
3223+ .blame(&handle(), &repo_name(), &rev("main"), &path("notes.md"))
3224+ .await
3225+ .expect("should read")
3226+ .expect("a blameable file");
3227+ let newest = &blame.groups[3].commit;
3228+
3229+ assert_eq!(newest.author_name, "Ada Lovelace");
3230+ assert_eq!(newest.authored_at, unix_time(THIRD_COMMIT));
3231+ assert_eq!(newest.filename, "notes.md");
3232+ // Full ids, so the page can link to a commit without an ambiguous abbreviation.
3233+ assert_eq!(newest.id.as_str().len(), 40);
3234+ // The oldest run is the faintest tint and the newest the strongest.
3235+ assert_eq!(blame.groups[0].age, 0);
3236+ assert_eq!(blame.groups[3].age, 4);
3237+ }
3238+
3239+ #[tokio::test]
3240+ async fn blame_can_be_asked_by_object_id_as_well_as_by_branch() {
3241+ // A blame page reached from a commit link names a sha in its URL.
3242+ let (_dir, query) = blamed();
3243+ let commit = query
3244+ .resolve(&handle(), &repo_name(), &rev("main"))
3245+ .await
3246+ .expect("should read")
3247+ .expect("main resolves");
3248+
3249+ let blame = query
3250+ .blame(
3251+ &handle(),
3252+ &repo_name(),
3253+ &rev(commit.as_str()),
3254+ &path("notes.md"),
3255+ )
3256+ .await
3257+ .expect("should read")
3258+ .expect("a blameable file");
3259+
3260+ assert_eq!(blame.groups.len(), 4);
3261+ }
3262+
3263+ #[tokio::test]
3264+ async fn there_is_nothing_to_blame_that_is_not_a_file() {
3265+ // Every one of these is a fatal error if handed straight to `git blame`, which
3266+ // is why the existence check runs first.
3267+ let (_dir, query) = blamed();
3268+
3269+ for (revision, target) in [
3270+ ("main", "docs"),
3271+ ("main", "nope.md"),
3272+ ("no-such-branch", "notes.md"),
3273+ ] {
3274+ assert_eq!(
3275+ query
3276+ .blame(&handle(), &repo_name(), &rev(revision), &path(target))
3277+ .await
3278+ .expect("should read"),
3279+ None,
3280+ "{revision}:{target} should not blame"
3281+ );
3282+ }
3283+ }
3284+
3285+ #[tokio::test]
3286+ async fn an_empty_repository_blames_nothing() {
3287+ let (_dir, query) = empty();
3288+
3289+ assert_eq!(
3290+ query
3291+ .blame(&handle(), &repo_name(), &rev("main"), &path("README.md"))
3292+ .await
3293+ .expect("should read"),
3294+ None
3295+ );
3296+ }
30963297 }