steid

@jamesgill /

feat: a commit is a page, and two revisions can be compared

Nothing linked to a commit by its sha because there was nowhere for a sha to
go. Now there is: `/commits/{sha}` shows what one commit changed, and
`/compare/{base}...{head}` shows what one revision adds to another.

Four things worth knowing, because they were decisions rather than mechanics:

- **The counts and the patch come from one `git` process.** git writes a
  `--numstat` block *before* the `-p` patch, so asking for both in the same run
  means the per-file counts survive the byte cap a very large diff runs into —
  a diff too big to draw still lists every changed file with real numbers. A
  second process would have been paid on every commit page to buy something
  only the rare oversized one needs.

- **`run_capped` reads through a pipe and kills the process.** `run` collects
  everything git writes, which is right for a tree listing and wrong for a
  patch: one commit can carry hundreds of megabytes of diff, and a page must
  not be able to pull that into memory.

- **`merge-base` is the one command allowed a non-zero exit.** This module's
  rule is that a non-zero exit is always a fault, because git's not-found codes
  collide with its error codes. `merge-base` is the exception git actually
  documents: 1 means "no common ancestor", 128 means broken. Two unrelated
  histories in one repository is a state a compare page must be able to state.

- **Compare is three-dot.** The diff is merge-base-to-head, not base-to-head,
  because a two-dot diff also undoes everything the base gained since the
  branch left it — a wall of deletions nobody made. It is also exactly what a
  pull request will need.

The patch parser tracks hunk line counts rather than matching prefixes anywhere,
because a patch *of a patch* contains lines reading `diff --git …` as content.

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

13 files changed+3186 −26

src/application/commit.rs+493 −0View file
@@ -0,0 +1,493 @@
1+//! Reading one commit, and comparing two revisions.
2+//!
3+//! Two use cases that share a renderer and a shape: both end in a [`Diff`], and both
4+//! reach it through the same authorization every other read does — see
5+//! [`view_repo`](super::repo::view_repo). A repository invisible on its page has no
6+//! commits and nothing to compare, by construction.
7+//!
8+//! # Why compare is three-dot
9+//!
10+//! `compare_revisions` diffs the **merge base** of the two revisions against the head,
11+//! not the base against the head. That is what `git diff base...head` means and it is
12+//! the only definition that answers the question anybody is actually asking: *what does
13+//! this branch add?* A two-dot diff also undoes everything the base gained since the
14+//! branch left it, which shows up as a wall of deletions nobody made. It is also
15+//! precisely what a pull request will need, which is why it is settled here rather than
16+//! at the point a pull request exists.
17+
18+use crate::domain::{
19+ Actor, CommitDetail, CommitSummary, ObjectId, OrgName, RefName, RepoName,
20+ repository::{MembershipRepository, OrgRepository, RepoRepository},
21+};
22+
23+use super::{
24+ browse::MAX_RAW_BYTES,
25+ diff::{Diff, parse_diff},
26+ error::Result,
27+ port::GitQuery,
28+ repo::view_repo,
29+};
30+
31+/// How many commits a comparison lists.
32+///
33+/// The same order of magnitude as the log's limit, and for the same reason: nobody
34+/// reads past it, and a branch that is a thousand commits ahead needs a different page,
35+/// not a longer one.
36+pub const COMPARE_LOG_LIMIT: usize = 100;
37+
38+/// One commit and what it changed.
39+#[derive(Debug, Clone, PartialEq, Eq)]
40+pub struct CommitPage {
41+ pub commit: CommitDetail,
42+ pub diff: Diff,
43+}
44+
45+/// Reads a commit and its diff.
46+///
47+/// The diff is against the **first parent**, which is what a commit page means by "what
48+/// this commit changed" — for a merge, the other parents' changes belong to the commits
49+/// that made them, and diffing against all of them would show a merge as either empty
50+/// or as the whole of the branch it merged. A root commit has no parent and is diffed
51+/// against nothing, so the first commit in a history shows everything it introduced
52+/// rather than an empty page.
53+///
54+/// `Ok(None)` on the usual terms: invisible, absent, or a revision that names no commit.
55+///
56+/// **Three `git` processes** — two to resolve and read the commit, one for the diff.
57+/// The diff takes the id the commit already resolved to, so nothing is resolved twice.
58+#[allow(clippy::too_many_arguments)]
59+pub async fn view_commit(
60+ handle: &OrgName,
61+ name: &RepoName,
62+ rev: &RefName,
63+ actor: &Actor,
64+ orgs: &impl OrgRepository,
65+ memberships: &impl MembershipRepository,
66+ repos: &impl RepoRepository,
67+ queries: &impl GitQuery,
68+) -> Result<Option<CommitPage>> {
69+ if view_repo(handle, name, actor, orgs, memberships, repos)
70+ .await?
71+ .is_none()
72+ {
73+ return Ok(None);
74+ }
75+
76+ let Some(commit) = queries.commit(handle, name, rev).await? else {
77+ return Ok(None);
78+ };
79+
80+ let diff = diff_between(handle, name, commit.parents.first(), &commit.id, queries).await?;
81+
82+ Ok(Some(CommitPage { commit, diff }))
83+}
84+
85+/// What comparing two revisions produced.
86+///
87+/// A parsed answer rather than an error for each way it can go nowhere: an unknown ref
88+/// re-renders the form, identical refs say so, and unrelated histories are a real state
89+/// of a real repository. None of the three is a fault, and turning any of them into one
90+/// would put a 500 in front of a typo.
91+#[derive(Debug, Clone, PartialEq, Eq)]
92+pub enum Compared {
93+ /// One of the two revisions names nothing in this repository.
94+ UnknownRef {
95+ rev: RefName,
96+ },
97+ /// Both revisions resolve to the same commit.
98+ Identical,
99+ /// The two commits share no ancestor, so there is nothing a diff could be relative
100+ /// to. Happens when two histories are pushed into one repository.
101+ Unrelated,
102+ Ready(Box<Comparison>),
103+}
104+
105+/// Two revisions, and what separates them.
106+#[derive(Debug, Clone, PartialEq, Eq)]
107+pub struct Comparison {
108+ pub base: RefName,
109+ pub head: RefName,
110+ pub base_id: ObjectId,
111+ pub head_id: ObjectId,
112+ pub merge_base: ObjectId,
113+ /// The commits on `head` that are not on `base`, newest first, capped at
114+ /// [`COMPARE_LOG_LIMIT`].
115+ pub commits: Vec<CommitSummary>,
116+ /// How many there are in total, so a capped list can say what it is not showing.
117+ pub total_commits: usize,
118+ pub diff: Diff,
119+}
120+
121+impl Comparison {
122+ /// Whether `head` is already contained in `base` — nothing to bring across.
123+ ///
124+ /// The merge base being the head itself is exactly that: every commit on `head` is
125+ /// reachable from `base`. The page offers the comparison the other way round rather
126+ /// than showing an empty one, because that is almost always what was meant.
127+ pub fn head_is_behind(&self) -> bool {
128+ self.merge_base == self.head_id
129+ }
130+
131+ /// Whether the list of commits was cut short by [`COMPARE_LOG_LIMIT`].
132+ pub fn truncated(&self) -> bool {
133+ self.total_commits > self.commits.len()
134+ }
135+}
136+
137+/// Compares two revisions, three-dot.
138+///
139+/// `Ok(None)` means the repository is invisible or absent — the same answer as
140+/// everywhere else. Everything that is wrong with the *revisions* comes back as a
141+/// [`Compared`] variant instead, because the page's answer to those is a rendered
142+/// explanation and not a 404.
143+///
144+/// **Five `git` processes** at most: two to resolve, one for the merge base, then the
145+/// commit list and the diff. The two resolutions run concurrently, as do the last two,
146+/// so the page waits on three round trips rather than five.
147+#[allow(clippy::too_many_arguments)]
148+pub async fn compare_revisions(
149+ handle: &OrgName,
150+ name: &RepoName,
151+ base: &RefName,
152+ head: &RefName,
153+ actor: &Actor,
154+ orgs: &impl OrgRepository,
155+ memberships: &impl MembershipRepository,
156+ repos: &impl RepoRepository,
157+ queries: &impl GitQuery,
158+) -> Result<Option<Compared>> {
159+ if view_repo(handle, name, actor, orgs, memberships, repos)
160+ .await?
161+ .is_none()
162+ {
163+ return Ok(None);
164+ }
165+
166+ let (base_id, head_id) = tokio::try_join!(
167+ queries.resolve(handle, name, base),
168+ queries.resolve(handle, name, head),
169+ )?;
170+
171+ // Reported one at a time, and the base first, so the form can point at the field
172+ // that is wrong rather than at both.
173+ let Some(base_id) = base_id else {
174+ return Ok(Some(Compared::UnknownRef { rev: base.clone() }));
175+ };
176+ let Some(head_id) = head_id else {
177+ return Ok(Some(Compared::UnknownRef { rev: head.clone() }));
178+ };
179+
180+ if base_id == head_id {
181+ return Ok(Some(Compared::Identical));
182+ }
183+
184+ let Some(merge_base) = queries.merge_base(handle, name, &base_id, &head_id).await? else {
185+ return Ok(Some(Compared::Unrelated));
186+ };
187+
188+ // One past the cap, so the page can say the list is cut short without a second
189+ // process spent counting.
190+ let (commits, diff) = tokio::try_join!(
191+ queries.log_between(
192+ handle,
193+ name,
194+ Some(&merge_base),
195+ &head_id,
196+ COMPARE_LOG_LIMIT + 1,
197+ ),
198+ diff_between(handle, name, Some(&merge_base), &head_id, queries),
199+ )?;
200+
201+ let total_commits = commits.len();
202+ let mut commits = commits;
203+ commits.truncate(COMPARE_LOG_LIMIT);
204+
205+ Ok(Some(Compared::Ready(Box::new(Comparison {
206+ base: base.clone(),
207+ head: head.clone(),
208+ base_id,
209+ head_id,
210+ merge_base,
211+ commits,
212+ total_commits,
213+ diff,
214+ }))))
215+}
216+
217+/// Reads a patch and parses it.
218+///
219+/// The byte cap is [`MAX_RAW_BYTES`] rather than a new number: it is already the answer
220+/// to "how much of a repository may one request hold in memory", and a patch is
221+/// governed by exactly that question. A diff over it is reported as truncated, with the
222+/// per-file counts intact — see [`Diff`].
223+async fn diff_between(
224+ handle: &OrgName,
225+ name: &RepoName,
226+ base: Option<&ObjectId>,
227+ head: &ObjectId,
228+ queries: &impl GitQuery,
229+) -> std::result::Result<Diff, super::port::GitQueryError> {
230+ let raw = queries
231+ .diff(handle, name, base, head, MAX_RAW_BYTES)
232+ .await?;
233+
234+ Ok(parse_diff(&raw.numstat, &raw.patch, raw.truncated))
235+}
236+
237+#[cfg(test)]
238+mod tests {
239+ use std::time::SystemTime;
240+
241+ use super::*;
242+ use crate::{
243+ domain::{
244+ Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
245+ },
246+ infrastructure::{
247+ git::InMemoryGitQuery,
248+ repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
249+ },
250+ };
251+
252+ struct Fixture {
253+ orgs: InMemoryOrgRepo,
254+ memberships: InMemoryMembershipRepo,
255+ repos: InMemoryRepoRepo,
256+ handle: OrgName,
257+ owner: Actor,
258+ stranger: Actor,
259+ }
260+
261+ async fn fixture(visibility: Visibility) -> Fixture {
262+ let orgs = InMemoryOrgRepo::new();
263+ let memberships = InMemoryMembershipRepo::new();
264+ let repos = InMemoryRepoRepo::new();
265+
266+ let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
267+ orgs.save(&org).await.expect("save org");
268+
269+ let owner = UserId::generate();
270+ memberships
271+ .save(&Membership::new(
272+ MembershipId::generate(),
273+ org.id.clone(),
274+ owner.clone(),
275+ crate::domain::Role::Owner,
276+ ))
277+ .await
278+ .expect("save membership");
279+
280+ repos
281+ .save(
282+ &Repository::new(
283+ RepoId::generate(),
284+ org.id.clone(),
285+ "steid",
286+ None,
287+ visibility,
288+ SystemTime::now(),
289+ )
290+ .expect("valid repository"),
291+ )
292+ .await
293+ .expect("save repo");
294+
295+ Fixture {
296+ orgs,
297+ memberships,
298+ repos,
299+ handle: org.name,
300+ owner: Actor::User(owner),
301+ stranger: Actor::Anonymous,
302+ }
303+ }
304+
305+ fn repo_name() -> RepoName {
306+ RepoName::new("steid").expect("valid repository name")
307+ }
308+
309+ fn rev(value: &str) -> RefName {
310+ RefName::new(value).expect("valid revision")
311+ }
312+
313+ fn detail() -> CommitDetail {
314+ CommitDetail {
315+ id: ObjectId::from_trusted("a".repeat(40)),
316+ tree: ObjectId::from_trusted("b".repeat(40)),
317+ parents: vec![ObjectId::from_trusted("c".repeat(40))],
318+ summary: "feat: a thing".to_owned(),
319+ body: "why it was done".to_owned(),
320+ author_name: "Ada Lovelace".to_owned(),
321+ author_email: "ada@example.com".to_owned(),
322+ authored_at: SystemTime::UNIX_EPOCH,
323+ committer_name: "Ada Lovelace".to_owned(),
324+ committer_email: "ada@example.com".to_owned(),
325+ committed_at: SystemTime::UNIX_EPOCH,
326+ }
327+ }
328+
329+ const PATCH: &str = "\
330+diff --git a/a.txt b/a.txt
331+--- a/a.txt
332++++ b/a.txt
333+@@ -1,1 +1,1 @@
334+-old
335++new
336+";
337+
338+ impl Fixture {
339+ async fn commit(
340+ &self,
341+ actor: &Actor,
342+ queries: &InMemoryGitQuery,
343+ ) -> Result<Option<CommitPage>> {
344+ view_commit(
345+ &self.handle,
346+ &repo_name(),
347+ &rev("main"),
348+ actor,
349+ &self.orgs,
350+ &self.memberships,
351+ &self.repos,
352+ queries,
353+ )
354+ .await
355+ }
356+
357+ async fn compare(
358+ &self,
359+ actor: &Actor,
360+ queries: &InMemoryGitQuery,
361+ ) -> Result<Option<Compared>> {
362+ compare_revisions(
363+ &self.handle,
364+ &repo_name(),
365+ &rev("main"),
366+ &rev("next"),
367+ actor,
368+ &self.orgs,
369+ &self.memberships,
370+ &self.repos,
371+ queries,
372+ )
373+ .await
374+ }
375+ }
376+
377+ // --- view_commit --------------------------------------------------------------
378+
379+ #[tokio::test]
380+ async fn a_commit_comes_back_with_its_parsed_diff() {
381+ let f = fixture(Visibility::Public).await;
382+ let queries = InMemoryGitQuery::new()
383+ .with_commit(detail())
384+ .with_diff(PATCH, "1\t1\ta.txt\n");
385+
386+ let page = f
387+ .commit(&f.owner, &queries)
388+ .await
389+ .expect("should read")
390+ .expect("found");
391+
392+ assert_eq!(page.commit.summary, "feat: a thing");
393+ assert_eq!(page.diff.files_changed(), 1);
394+ assert_eq!((page.diff.added(), page.diff.removed()), (1, 1));
395+ assert_eq!(page.diff.files[0].rows.len(), 3);
396+ }
397+
398+ #[tokio::test]
399+ async fn a_revision_that_names_no_commit_is_not_found() {
400+ let f = fixture(Visibility::Public).await;
401+
402+ assert!(
403+ f.commit(&f.owner, &InMemoryGitQuery::new())
404+ .await
405+ .expect("should read")
406+ .is_none()
407+ );
408+ }
409+
410+ #[tokio::test]
411+ async fn a_private_repositorys_commits_are_invisible_to_a_stranger() {
412+ // A commit message and a diff are content. Same answer as every other read.
413+ let f = fixture(Visibility::Private).await;
414+ let queries = InMemoryGitQuery::new()
415+ .with_commit(detail())
416+ .with_diff(PATCH, "1\t1\ta.txt\n");
417+
418+ assert!(
419+ f.commit(&f.stranger, &queries)
420+ .await
421+ .expect("should read")
422+ .is_none()
423+ );
424+ assert!(
425+ f.commit(&f.owner, &queries)
426+ .await
427+ .expect("should read")
428+ .is_some()
429+ );
430+ }
431+
432+ // --- compare_revisions --------------------------------------------------------
433+
434+ #[tokio::test]
435+ async fn identical_revisions_have_nothing_to_compare() {
436+ // The fake resolves every revision to the same id, which is exactly this case.
437+ let f = fixture(Visibility::Public).await;
438+
439+ let compared = f
440+ .compare(&f.owner, &InMemoryGitQuery::new())
441+ .await
442+ .expect("should read")
443+ .expect("visible");
444+
445+ assert_eq!(compared, Compared::Identical);
446+ }
447+
448+ #[tokio::test]
449+ async fn an_unknown_revision_is_reported_rather_than_being_a_404() {
450+ // The page re-renders its form with the reason; a 404 would discard what was
451+ // typed and say nothing about which of the two was wrong.
452+ let f = fixture(Visibility::Public).await;
453+
454+ let compared = f
455+ .compare(&f.owner, &InMemoryGitQuery::empty())
456+ .await
457+ .expect("should read")
458+ .expect("visible");
459+
460+ assert_eq!(compared, Compared::UnknownRef { rev: rev("main") });
461+ }
462+
463+ #[tokio::test]
464+ async fn a_private_repository_cannot_be_compared_by_a_stranger() {
465+ let f = fixture(Visibility::Private).await;
466+
467+ assert!(
468+ f.compare(&f.stranger, &InMemoryGitQuery::new())
469+ .await
470+ .expect("should read")
471+ .is_none()
472+ );
473+ }
474+
475+ #[test]
476+ fn a_head_contained_in_its_base_is_behind_it() {
477+ let id = |value: &str| ObjectId::from_trusted(value.repeat(40));
478+
479+ let comparison = Comparison {
480+ base: rev("main"),
481+ head: rev("next"),
482+ base_id: id("a"),
483+ head_id: id("b"),
484+ merge_base: id("b"),
485+ commits: Vec::new(),
486+ total_commits: 0,
487+ diff: Diff::default(),
488+ };
489+
490+ assert!(comparison.head_is_behind());
491+ assert!(!comparison.truncated());
492+ }
493+}
src/application/diff.rs+894 −0View file
@@ -0,0 +1,894 @@
1+//! Reading git's unified diff format.
2+//!
3+//! Pure: bytes in, a structure out, no ports and no I/O. That is deliberate — a diff
4+//! parser is the kind of code that is wrong in ways only a captured sample reveals, and
5+//! keeping it a plain function means every one of those samples is a unit test rather
6+//! than a fixture repository.
7+//!
8+//! # What the patch is trusted for, and what it is not
9+//!
10+//! The **counts** come from git's `--numstat`, never from the patch: numstat is written
11+//! before the patch and therefore survives the byte cap that a very large diff runs
12+//! into, so a commit whose patch cannot be shown still has a complete file list with
13+//! real numbers beside it. The **lines** come from the patch, because that is the only
14+//! place they exist.
15+//!
16+//! # Paths
17+//!
18+//! git writes a path several times per file — in the `diff --git` header, in `---` and
19+//! `+++`, and in `rename from`/`rename to`. Only the last three are unambiguous: the
20+//! header is `a/<old> b/<new>` with a space between two names that may themselves
21+//! contain spaces, and nothing in the format says where the split is. So the header is
22+//! read as a first guess and every later line overrides it.
23+
24+use std::fmt;
25+
26+/// How many rows of one file's diff are rendered before the rest is a link.
27+///
28+/// A thousand lines is far past what anyone reads in a page — beyond it the browser is
29+/// laying out a file nobody is looking at. The blob at that revision is the way to see
30+/// the rest, and the page says so rather than silently stopping.
31+pub const MAX_FILE_DIFF_LINES: usize = 1000;
32+
33+/// What happened to a file in a commit.
34+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35+pub enum FileChange {
36+ Added,
37+ Deleted,
38+ Modified,
39+ /// Includes a copy, which git reports the same way and which reads identically.
40+ Renamed,
41+}
42+
43+impl FileChange {
44+ pub fn as_str(self) -> &'static str {
45+ match self {
46+ Self::Added => "added",
47+ Self::Deleted => "deleted",
48+ Self::Modified => "modified",
49+ Self::Renamed => "renamed",
50+ }
51+ }
52+}
53+
54+/// What one row of a rendered diff is.
55+///
56+/// Hunk headers and git's "no newline" note are rows rather than structure around the
57+/// rows, so a file's diff is one flat list and the table that renders it is one loop.
58+/// Nesting hunks would buy nothing: nothing is ever asked about a hunk as a unit.
59+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60+pub enum LineKind {
61+ /// `@@ -a,b +c,d @@`, and whatever git appended to it.
62+ Hunk,
63+ Context,
64+ Added,
65+ Removed,
66+ /// `\ No newline at end of file`. Belongs to the line above it and has no number of
67+ /// its own.
68+ Note,
69+}
70+
71+/// One row of a file's diff.
72+///
73+/// The numbers are `Option` because a row genuinely has one, both, or neither: an added
74+/// line exists only in the new file, a hunk header in neither.
75+#[derive(Debug, Clone, PartialEq, Eq)]
76+pub struct DiffLine {
77+ pub kind: LineKind,
78+ pub old: Option<u32>,
79+ pub new: Option<u32>,
80+ /// The line's content, without the leading marker git prefixes it with.
81+ pub text: String,
82+}
83+
84+/// One file's worth of a patch.
85+#[derive(Debug, Clone, PartialEq, Eq)]
86+pub struct FileDiff {
87+ /// The path as it is after the change — and for a deleted file, as it was.
88+ pub path: String,
89+ /// Where a renamed file came from. `None` for everything else, which is what makes
90+ /// it the thing a header checks before drawing an arrow.
91+ pub old_path: Option<String>,
92+ pub change: FileChange,
93+ /// Whether git declined to describe the change as lines.
94+ pub binary: bool,
95+ pub added: u32,
96+ pub removed: u32,
97+ /// At most [`MAX_FILE_DIFF_LINES`] of them.
98+ pub rows: Vec<DiffLine>,
99+ /// How many rows the file's diff really has, so a truncated one can say what it is
100+ /// not showing.
101+ pub total_rows: usize,
102+}
103+
104+impl FileDiff {
105+ /// Whether the rows are only the beginning of this file's diff.
106+ pub fn truncated(&self) -> bool {
107+ self.total_rows > self.rows.len()
108+ }
109+}
110+
111+/// One line of `--numstat`: what changed in a file, without any of the change itself.
112+///
113+/// The counts are `None` for a binary file, which is how git spells it — `-` in both
114+/// columns — and a page reports that rather than printing zeroes, which would read as
115+/// "nothing changed".
116+#[derive(Debug, Clone, PartialEq, Eq)]
117+pub struct FileStat {
118+ pub path: String,
119+ pub old_path: Option<String>,
120+ pub added: Option<u32>,
121+ pub removed: Option<u32>,
122+}
123+
124+impl FileStat {
125+ pub fn is_binary(&self) -> bool {
126+ self.added.is_none()
127+ }
128+}
129+
130+/// A whole diff, ready to render.
131+#[derive(Debug, Clone, Default, PartialEq, Eq)]
132+pub struct Diff {
133+ /// Every changed file with its counts. Complete even when the patch is not — this
134+ /// is what the page falls back to when [`truncated`](Self::truncated) is set.
135+ pub stats: Vec<FileStat>,
136+ /// The patch itself, per file. **Empty when the diff was truncated**: half a patch
137+ /// is not a smaller patch, and rendering the files that happened to fit while
138+ /// silently dropping the rest would be a lie about what the commit did.
139+ pub files: Vec<FileDiff>,
140+ pub truncated: bool,
141+}
142+
143+impl Diff {
144+ pub fn is_empty(&self) -> bool {
145+ self.stats.is_empty()
146+ }
147+
148+ pub fn files_changed(&self) -> usize {
149+ self.stats.len()
150+ }
151+
152+ /// Lines added across every file, from git's own counts.
153+ pub fn added(&self) -> u32 {
154+ self.stats.iter().filter_map(|file| file.added).sum()
155+ }
156+
157+ pub fn removed(&self) -> u32 {
158+ self.stats.iter().filter_map(|file| file.removed).sum()
159+ }
160+}
161+
162+/// Builds a whole diff from git's two outputs.
163+///
164+/// `truncated` is the port's answer about the *patch*; the numstat is always whole.
165+pub fn parse_diff(numstat: &[u8], patch: &[u8], truncated: bool) -> Diff {
166+ Diff {
167+ stats: parse_numstat(numstat),
168+ files: if truncated {
169+ Vec::new()
170+ } else {
171+ parse_patch(patch)
172+ },
173+ truncated,
174+ }
175+}
176+
177+/// Parses `--numstat`: `<added> TAB <removed> TAB <path>` per line.
178+///
179+/// A rename arrives as one line whose path is `old => new`, factored where git can:
180+/// `src/{a.rs => b.rs}` means `src/a.rs` became `src/b.rs`. Anything unparseable is
181+/// skipped rather than failed on — this decorates a page, and one odd line is not a
182+/// reason to refuse the commit.
183+pub fn parse_numstat(numstat: &[u8]) -> Vec<FileStat> {
184+ let mut stats = Vec::new();
185+
186+ for line in String::from_utf8_lossy(numstat).lines() {
187+ let line = line.trim_end_matches('\r');
188+
189+ if line.is_empty() {
190+ continue;
191+ }
192+
193+ let mut fields = line.splitn(3, '\t');
194+ let (Some(added), Some(removed), Some(path)) =
195+ (fields.next(), fields.next(), fields.next())
196+ else {
197+ continue;
198+ };
199+
200+ // `-` in both columns is git's way of saying "binary", not zero.
201+ let count = |field: &str| field.parse::<u32>().ok();
202+ let (old_path, path) = split_rename(&unquote(path));
203+
204+ stats.push(FileStat {
205+ path,
206+ old_path,
207+ added: count(added),
208+ removed: count(removed),
209+ });
210+ }
211+
212+ stats
213+}
214+
215+/// Expands numstat's rename spelling into the two paths it means.
216+///
217+/// `src/{a.rs => b.rs}` and `a.rs => b.rs` are the two forms; the braces mark the part
218+/// that differs when the paths share a prefix and a suffix.
219+fn split_rename(path: &str) -> (Option<String>, String) {
220+ let Some(arrow) = path.find(" => ") else {
221+ return (None, path.to_owned());
222+ };
223+
224+ match (path.find('{'), path.find('}')) {
225+ // `prefix{old => new}suffix`, with the braces bracketing the arrow.
226+ (Some(open), Some(close)) if open < arrow && arrow < close => {
227+ let prefix = &path[..open];
228+ let suffix = &path[close + 1..];
229+ let old = &path[open + 1..arrow];
230+ let new = &path[arrow + 4..close];
231+
232+ (
233+ Some(format!("{prefix}{old}{suffix}")),
234+ format!("{prefix}{new}{suffix}"),
235+ )
236+ }
237+ _ => (Some(path[..arrow].to_owned()), path[arrow + 4..].to_owned()),
238+ }
239+}
240+
241+/// Parses a unified patch into one entry per file.
242+///
243+/// The hunk line counts in `@@ -a,b +c,d @@` are tracked rather than ignored, so the
244+/// parser always knows whether it is inside a hunk. That is not fussiness: a patch *of
245+/// a patch* contains lines reading `diff --git …` and `+++ b/…` as ordinary content,
246+/// and a parser that matched those prefixes anywhere would split one file into several
247+/// and attribute the rest of the commit to a file that does not exist.
248+pub fn parse_patch(patch: &[u8]) -> Vec<FileDiff> {
249+ let text = String::from_utf8_lossy(patch);
250+ let mut files: Vec<FileDiff> = Vec::new();
251+ let mut state = Numbering::default();
252+
253+ for line in text.lines() {
254+ if !state.in_hunk()
255+ && let Some(header) = line.strip_prefix("diff --git ")
256+ {
257+ let (old, new) = header_paths(header);
258+
259+ files.push(FileDiff {
260+ path: new,
261+ old_path: None,
262+ change: FileChange::Modified,
263+ binary: false,
264+ added: 0,
265+ removed: 0,
266+ rows: Vec::new(),
267+ total_rows: 0,
268+ });
269+
270+ // The header's guess at the old path is kept only until a `---` or a
271+ // `rename from` says better; a modified file's two paths are the same, so it
272+ // is only ever wrong for a rename, which always has those lines.
273+ state = Numbering {
274+ header_old: old,
275+ ..Numbering::default()
276+ };
277+
278+ continue;
279+ }
280+
281+ let Some(file) = files.last_mut() else {
282+ // Anything before the first file header is not part of a patch. Skipped
283+ // rather than an error: a caller may hand over a fragment.
284+ continue;
285+ };
286+
287+ read_line(file, &mut state, line);
288+ }
289+
290+ files
291+}
292+
293+/// Where the parser is inside the current file.
294+#[derive(Debug, Default)]
295+struct Numbering {
296+ old: u32,
297+ new: u32,
298+ /// How many lines of the current hunk's old and new sides are still to come. Both
299+ /// zero means the hunk is over and the next line is metadata again.
300+ old_left: u32,
301+ new_left: u32,
302+ header_old: String,
303+}
304+
305+impl Numbering {
306+ fn in_hunk(&self) -> bool {
307+ self.old_left > 0 || self.new_left > 0
308+ }
309+}
310+
311+/// Reads one line of a patch into the file it belongs to.
312+fn read_line(file: &mut FileDiff, state: &mut Numbering, line: &str) {
313+ // git's "no newline" note sits *after* the last line of a hunk, so it arrives with
314+ // the hunk already counted out. It is matched first for that reason.
315+ if line.starts_with('\\') && file.total_rows > 0 {
316+ push(
317+ file,
318+ DiffLine {
319+ kind: LineKind::Note,
320+ old: None,
321+ new: None,
322+ text: line.trim_start_matches('\\').trim().to_owned(),
323+ },
324+ );
325+ return;
326+ }
327+
328+ if state.in_hunk() {
329+ read_body(file, state, line);
330+ return;
331+ }
332+
333+ // Metadata: every one of these is a fixed prefix git writes before the hunks, and
334+ // reaching them means the parser is not inside one.
335+ if let Some(from) = line.strip_prefix("rename from ") {
336+ file.old_path = Some(unquote(from));
337+ file.change = FileChange::Renamed;
338+ return;
339+ }
340+
341+ if let Some(to) = line.strip_prefix("rename to ") {
342+ file.path = unquote(to);
343+ file.change = FileChange::Renamed;
344+ return;
345+ }
346+
347+ if line.starts_with("new file mode ") {
348+ file.change = FileChange::Added;
349+ return;
350+ }
351+
352+ if line.starts_with("deleted file mode ") {
353+ file.change = FileChange::Deleted;
354+ return;
355+ }
356+
357+ // Two spellings, depending on whether git was asked for a readable diff or a binary
358+ // patch. Both mean the same thing to a page.
359+ if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
360+ file.binary = true;
361+ return;
362+ }
363+
364+ if let Some(path) = line.strip_prefix("--- ") {
365+ // `/dev/null` is git's spelling of "this file did not exist", which is what
366+ // makes an addition an addition even when the mode line was not seen.
367+ if path == "/dev/null" {
368+ file.change = FileChange::Added;
369+ } else if file.change != FileChange::Renamed {
370+ state.header_old = strip_prefix_marker(path);
371+ }
372+ return;
373+ }
374+
375+ if let Some(path) = line.strip_prefix("+++ ") {
376+ if path == "/dev/null" {
377+ file.change = FileChange::Deleted;
378+ // A deleted file's own name is the one it had, which `+++` does not carry.
379+ file.path = state.header_old.clone();
380+ } else if file.change != FileChange::Renamed {
381+ file.path = strip_prefix_marker(path);
382+ }
383+ return;
384+ }
385+
386+ if let Some((old, new, old_left, new_left)) = hunk_start(line) {
387+ state.old = old;
388+ state.new = new;
389+ state.old_left = old_left;
390+ state.new_left = new_left;
391+
392+ push(
393+ file,
394+ DiffLine {
395+ kind: LineKind::Hunk,
396+ old: None,
397+ new: None,
398+ text: line.to_owned(),
399+ },
400+ );
401+ }
402+
403+ // Anything else between files — `index`, a mode line, a similarity score, or a
404+ // blank — says nothing a page shows.
405+}
406+
407+/// Reads one line from inside a hunk, where the first character decides everything.
408+fn read_body(file: &mut FileDiff, state: &mut Numbering, line: &str) {
409+ match line.as_bytes().first() {
410+ Some(b'+') => {
411+ push(
412+ file,
413+ DiffLine {
414+ kind: LineKind::Added,
415+ old: None,
416+ new: Some(state.new),
417+ text: line[1..].to_owned(),
418+ },
419+ );
420+ state.new += 1;
421+ state.new_left = state.new_left.saturating_sub(1);
422+ file.added += 1;
423+ }
424+ Some(b'-') => {
425+ push(
426+ file,
427+ DiffLine {
428+ kind: LineKind::Removed,
429+ old: Some(state.old),
430+ new: None,
431+ text: line[1..].to_owned(),
432+ },
433+ );
434+ state.old += 1;
435+ state.old_left = state.old_left.saturating_sub(1);
436+ file.removed += 1;
437+ }
438+ // A context line is a leading space — and an entirely empty line, which some
439+ // tools write where git would write a single space.
440+ _ => {
441+ push(
442+ file,
443+ DiffLine {
444+ kind: LineKind::Context,
445+ old: Some(state.old),
446+ new: Some(state.new),
447+ text: line.strip_prefix(' ').unwrap_or(line).to_owned(),
448+ },
449+ );
450+ state.old += 1;
451+ state.new += 1;
452+ state.old_left = state.old_left.saturating_sub(1);
453+ state.new_left = state.new_left.saturating_sub(1);
454+ }
455+ }
456+}
457+
458+/// Adds a row, counting it even once the cap stops it being kept.
459+///
460+/// The count is what lets the page say how much it is not showing, and counting without
461+/// keeping is what stops a hundred-thousand-line file being held in memory to render a
462+/// thousand of it.
463+fn push(file: &mut FileDiff, row: DiffLine) {
464+ file.total_rows += 1;
465+
466+ if file.rows.len() < MAX_FILE_DIFF_LINES {
467+ file.rows.push(row);
468+ }
469+}
470+
471+/// Reads `@@ -a,b +c,d @@` into the two starting line numbers and the two lengths.
472+///
473+/// A count git omits is 1, which is what the format means by leaving it out.
474+fn hunk_start(line: &str) -> Option<(u32, u32, u32, u32)> {
475+ let inner = line.strip_prefix("@@ ")?;
476+ let inner = inner.split(" @@").next()?;
477+ let mut parts = inner.split_whitespace();
478+
479+ let old = parts.next()?.strip_prefix('-')?;
480+ let new = parts.next()?.strip_prefix('+')?;
481+
482+ fn range(value: &str) -> Option<(u32, u32)> {
483+ let mut fields = value.split(',');
484+ let start = fields.next()?.parse::<u32>().ok()?;
485+ let length = match fields.next() {
486+ Some(length) => length.parse::<u32>().ok()?,
487+ None => 1,
488+ };
489+
490+ // A hunk that writes into an empty file starts at 0; the first line it writes
491+ // is 1, so a zero start is nudged rather than trusted. git writes `-0,0` for
492+ // exactly that, and a naive read numbers the file from zero.
493+ Some((start.max(1), length))
494+ }
495+
496+ let (old_start, old_len) = range(old)?;
497+ let (new_start, new_len) = range(new)?;
498+
499+ Some((old_start, new_start, old_len, new_len))
500+}
501+
502+/// Splits `a/<old> b/<new>` as best the format allows.
503+///
504+/// Ambiguous by construction when a path contains a space, which is why this is only
505+/// ever a first guess — see the module note. The common case, where both names are the
506+/// same, is resolved exactly: the header is then two equal halves.
507+fn header_paths(header: &str) -> (String, String) {
508+ if let Some(rest) = header.strip_prefix("a/") {
509+ // `<old> b/<new>`. When the names match, the split is at the midpoint and the
510+ // arithmetic is exact regardless of what the name contains.
511+ let midpoint = (rest.len().saturating_sub(3)) / 2;
512+
513+ if rest.len() > 3
514+ && rest[midpoint..].starts_with(" b/")
515+ && rest[..midpoint] == rest[midpoint + 3..]
516+ {
517+ let path = unquote(&rest[..midpoint]);
518+ return (path.clone(), path);
519+ }
520+
521+ if let Some(split) = rest.find(" b/") {
522+ return (unquote(&rest[..split]), unquote(&rest[split + 3..]));
523+ }
524+ }
525+
526+ // Not a shape we understand. The `---`/`+++` lines will correct it, and for a
527+ // binary file with no such lines the header itself is all there is.
528+ let guess = unquote(header);
529+ (guess.clone(), guess)
530+}
531+
532+/// Drops the `a/` or `b/` git puts in front of a path in `---` and `+++`.
533+fn strip_prefix_marker(path: &str) -> String {
534+ let path = unquote(path);
535+
536+ path.strip_prefix("a/")
537+ .or_else(|| path.strip_prefix("b/"))
538+ .unwrap_or(&path)
539+ .to_owned()
540+}
541+
542+/// Undoes the C-style quoting git applies to a path it cannot write literally.
543+///
544+/// A path containing a quote, a backslash, a tab or a newline arrives wrapped in double
545+/// quotes with those bytes escaped, and non-ASCII bytes as three-digit octal. Left
546+/// quoted, such a path would be shown with its escapes visible and linked to with a
547+/// name that is not its name.
548+fn unquote(path: &str) -> String {
549+ let Some(inner) = path
550+ .strip_prefix('"')
551+ .and_then(|rest| rest.strip_suffix('"'))
552+ else {
553+ return path.to_owned();
554+ };
555+
556+ let mut bytes = Vec::with_capacity(inner.len());
557+ let mut chars = inner.chars();
558+
559+ while let Some(char) = chars.next() {
560+ if char != '\\' {
561+ let mut buffer = [0u8; 4];
562+ bytes.extend_from_slice(char.encode_utf8(&mut buffer).as_bytes());
563+ continue;
564+ }
565+
566+ match chars.next() {
567+ Some('n') => bytes.push(b'\n'),
568+ Some('t') => bytes.push(b'\t'),
569+ Some('r') => bytes.push(b'\r'),
570+ Some('"') => bytes.push(b'"'),
571+ Some('\\') => bytes.push(b'\\'),
572+ // Three octal digits, which is how a byte outside ASCII is written.
573+ Some(digit @ '0'..='7') => {
574+ let mut octal = String::from(digit);
575+
576+ for _ in 0..2 {
577+ match chars.next() {
578+ Some(next @ '0'..='7') => octal.push(next),
579+ Some(other) => {
580+ bytes.extend_from_slice(other.to_string().as_bytes());
581+ break;
582+ }
583+ None => break,
584+ }
585+ }
586+
587+ match u8::from_str_radix(&octal, 8) {
588+ Ok(byte) => bytes.push(byte),
589+ Err(_) => bytes.extend_from_slice(octal.as_bytes()),
590+ }
591+ }
592+ Some(other) => {
593+ let mut buffer = [0u8; 4];
594+ bytes.extend_from_slice(other.encode_utf8(&mut buffer).as_bytes());
595+ }
596+ None => bytes.push(b'\\'),
597+ }
598+ }
599+
600+ String::from_utf8_lossy(&bytes).into_owned()
601+}
602+
603+impl fmt::Display for FileChange {
604+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
605+ f.write_str(self.as_str())
606+ }
607+}
608+
609+#[cfg(test)]
610+mod tests {
611+ use super::*;
612+
613+ /// Captured from `git diff-tree --no-commit-id -p -M --numstat --no-color`, which
614+ /// is exactly what the adapter runs. Every sample below is real git output rather
615+ /// than a hand-written approximation of it — the whole reason this parser is
616+ /// testable without a repository.
617+ const RENAME: &str = "\
618+diff --git a/src/x.txt b/src/y.txt
619+similarity index 83%
620+rename from src/x.txt
621+rename to src/y.txt
622+index 6f195b4..cbe236c 100644
623+--- a/src/x.txt
624++++ b/src/y.txt
625+@@ -3,3 +3,4 @@ bbb
626+ ccc
627+ ddd
628+ eee
629++fff
630+";
631+
632+ const MIXED: &str = "\
633+diff --git a/a.txt b/a.txt
634+deleted file mode 100644
635+index 4cb29ea..0000000
636+--- a/a.txt
637++++ /dev/null
638+@@ -1,3 +0,0 @@
639+-one
640+-two
641+-three
642+diff --git a/b.txt b/b.txt
643+new file mode 100644
644+index 0000000..f59d6df
645+--- /dev/null
646++++ b/b.txt
647+@@ -0,0 +1,2 @@
648++one
649++two
650+\\ No newline at end of file
651+diff --git a/logo.bin b/logo.bin
652+index ad2f385..fe25227 100644
653+Binary files a/logo.bin and b/logo.bin differ
654+";
655+
656+ const MODIFIED: &str = "\
657+diff --git a/Cargo.toml b/Cargo.toml
658+index 937d41c..3c72429 100644
659+--- a/Cargo.toml
660++++ b/Cargo.toml
661+@@ -17,7 +17,7 @@ serde = { version = \"1.0.229\" }
662+ sha2 = \"0.10\"
663+ sqlx = \"0.9.0\"
664+ subtle = \"2.6.1\"
665+-tokio = { features = [\"process\"] }
666++tokio = { features = [\"process\", \"time\"] }
667+ tokio-util = \"0.7\"
668+ topcoat = \"0.5.0\"
669+ uuid = \"1.24.0\"
670+";
671+
672+ // --- numstat ------------------------------------------------------------------
673+
674+ #[test]
675+ fn numstat_counts_a_plain_change() {
676+ let stats = parse_numstat(b"1\t1\tCargo.toml\n10\t0\tplans/progress.md\n");
677+
678+ assert_eq!(stats.len(), 2);
679+ assert_eq!(stats[0].path, "Cargo.toml");
680+ assert_eq!((stats[0].added, stats[0].removed), (Some(1), Some(1)));
681+ assert!(stats[0].old_path.is_none());
682+ assert_eq!(stats[1].path, "plans/progress.md");
683+ }
684+
685+ #[test]
686+ fn a_binary_file_has_no_counts_rather_than_zero_ones() {
687+ // Zeroes would read as "nothing changed", which is a different claim.
688+ let stats = parse_numstat(b"-\t-\tlogo.png\n");
689+
690+ assert!(stats[0].is_binary());
691+ assert_eq!(stats[0].added, None);
692+ }
693+
694+ #[test]
695+ fn a_rename_with_a_shared_prefix_expands_to_both_paths() {
696+ let stats = parse_numstat(b"1\t0\tsrc/{x.txt => y.txt}\n");
697+
698+ assert_eq!(stats[0].old_path.as_deref(), Some("src/x.txt"));
699+ assert_eq!(stats[0].path, "src/y.txt");
700+ }
701+
702+ #[test]
703+ fn a_rename_with_nothing_in_common_expands_too() {
704+ let stats = parse_numstat(b"2\t2\told.rs => new.rs\n");
705+
706+ assert_eq!(stats[0].old_path.as_deref(), Some("old.rs"));
707+ assert_eq!(stats[0].path, "new.rs");
708+ }
709+
710+ #[test]
711+ fn a_rename_within_a_directory_keeps_the_suffix() {
712+ let stats = parse_numstat(b"0\t0\tsrc/{a => b}/mod.rs\n");
713+
714+ assert_eq!(stats[0].old_path.as_deref(), Some("src/a/mod.rs"));
715+ assert_eq!(stats[0].path, "src/b/mod.rs");
716+ }
717+
718+ #[test]
719+ fn a_quoted_path_comes_back_as_its_real_name() {
720+ let stats = parse_numstat("1\t0\t\"docs/caf\\303\\251.md\"\n".as_bytes());
721+
722+ assert_eq!(stats[0].path, "docs/café.md");
723+ }
724+
725+ // --- the patch ----------------------------------------------------------------
726+
727+ #[test]
728+ fn a_modification_numbers_both_sides() {
729+ let files = parse_patch(MODIFIED.as_bytes());
730+
731+ assert_eq!(files.len(), 1);
732+ let file = &files[0];
733+
734+ assert_eq!(file.path, "Cargo.toml");
735+ assert_eq!(file.change, FileChange::Modified);
736+ assert_eq!((file.added, file.removed), (1, 1));
737+
738+ // The hunk header, then three context lines, then the pair, then three more.
739+ assert_eq!(file.rows[0].kind, LineKind::Hunk);
740+ assert_eq!(file.rows[1].kind, LineKind::Context);
741+ assert_eq!(file.rows[1].old, Some(17));
742+ assert_eq!(file.rows[1].new, Some(17));
743+
744+ let removed = file
745+ .rows
746+ .iter()
747+ .find(|row| row.kind == LineKind::Removed)
748+ .expect("a removed line");
749+ assert_eq!(removed.old, Some(20));
750+ assert_eq!(removed.new, None);
751+
752+ let added = file
753+ .rows
754+ .iter()
755+ .find(|row| row.kind == LineKind::Added)
756+ .expect("an added line");
757+ assert_eq!(added.old, None);
758+ assert_eq!(added.new, Some(20));
759+ assert_eq!(added.text, "tokio = { features = [\"process\", \"time\"] }");
760+
761+ // Numbering resumes together after the pair.
762+ let last = file.rows.last().expect("a last row");
763+ assert_eq!((last.old, last.new), (Some(23), Some(23)));
764+ }
765+
766+ #[test]
767+ fn a_rename_carries_both_paths() {
768+ let files = parse_patch(RENAME.as_bytes());
769+
770+ assert_eq!(files.len(), 1);
771+ assert_eq!(files[0].change, FileChange::Renamed);
772+ assert_eq!(files[0].old_path.as_deref(), Some("src/x.txt"));
773+ assert_eq!(files[0].path, "src/y.txt");
774+ assert_eq!((files[0].added, files[0].removed), (1, 0));
775+ }
776+
777+ #[test]
778+ fn a_deletion_a_creation_and_a_binary_file_are_told_apart() {
779+ let files = parse_patch(MIXED.as_bytes());
780+
781+ assert_eq!(files.len(), 3);
782+
783+ assert_eq!(files[0].path, "a.txt");
784+ assert_eq!(files[0].change, FileChange::Deleted);
785+ assert_eq!((files[0].added, files[0].removed), (0, 3));
786+
787+ assert_eq!(files[1].path, "b.txt");
788+ assert_eq!(files[1].change, FileChange::Added);
789+ assert_eq!((files[1].added, files[1].removed), (2, 0));
790+
791+ assert_eq!(files[2].path, "logo.bin");
792+ assert!(files[2].binary);
793+ assert!(files[2].rows.is_empty());
794+ }
795+
796+ #[test]
797+ fn a_missing_final_newline_is_a_note_rather_than_a_line() {
798+ // It has no number of its own: it describes the line above it.
799+ let files = parse_patch(MIXED.as_bytes());
800+ let note = files[1].rows.last().expect("a last row");
801+
802+ assert_eq!(note.kind, LineKind::Note);
803+ assert_eq!(note.old, None);
804+ assert_eq!(note.new, None);
805+ assert_eq!(note.text, "No newline at end of file");
806+ // And it is not counted as an addition.
807+ assert_eq!(files[1].added, 2);
808+ }
809+
810+ #[test]
811+ fn a_new_file_starts_numbering_at_one_not_zero() {
812+ // git writes `@@ -0,0 +1,2 @@`, and a naive read of the old side gives 0.
813+ let files = parse_patch(MIXED.as_bytes());
814+ let first = files[1]
815+ .rows
816+ .iter()
817+ .find(|row| row.kind == LineKind::Added)
818+ .expect("an added line");
819+
820+ assert_eq!(first.new, Some(1));
821+ }
822+
823+ #[test]
824+ fn a_line_that_looks_like_metadata_inside_a_hunk_is_still_a_line() {
825+ // A patch of a patch: `+++ b/x` inside a hunk is content, not a header. It is
826+ // an addition because a body line's first character decides, and the metadata
827+ // checks only ever run before the first `@@`.
828+ let patch = "\
829+diff --git a/p.diff b/p.diff
830+--- a/p.diff
831++++ b/p.diff
832+@@ -1,1 +1,2 @@
833+ context
834++++ b/inner
835+";
836+ let files = parse_patch(patch.as_bytes());
837+
838+ assert_eq!(files[0].added, 1);
839+ assert_eq!(files[0].rows.last().expect("a row").text, "++ b/inner");
840+ }
841+
842+ #[test]
843+ fn a_long_file_keeps_its_first_rows_and_counts_the_rest() {
844+ let mut patch = String::from(
845+ "diff --git a/big.txt b/big.txt\n--- a/big.txt\n+++ b/big.txt\n@@ -1,0 +1,5000 @@\n",
846+ );
847+ for index in 0..5000 {
848+ patch.push_str(&format!("+line {index}\n"));
849+ }
850+
851+ let files = parse_patch(patch.as_bytes());
852+
853+ assert_eq!(files[0].rows.len(), MAX_FILE_DIFF_LINES);
854+ assert_eq!(files[0].total_rows, 5001);
855+ assert!(files[0].truncated());
856+ // The counts are of the whole file, not of what was kept.
857+ assert_eq!(files[0].added, 5000);
858+ }
859+
860+ // --- the whole thing ----------------------------------------------------------
861+
862+ #[test]
863+ fn the_totals_come_from_numstat_not_from_the_patch() {
864+ let diff = parse_diff(
865+ b"0\t3\ta.txt\n2\t0\tb.txt\n-\t-\tlogo.bin\n",
866+ MIXED.as_bytes(),
867+ false,
868+ );
869+
870+ assert_eq!(diff.files_changed(), 3);
871+ assert_eq!(diff.added(), 2);
872+ assert_eq!(diff.removed(), 3);
873+ assert_eq!(diff.files.len(), 3);
874+ }
875+
876+ #[test]
877+ fn a_truncated_diff_keeps_its_file_list_and_drops_its_patch() {
878+ // The point of asking git for both in one run: the counts outlive the cap.
879+ let diff = parse_diff(b"0\t3\ta.txt\n2\t0\tb.txt\n", MIXED.as_bytes(), true);
880+
881+ assert!(diff.truncated);
882+ assert_eq!(diff.files_changed(), 2);
883+ assert_eq!(diff.added(), 2);
884+ assert!(diff.files.is_empty());
885+ }
886+
887+ #[test]
888+ fn a_commit_that_changed_nothing_is_empty_rather_than_broken() {
889+ let diff = parse_diff(b"", b"", false);
890+
891+ assert!(diff.is_empty());
892+ assert_eq!(diff.files_changed(), 0);
893+ }
894+}
src/application/mod.rs+6 −0View file
@@ -7,7 +7,9 @@ pub mod archive;
77 pub(crate) mod authz;
88 pub mod browse;
99 pub mod claim;
10+pub mod commit;
1011 pub mod config;
12+pub mod diff;
1113 pub mod error;
1214 pub mod git;
1315 pub mod identity;
@@ -26,7 +28,11 @@ pub use browse::{
2628 browse_repo, list_branches, list_refs, list_tags, read_raw_file, repo_log,
2729 };
2830 pub use claim::{OwnerSpec, claim_instance, is_claimed, sole_owner_handle};
31+pub use commit::{
32+ COMPARE_LOG_LIMIT, CommitPage, Compared, Comparison, compare_revisions, view_commit,
33+};
2934 pub use config::{AppConfig, Secret};
35+pub use diff::{Diff, DiffLine, FileChange, FileDiff, FileStat, LineKind, MAX_FILE_DIFF_LINES};
3036 pub use error::{Error, Result};
3137 pub use git::{GitClientHeaders, GitEndpoint, GitOperation, GitService, serve_git};
3238 pub use identity::{Identity, describe_identity};
src/application/port.rs+97 −2View file
@@ -8,8 +8,8 @@ use std::{path::PathBuf, pin::Pin};
88 use tokio::io::AsyncRead;
99
1010 use crate::domain::{
11 BranchRow, CommitSummary, DomainError, GitRef, GrepHit, ObjectId, OrgName, PasswordHash,
12 RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
11+ BranchRow, CommitDetail, CommitSummary, DomainError, GitRef, GrepHit, ObjectId, OrgName,
12+ PasswordHash, RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
1313 };
1414
1515 /// Hashes and verifies passwords.
@@ -389,6 +389,80 @@ pub trait GitQuery: Send + Sync {
389389 name: &RepoName,
390390 ) -> impl Future<Output = Result<Option<TagSummary>, GitQueryError>> + Send;
391391
392+ /// One commit, in full.
393+ ///
394+ /// `Ok(None)` for a revision that names nothing, and for one that names something
395+ /// which is not a commit — a tree or a blob addressed by its own id. A commit page
396+ /// has nothing to show for either, so both are the same 404.
397+ ///
398+ /// **Two `git` processes**: the revision is resolved before it is read, for the
399+ /// reason [`count_commits`](Self::count_commits) resolves first. The resolved
400+ /// [`ObjectId`] comes back on the answer, so a caller that goes on to ask for a
401+ /// diff does not pay to resolve it again.
402+ fn commit(
403+ &self,
404+ handle: &OrgName,
405+ name: &RepoName,
406+ rev: &RefName,
407+ ) -> impl Future<Output = Result<Option<CommitDetail>, GitQueryError>> + Send;
408+
409+ /// The patch between two commits, as git writes it.
410+ ///
411+ /// Takes resolved ids rather than revisions: the caller has already resolved them
412+ /// — that is what [`commit`](Self::commit) and [`resolve`](Self::resolve) hand
413+ /// back — and re-resolving here would be a process per call for an answer already
414+ /// in hand.
415+ ///
416+ /// `base` of `None` diffs against the empty tree, which is what a root commit needs
417+ /// — a first commit has no parent to compare with, and showing it as an empty diff
418+ /// would hide the whole of it.
419+ ///
420+ /// **Bytes are capped like every other read here**, and unlike the others the cap
421+ /// is expected to bite: a merge of a vendored dependency is a legitimately enormous
422+ /// patch. [`RawDiff::truncated`] says whether it did, and the per-file counts are
423+ /// carried separately *precisely so that they survive it* — see [`RawDiff`].
424+ ///
425+ /// One `git` process.
426+ fn diff(
427+ &self,
428+ handle: &OrgName,
429+ name: &RepoName,
430+ base: Option<&ObjectId>,
431+ head: &ObjectId,
432+ max_bytes: u64,
433+ ) -> impl Future<Output = Result<RawDiff, GitQueryError>> + Send;
434+
435+ /// The best common ancestor of two commits, or `None` when they share none.
436+ ///
437+ /// `None` is a real answer, not a failure: two histories imported into one
438+ /// repository have no merge base, and a compare page says so rather than 500ing.
439+ ///
440+ /// One `git` process.
441+ fn merge_base(
442+ &self,
443+ handle: &OrgName,
444+ name: &RepoName,
445+ base: &ObjectId,
446+ head: &ObjectId,
447+ ) -> impl Future<Output = Result<Option<ObjectId>, GitQueryError>> + Send;
448+
449+ /// The commits reachable from `head` but not from `base`, newest first.
450+ ///
451+ /// `base` of `None` is every commit reachable from `head`, which makes this a
452+ /// superset of [`log`](Self::log) — kept separate anyway, because `log` takes a
453+ /// revision and this takes resolved ids, and collapsing them would put a URL's text
454+ /// back in front of git's revision parser.
455+ ///
456+ /// One `git` process.
457+ fn log_between(
458+ &self,
459+ handle: &OrgName,
460+ name: &RepoName,
461+ base: Option<&ObjectId>,
462+ head: &ObjectId,
463+ limit: usize,
464+ ) -> impl Future<Output = Result<Vec<CommitSummary>, GitQueryError>> + Send;
465+
392466 /// Every branch, with what its tip commit says, newest commit first.
393467 ///
394468 /// Richer than [`list_refs`](Self::list_refs) and for a different page: the switcher
@@ -443,6 +517,27 @@ pub trait GitQuery: Send + Sync {
443517 limit: usize,
444518 ) -> impl Future<Output = Result<Vec<GrepHit>, GitQueryError>> + Send;
445519 }
520+
521+/// A patch, plus the per-file counts that outlive truncating it.
522+///
523+/// The two arrive from **one** `git` process, because git will write a `--numstat`
524+/// block and a `-p` patch in the same run and writes the counts *first*. That ordering
525+/// is the whole reason for asking this way: a diff too large to render still has
526+/// complete per-file statistics at the top of what was read, so the page can list every
527+/// changed file with its `+a −b` and simply decline to draw the lines. Asking twice
528+/// would cost a second process on every commit page to buy something only the rare
529+/// oversized one needs.
530+#[derive(Debug, Clone, Default, PartialEq, Eq)]
531+pub struct RawDiff {
532+ /// The `--numstat` block: `<added> TAB <removed> TAB <path>` per changed file, with
533+ /// `-` for both counts when the file is binary. Always complete.
534+ pub numstat: Vec<u8>,
535+ /// The unified patch. Cut short — possibly mid-line — when
536+ /// [`truncated`](Self::truncated) is set.
537+ pub patch: Vec<u8>,
538+ /// Whether the patch hit the byte cap and is therefore not the whole of it.
539+ pub truncated: bool,
540+}
446541 /// A repository could not be read.
447542 #[derive(Debug)]
448543 pub struct GitQueryError {
src/domain/mod.rs+2 −2View file
@@ -25,8 +25,8 @@ pub use error::DomainError;
2525 pub use id::{MembershipId, OrgId, RepoId, TokenId, UserId};
2626 pub use membership::{Membership, Role};
2727 pub use object::{
28 BranchRow, CommitSummary, EntryKind, GitRef, GrepHit, ObjectId, RefKind, TagRow, TagSummary,
29 TreeEntry,
28+ BranchRow, CommitDetail, CommitSummary, EntryKind, GitRef, GrepHit, ObjectId, RefKind, TagRow,
29+ TagSummary, TreeEntry,
3030 };
3131 pub use org::{OrgName, Organization};
3232 pub use password::PasswordHash;
src/domain/object.rs+44 −0View file
@@ -247,6 +247,50 @@ pub struct GrepHit {
247247 pub text: String,
248248 }
249249
250+/// A commit, in the detail a commit page shows.
251+///
252+/// Separate from [`CommitSummary`] rather than an extension of it: a log reads fifty
253+/// commits and wants four fields each, while a commit page reads one and wants all of
254+/// them. Asking git for the wider format fifty times over would be paid on the page
255+/// that can least afford it.
256+///
257+/// The author and the committer are kept apart because they genuinely differ — a
258+/// rebase, a patch applied by a maintainer, a cherry-pick — and a page that showed only
259+/// one would quietly misattribute all three.
260+#[derive(Debug, Clone, PartialEq, Eq)]
261+pub struct CommitDetail {
262+ pub id: ObjectId,
263+ /// The tree this commit points at, so a page can link to browsing it.
264+ pub tree: ObjectId,
265+ /// Every parent, in git's order. Empty for a root commit, two or more for a merge.
266+ pub parents: Vec<ObjectId>,
267+ /// The first line of the message.
268+ pub summary: String,
269+ /// Everything after the first paragraph break, verbatim. Empty when there is none.
270+ pub body: String,
271+ pub author_name: String,
272+ pub author_email: String,
273+ pub authored_at: SystemTime,
274+ pub committer_name: String,
275+ pub committer_email: String,
276+ pub committed_at: SystemTime,
277+}
278+
279+impl CommitDetail {
280+ /// Whether the commit was recorded by someone other than the person who wrote it.
281+ ///
282+ /// The one question a page asks before deciding to name the committer at all:
283+ /// showing "Ada authored and Ada committed" on every ordinary commit is noise.
284+ pub fn has_distinct_committer(&self) -> bool {
285+ self.committer_name != self.author_name || self.committer_email != self.author_email
286+ }
287+
288+ /// Whether this commit has no parent — the first commit in a history.
289+ pub fn is_root(&self) -> bool {
290+ self.parents.is_empty()
291+ }
292+}
293+
250294 #[cfg(test)]
251295 mod tests {
252296 use super::*;
src/infrastructure/git.rs+70 −3View file
@@ -23,11 +23,11 @@ use crate::{
2323 application::port::{
2424 ArchiveRequest, Blob, ByteStream, GitArchive, GitArchiveError, GitMethod, GitProtocolError,
2525 GitProtocolServer, GitQuery, GitQueryError, GitRequest, GitResponse, GitStorage,
26 GitStorageError,
26+ GitStorageError, RawDiff,
2727 },
2828 domain::{
29 BranchRow, CommitSummary, GitRef, GrepHit, ObjectId, OrgName, RefKind, RefName, RepoName,
30 RepoPath, TagRow, TagSummary, TreeEntry,
29+ BranchRow, CommitDetail, CommitSummary, GitRef, GrepHit, ObjectId, OrgName, RefKind,
30+ RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
3131 },
3232 };
3333
@@ -638,6 +638,13 @@ pub struct InMemoryGitQuery {
638638 /// Makes the next grep report the read timeout, which is a page state rather than
639639 /// a failure and therefore worth a test.
640640 slow_grep: bool,
641+ /// The one commit `commit` answers with, whatever revision it is asked for. A fake
642+ /// of git's object store is not what these tests are about.
643+ detail: Option<CommitDetail>,
644+ /// The raw patch `diff` answers with, and whether the fake should call it truncated.
645+ diff: Option<RawDiff>,
646+ /// The merge base of any two commits, because a fake has no graph to walk.
647+ merge_base: Option<ObjectId>,
641648 }
642649
643650 impl InMemoryGitQuery {
@@ -743,6 +750,25 @@ impl InMemoryGitQuery {
743750 self
744751 }
745752
753+ pub fn with_commit(mut self, detail: CommitDetail) -> Self {
754+ self.detail = Some(detail);
755+ self
756+ }
757+
758+ pub fn with_diff(mut self, patch: impl Into<Vec<u8>>, numstat: impl Into<Vec<u8>>) -> Self {
759+ self.diff = Some(RawDiff {
760+ numstat: numstat.into(),
761+ patch: patch.into(),
762+ truncated: false,
763+ });
764+ self
765+ }
766+
767+ pub fn with_merge_base(mut self, id: &str) -> Self {
768+ self.merge_base = Some(ObjectId::from_trusted(id));
769+ self
770+ }
771+
746772 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
747773 self.refs.push(GitRef {
748774 name: RefName::from_trusted(name),
@@ -869,6 +895,47 @@ impl GitQuery for InMemoryGitQuery {
869895
870896 Ok(self.grep_hits.iter().take(limit).cloned().collect())
871897 }
898+
899+ async fn commit(
900+ &self,
901+ _handle: &OrgName,
902+ _name: &RepoName,
903+ _rev: &RefName,
904+ ) -> Result<Option<CommitDetail>, GitQueryError> {
905+ Ok(self.detail.clone())
906+ }
907+
908+ async fn diff(
909+ &self,
910+ _handle: &OrgName,
911+ _name: &RepoName,
912+ _base: Option<&ObjectId>,
913+ _head: &ObjectId,
914+ _max_bytes: u64,
915+ ) -> Result<RawDiff, GitQueryError> {
916+ Ok(self.diff.clone().unwrap_or_default())
917+ }
918+
919+ async fn merge_base(
920+ &self,
921+ _handle: &OrgName,
922+ _name: &RepoName,
923+ _base: &ObjectId,
924+ _head: &ObjectId,
925+ ) -> Result<Option<ObjectId>, GitQueryError> {
926+ Ok(self.merge_base.clone())
927+ }
928+
929+ async fn log_between(
930+ &self,
931+ _handle: &OrgName,
932+ _name: &RepoName,
933+ _base: Option<&ObjectId>,
934+ _head: &ObjectId,
935+ limit: usize,
936+ ) -> Result<Vec<CommitSummary>, GitQueryError> {
937+ Ok(self.commits.iter().take(limit).cloned().collect())
938+ }
872939 }
873940
874941 #[cfg(test)]
src/infrastructure/git_query.rs+695 −7View file
@@ -35,16 +35,16 @@ use std::{
3535 time::{Duration, SystemTime, UNIX_EPOCH},
3636 };
3737
38use tokio::io::AsyncWriteExt;
38+use tokio::io::{AsyncReadExt, AsyncWriteExt};
3939
4040 use crate::{
4141 application::{
42 port::{Blob, GitQuery, GitQueryError},
42+ port::{Blob, GitQuery, GitQueryError, RawDiff},
4343 search::parse_grep_output,
4444 },
4545 domain::{
46 BranchRow, CommitSummary, EntryKind, GitRef, GrepHit, ObjectId, OrgName, RefKind, RefName,
47 RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
46+ BranchRow, CommitDetail, CommitSummary, EntryKind, GitRef, GrepHit, ObjectId, OrgName,
47+ RefKind, RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
4848 },
4949 infrastructure::git::git_command,
5050 };
@@ -243,7 +243,6 @@ impl GitQuery for DiskGitQuery {
243243 // commit message contains newlines as a matter of course, and a name can contain
244244 // almost anything, so splitting on lines or whitespace would misread real
245245 // history rather than exotic history.
246 let format = "--format=%H%x00%ct%x00%an%x00%s";
247246 let count = format!("--max-count={limit}");
248247
249248 let output = run(
@@ -252,7 +251,7 @@ impl GitQuery for DiskGitQuery {
252251 OsStr::new("log"),
253252 OsStr::new("-z"),
254253 OsStr::new(&count),
255 OsStr::new(format),
254+ OsStr::new(LOG_FORMAT),
256255 OsStr::new(commit.as_str()),
257256 ],
258257 )
@@ -435,6 +434,163 @@ impl GitQuery for DiskGitQuery {
435434
436435 Ok(parse_grep_output(&output.stdout, commit, limit))
437436 }
437+
438+ async fn commit(
439+ &self,
440+ handle: &OrgName,
441+ name: &RepoName,
442+ rev: &RefName,
443+ ) -> Result<Option<CommitDetail>, GitQueryError> {
444+ let repo = self.repo_path(handle, name);
445+
446+ // Resolved first, as everywhere else in this module: `git log` is fatal on a
447+ // revision that names nothing, and `resolve` already peels an annotated tag and
448+ // refuses a tree or a blob — so what reaches `log` is an object id known to be
449+ // a commit, and a non-zero exit below really is a fault.
450+ let Some(id) = self.resolve(handle, name, rev).await? else {
451+ return Ok(None);
452+ };
453+
454+ let output = run(
455+ &repo,
456+ [
457+ OsStr::new("log"),
458+ OsStr::new("--max-count=1"),
459+ OsStr::new(COMMIT_FORMAT),
460+ OsStr::new(id.as_str()),
461+ ],
462+ )
463+ .await?;
464+
465+ parse_commit(&output.stdout).map(Some)
466+ }
467+
468+ async fn diff(
469+ &self,
470+ handle: &OrgName,
471+ name: &RepoName,
472+ base: Option<&ObjectId>,
473+ head: &ObjectId,
474+ max_bytes: u64,
475+ ) -> Result<RawDiff, GitQueryError> {
476+ let repo = self.repo_path(handle, name);
477+
478+ // `diff-tree` rather than `diff`, for both shapes: it is the plumbing command,
479+ // it takes tree-ish arguments rather than revision expressions, and it does not
480+ // consult a working tree that a bare repository does not have.
481+ //
482+ // The flags, each load-bearing:
483+ // --no-commit-id `-p` would otherwise print the commit's id as a first line
484+ // -p the patch itself
485+ // -M rename detection, so a moved file is one entry, not two
486+ // --numstat the per-file counts, written *before* the patch — which is
487+ // what makes them survive the byte cap below
488+ // --no-color the caller renders; git must not send escape sequences
489+ // --root a first commit is diffed against nothing rather than
490+ // skipped, which is the only way to see what it introduced
491+ let mut args: Vec<&OsStr> = vec![
492+ OsStr::new("diff-tree"),
493+ OsStr::new("--no-commit-id"),
494+ OsStr::new("-p"),
495+ OsStr::new("-M"),
496+ OsStr::new("--numstat"),
497+ OsStr::new("--no-color"),
498+ ];
499+
500+ match base {
501+ Some(base) => {
502+ args.push(OsStr::new(base.as_str()));
503+ }
504+ None => args.push(OsStr::new("--root")),
505+ }
506+
507+ args.push(OsStr::new(head.as_str()));
508+
509+ let (stdout, truncated) = run_capped(&repo, args, max_bytes).await?;
510+ let (numstat, patch) = split_numstat(&stdout);
511+
512+ Ok(RawDiff {
513+ numstat: numstat.to_vec(),
514+ patch: patch.to_vec(),
515+ truncated,
516+ })
517+ }
518+
519+ async fn merge_base(
520+ &self,
521+ handle: &OrgName,
522+ name: &RepoName,
523+ base: &ObjectId,
524+ head: &ObjectId,
525+ ) -> Result<Option<ObjectId>, GitQueryError> {
526+ let repo = self.repo_path(handle, name);
527+
528+ // **The one exception to this module's rule that a non-zero exit is a fault.**
529+ // `git merge-base` documents exit 1 as "no merge base found" and reserves 128
530+ // for real errors, so the two are distinguishable here in a way they are not
531+ // for `rev-parse` — which is why the rule exists at all. Two histories with no
532+ // common ancestor is a thing a compare page must be able to say, and the
533+ // arguments are resolved object ids, so there is nothing else exit 1 can mean.
534+ let output = run_allowing(
535+ &repo,
536+ [
537+ OsStr::new("merge-base"),
538+ OsStr::new(base.as_str()),
539+ OsStr::new(head.as_str()),
540+ ],
541+ &[NO_COMMON_ANCESTOR],
542+ )
543+ .await?;
544+
545+ let id = String::from_utf8_lossy(&output.stdout);
546+ let id = id.trim();
547+
548+ if id.is_empty() {
549+ return Ok(None);
550+ }
551+
552+ Ok(Some(ObjectId::new(id).map_err(|error| {
553+ GitQueryError::new(format!("git named a bad merge base: {error}"))
554+ })?))
555+ }
556+
557+ async fn log_between(
558+ &self,
559+ handle: &OrgName,
560+ name: &RepoName,
561+ base: Option<&ObjectId>,
562+ head: &ObjectId,
563+ limit: usize,
564+ ) -> Result<Vec<CommitSummary>, GitQueryError> {
565+ let repo = self.repo_path(handle, name);
566+
567+ if limit == 0 {
568+ return Ok(Vec::new());
569+ }
570+
571+ // Both ends are already object ids, so the range is built here rather than
572+ // taken from a URL — `..` is the one piece of revision syntax this module
573+ // writes itself, and `RefName` refuses it precisely so that nothing else can.
574+ let range = match base {
575+ Some(base) => format!("{}..{}", base.as_str(), head.as_str()),
576+ None => head.as_str().to_owned(),
577+ };
578+ let count = format!("--max-count={limit}");
579+
580+ let output = run(
581+ &repo,
582+ [
583+ OsStr::new("log"),
584+ OsStr::new("-z"),
585+ OsStr::new(&count),
586+ OsStr::new(LOG_FORMAT),
587+ OsStr::new(&range),
588+ ],
589+ )
590+ .await?;
591+
592+ parse_log(&output.stdout)
593+ }
438594 }
439595
440596 /// What `cat-file --batch-check` said about one object.
@@ -941,9 +1097,15 @@ where
9411097 /// carries an answer. Named so the exception is legible at the call site.
9421098 const NO_MATCHES: i32 = 1;
9431099
1100+/// `git merge-base`'s exit status for two commits that share no ancestor — documented
1101+/// as an answer, with 128 reserved for real errors.
1102+const NO_COMMON_ANCESTOR: i32 = 1;
1103+
9441104 /// [`run`] for a command whose exit status is partly an answer.
9451105 ///
946/// Exists for `git grep` alone. Every other command here is asked about something
1106+/// Exists for `git grep` and `git merge-base` alone — grep's 1 is "matched nothing" and
1107+/// merge-base's 1 is "no common ancestor", both promised by git's manual. Every other
1108+/// command here is asked about something
9471109 /// `cat-file --batch-check` has already confirmed exists, which is what makes the
9481110 /// module's "a non-zero exit is always a fault" rule hold; grep is the one command
9491111 /// whose whole job is to find nothing sometimes.
@@ -1009,6 +1171,201 @@ where
10091171 Ok(output)
10101172 }
10111173
1174+/// Runs a git command and reads at most `max_bytes` of its output.
1175+///
1176+/// [`run`] collects everything git writes, which is right for a tree listing and wrong
1177+/// for a patch: a single commit can legitimately carry hundreds of megabytes of diff,
1178+/// and a page must not be able to pull that into memory. So this one reads through a
1179+/// pipe and stops, killing the process rather than draining it — an abandoned `git
1180+/// diff-tree` writing into a closed pipe is exactly what SIGPIPE is for.
1181+///
1182+/// Returns the bytes and whether the cap was hit. **The exit status is only checked
1183+/// when it was not**: a process killed part-way through has a status that says so, and
1184+/// treating that as a failure would turn every oversized diff into a 500.
1185+async fn run_capped<I, S>(
1186+ repo: &Path,
1187+ args: I,
1188+ max_bytes: u64,
1189+) -> Result<(Vec<u8>, bool), GitQueryError>
1190+where
1191+ I: IntoIterator<Item = S>,
1192+ S: AsRef<OsStr>,
1193+{
1194+ let mut command = git_command();
1195+ command
1196+ .arg("-C")
1197+ .arg(repo)
1198+ .args(args)
1199+ .stdin(Stdio::null())
1200+ .stdout(Stdio::piped())
1201+ .stderr(Stdio::piped())
1202+ .kill_on_drop(true);
1203+
1204+ let read = async {
1205+ let mut child = command
1206+ .spawn()
1207+ .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
1208+
1209+ let mut stdout = child.stdout.take().expect("stdout was piped");
1210+ let mut bytes = Vec::new();
1211+
1212+ // One byte past the cap, so hitting it exactly is not mistaken for exceeding
1213+ // it. The surplus byte is dropped below.
1214+ let limit = max_bytes.saturating_add(1);
1215+
1216+ (&mut stdout)
1217+ .take(limit)
1218+ .read_to_end(&mut bytes)
1219+ .await
1220+ .map_err(|error| GitQueryError::new(format!("could not read from git: {error}")))?;
1221+
1222+ if bytes.len() as u64 > max_bytes {
1223+ bytes.truncate(max_bytes as usize);
1224+ // Nothing waits for the exit status: git is still writing, and the whole
1225+ // point is not to read the rest. `kill_on_drop` reaps it.
1226+ let _ = child.start_kill();
1227+
1228+ return Ok((bytes, true));
1229+ }
1230+
1231+ // stdout is already drained, so this collects stderr and the status. stderr is
1232+ // git's own diagnostics — small by construction, and only read on failure.
1233+ let output = child
1234+ .wait_with_output()
1235+ .await
1236+ .map_err(|error| GitQueryError::new(format!("could not wait for git: {error}")))?;
1237+
1238+ if !output.status.success() {
1239+ return Err(GitQueryError::new(format!(
1240+ "git exited with {}: {}",
1241+ output.status,
1242+ String::from_utf8_lossy(&output.stderr).trim()
1243+ )));
1244+ }
1245+
1246+ Ok((bytes, false))
1247+ };
1248+
1249+ match tokio::time::timeout(GIT_TIMEOUT, read).await {
1250+ Ok(result) => result,
1251+ Err(_elapsed) => Err(GitQueryError::timed_out(GIT_TIMEOUT)),
1252+ }
1253+}
1254+
1255+/// What a log record carries: id, commit time, author name, subject.
1256+///
1257+/// Every separator is a NUL, and `-z` separates the records themselves. A commit
1258+/// message contains newlines as a matter of course and a name can contain almost
1259+/// anything, so splitting on lines or whitespace would misread real history rather than
1260+/// exotic history.
1261+const LOG_FORMAT: &str = "--format=%H%x00%ct%x00%an%x00%s";
1262+
1263+/// What a commit page needs, in one record: id, tree, parents, author, committer,
1264+/// subject, body.
1265+///
1266+/// NUL-separated for [`LOG_FORMAT`]'s reason, and the body is deliberately **last** —
1267+/// it is the one field that can contain anything at all, so parsing takes ten
1268+/// separators and treats whatever remains as the body rather than counting fields from
1269+/// both ends.
1270+///
1271+/// `%P` is the parents, space-separated: an empty string for a root commit, two ids for
1272+/// a merge.
1273+const COMMIT_FORMAT: &str =
1274+ "--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%s%x00%b";
1275+
1276+/// Parses the single record [`COMMIT_FORMAT`] produces.
1277+fn parse_commit(stdout: &[u8]) -> Result<CommitDetail, GitQueryError> {
1278+ // `splitn` rather than `split`: the body is the eleventh field and may contain
1279+ // anything, including — in a message written by a tool rather than a person — a NUL
1280+ // of its own. Everything after the tenth separator is the body.
1281+ let fields: Vec<&[u8]> = stdout.splitn(11, |byte| *byte == 0).collect();
1282+
1283+ let [
1284+ id,
1285+ tree,
1286+ parents,
1287+ author_name,
1288+ author_email,
1289+ authored_at,
1290+ committer_name,
1291+ committer_email,
1292+ committed_at,
1293+ summary,
1294+ body,
1295+ ] = fields[..]
1296+ else {
1297+ return Err(GitQueryError::new(
1298+ "git described a commit in a shape we do not understand",
1299+ ));
1300+ };
1301+
1302+ let text = |bytes: &[u8]| String::from_utf8_lossy(bytes).into_owned();
1303+ let object = |bytes: &[u8]| {
1304+ ObjectId::new(String::from_utf8_lossy(bytes).trim())
1305+ .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))
1306+ };
1307+ let seconds = |bytes: &[u8]| {
1308+ let value = String::from_utf8_lossy(bytes);
1309+ value.trim().parse::<i64>().map(unix_time).map_err(|_| {
1310+ GitQueryError::new(format!(
1311+ "git dated a commit as {:?}",
1312+ value.trim().to_owned()
1313+ ))
1314+ })
1315+ };
1316+
1317+ Ok(CommitDetail {
1318+ id: object(id)?,
1319+ tree: object(tree)?,
1320+ parents: String::from_utf8_lossy(parents)
1321+ .split_whitespace()
1322+ .map(|parent| {
1323+ ObjectId::new(parent).map_err(|error| {
1324+ GitQueryError::new(format!("git named a bad parent id: {error}"))
1325+ })
1326+ })
1327+ .collect::<Result<Vec<_>, _>>()?,
1328+ // `%s` is git's subject; trimmed to one line anyway, because that is git's
1329+ // invariant rather than something this parser should assume.
1330+ summary: text(summary).lines().next().unwrap_or_default().to_owned(),
1331+ // git ends the formatted record with a newline of its own, which lands on the
1332+ // body because the body is last. Trailing whitespace is not part of a message.
1333+ body: text(body).trim_end().to_owned(),
1334+ author_name: text(author_name),
1335+ author_email: text(author_email),
1336+ authored_at: seconds(authored_at)?,
1337+ committer_name: text(committer_name),
1338+ committer_email: text(committer_email),
1339+ committed_at: seconds(committed_at)?,
1340+ })
1341+}
1342+
1343+/// Splits `--numstat -p` output into its two halves.
1344+///
1345+/// git writes every `--numstat` line first and then the patch, so the boundary is the
1346+/// first `diff --git ` line — a marker no numstat line can produce, since one always
1347+/// begins with a count or the `-` that stands for a binary file. The blank line git
1348+/// puts between the two is not relied on.
1349+///
1350+/// A patch that is entirely absent — a commit that changed nothing, or a read cut short
1351+/// before the patch began — leaves the second half empty, which is a state the caller
1352+/// already has to handle.
1353+fn split_numstat(stdout: &[u8]) -> (&[u8], &[u8]) {
1354+ const MARKER: &[u8] = b"diff --git ";
1355+
1356+ if stdout.starts_with(MARKER) {
1357+ return (&[], stdout);
1358+ }
1359+
1360+ for (index, byte) in stdout.iter().enumerate() {
1361+ if *byte == b'\n' && stdout[index + 1..].starts_with(MARKER) {
1362+ return stdout.split_at(index + 1);
1363+ }
1364+ }
1365+
1366+ (stdout, &[])
1367+}
1368+
10121369 #[cfg(test)]
10131370 mod tests {
10141371 use std::collections::HashMap;
@@ -2405,4 +2762,335 @@ mod tests {
24052762 assert!(status.success());
24062763 (dir, repo)
24072764 }
2765+
2766+ // --- commits, diffs and comparisons ----------------------------------------
2767+
2768+ /// A repository with the shapes a diff parser has to survive: a rename, a binary
2769+ /// file, a file added, a file deleted — plus a second branch and an unrelated
2770+ /// history, which is what compare needs.
2771+ fn with_history() -> (TempDir, DiskGitQuery) {
2772+ let (dir, query) = empty();
2773+ let repo = query.repo_path(&handle(), &repo_name());
2774+ let work = dir.path().join("work");
2775+
2776+ std::fs::create_dir_all(work.join("src")).expect("create work tree");
2777+ git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
2778+
2779+ std::fs::write(work.join("src/a.txt"), b"aaa\nbbb\nccc\nddd\neee\n").expect("write");
2780+ std::fs::write(work.join("gone.txt"), b"going\n").expect("write");
2781+ std::fs::write(work.join("logo.bin"), BINARY).expect("write");
2782+ git(&work, FIRST_COMMIT, &["add", "-A"]);
2783+ git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
2784+
2785+ // A rename git will only find with `-M`: the content is 83% the same.
2786+ std::fs::rename(work.join("src/a.txt"), work.join("src/b.txt")).expect("rename");
2787+ std::fs::write(work.join("src/b.txt"), b"aaa\nbbb\nccc\nddd\neee\nfff\n").expect("write");
2788+ std::fs::remove_file(work.join("gone.txt")).expect("remove");
2789+ std::fs::write(work.join("logo.bin"), [0x00, 0x02, 0xff]).expect("write");
2790+ std::fs::write(work.join("new.txt"), b"new\n").expect("write");
2791+ git(&work, SECOND_COMMIT, &["add", "-A"]);
2792+ git(
2793+ &work,
2794+ SECOND_COMMIT,
2795+ &["commit", "--quiet", "-m", "second\n\nwhy it was done"],
2796+ );
2797+
2798+ // A branch ahead of main, for compare.
2799+ git(&work, THIRD_COMMIT, &["checkout", "--quiet", "-b", "next"]);
2800+ std::fs::write(work.join("new.txt"), b"new\nand more\n").expect("write");
2801+ git(&work, THIRD_COMMIT, &["add", "-A"]);
2802+ git(&work, THIRD_COMMIT, &["commit", "--quiet", "-m", "third"]);
2803+
2804+ // A history sharing no ancestor with either, which a compare page must be able
2805+ // to report rather than fail on.
2806+ git(
2807+ &work,
2808+ THIRD_COMMIT,
2809+ &["checkout", "--quiet", "--orphan", "unrelated"],
2810+ );
2811+ git(&work, THIRD_COMMIT, &["rm", "-rq", "--cached", "."]);
2812+ std::fs::write(work.join("z.txt"), b"z\n").expect("write");
2813+ git(&work, THIRD_COMMIT, &["add", "z.txt"]);
2814+ git(
2815+ &work,
2816+ THIRD_COMMIT,
2817+ &["commit", "--quiet", "-m", "unrelated"],
2818+ );
2819+
2820+ git(
2821+ &work,
2822+ THIRD_COMMIT,
2823+ &[
2824+ "push",
2825+ "--quiet",
2826+ repo.to_str().expect("utf-8 fixture path"),
2827+ "main",
2828+ "next",
2829+ "unrelated",
2830+ ],
2831+ );
2832+
2833+ (dir, query)
2834+ }
2835+
2836+ /// The commit a revision names, for a test that needs the id rather than the name.
2837+ async fn commit_id(query: &DiskGitQuery, name: &str) -> ObjectId {
2838+ query
2839+ .resolve(&handle(), &repo_name(), &rev(name))
2840+ .await
2841+ .expect("should resolve")
2842+ .expect("a commit")
2843+ }
2844+
2845+ #[tokio::test]
2846+ async fn a_commit_carries_its_message_its_people_and_its_parent() {
2847+ let (_dir, query) = with_history();
2848+
2849+ let commit = query
2850+ .commit(&handle(), &repo_name(), &rev("main"))
2851+ .await
2852+ .expect("should read")
2853+ .expect("a commit");
2854+
2855+ assert_eq!(commit.summary, "second");
2856+ // The body is everything after the blank line, and the trailing newline git
2857+ // adds to the record is not part of it.
2858+ assert_eq!(commit.body, "why it was done");
2859+ assert_eq!(commit.author_name, "Ada Lovelace");
2860+ assert_eq!(commit.author_email, "ada@example.com");
2861+ assert_eq!(commit.committed_at, unix_time(SECOND_COMMIT));
2862+ assert_eq!(commit.parents.len(), 1);
2863+ assert!(!commit.is_root());
2864+ assert!(!commit.has_distinct_committer());
2865+ }
2866+
2867+ #[tokio::test]
2868+ async fn a_first_commit_has_no_parent() {
2869+ let (_dir, query) = with_history();
2870+
2871+ let head = query
2872+ .commit(&handle(), &repo_name(), &rev("main"))
2873+ .await
2874+ .expect("should read")
2875+ .expect("a commit");
2876+ let parent = RefName::from_trusted(head.parents[0].as_str());
2877+
2878+ let root = query
2879+ .commit(&handle(), &repo_name(), &parent)
2880+ .await
2881+ .expect("should read")
2882+ .expect("a commit");
2883+
2884+ assert_eq!(root.summary, "first");
2885+ assert!(root.is_root());
2886+ }
2887+
2888+ #[tokio::test]
2889+ async fn an_abbreviated_id_names_the_same_commit_as_the_branch() {
2890+ // What a URL carries when someone clicks a shortened sha in the log.
2891+ let (_dir, query) = with_history();
2892+
2893+ let head = query
2894+ .commit(&handle(), &repo_name(), &rev("main"))
2895+ .await
2896+ .expect("should read")
2897+ .expect("a commit");
2898+
2899+ let short = query
2900+ .commit(
2901+ &handle(),
2902+ &repo_name(),
2903+ &RefName::from_trusted(head.id.short()),
2904+ )
2905+ .await
2906+ .expect("should read")
2907+ .expect("a commit");
2908+
2909+ assert_eq!(short.id, head.id);
2910+ }
2911+
2912+ #[tokio::test]
2913+ async fn a_revision_that_names_no_commit_has_none() {
2914+ let (_dir, query) = with_history();
2915+
2916+ assert!(
2917+ query
2918+ .commit(&handle(), &repo_name(), &rev("nope"))
2919+ .await
2920+ .expect("should read")
2921+ .is_none()
2922+ );
2923+ }
2924+
2925+ #[tokio::test]
2926+ async fn a_diff_carries_its_counts_before_its_patch() {
2927+ let (_dir, query) = with_history();
2928+ let head = query
2929+ .commit(&handle(), &repo_name(), &rev("main"))
2930+ .await
2931+ .expect("should read")
2932+ .expect("a commit");
2933+
2934+ let raw = query
2935+ .diff(
2936+ &handle(),
2937+ &repo_name(),
2938+ head.parents.first(),
2939+ &head.id,
2940+ 1024 * 1024,
2941+ )
2942+ .await
2943+ .expect("should diff");
2944+
2945+ let numstat = String::from_utf8_lossy(&raw.numstat);
2946+ let patch = String::from_utf8_lossy(&raw.patch);
2947+
2948+ assert!(!raw.truncated);
2949+ // Four files: the rename, the deletion, the binary, and the addition.
2950+ assert_eq!(numstat.lines().filter(|line| !line.is_empty()).count(), 4);
2951+ // The rename is one entry with both names, which is what `-M` buys.
2952+ assert!(
2953+ numstat.contains("src/{a.txt => b.txt}"),
2954+ "expected a rename in {numstat:?}"
2955+ );
2956+ // A binary file has no counts, which is git's `-` rather than a zero.
2957+ assert!(numstat.contains("-\t-\tlogo.bin"), "{numstat:?}");
2958+
2959+ // And the patch is the patch, starting where the counts stop.
2960+ assert!(
2961+ patch.starts_with("diff --git "),
2962+ "{:?}",
2963+ &patch[..60.min(patch.len())]
2964+ );
2965+ assert!(patch.contains("rename from src/a.txt"));
2966+ assert!(patch.contains("Binary files "));
2967+ }
2968+
2969+ #[tokio::test]
2970+ async fn a_root_commit_is_diffed_against_nothing_rather_than_skipped() {
2971+ let (_dir, query) = with_history();
2972+ let head = query
2973+ .commit(&handle(), &repo_name(), &rev("main"))
2974+ .await
2975+ .expect("should read")
2976+ .expect("a commit");
2977+ let root = query
2978+ .commit(
2979+ &handle(),
2980+ &repo_name(),
2981+ &RefName::from_trusted(head.parents[0].as_str()),
2982+ )
2983+ .await
2984+ .expect("should read")
2985+ .expect("a commit");
2986+
2987+ let raw = query
2988+ .diff(&handle(), &repo_name(), None, &root.id, 1024 * 1024)
2989+ .await
2990+ .expect("should diff");
2991+
2992+ // Without `--root` this would be empty, and the first commit in a repository
2993+ // would show as having changed nothing.
2994+ assert!(String::from_utf8_lossy(&raw.numstat).contains("src/a.txt"));
2995+ assert!(String::from_utf8_lossy(&raw.patch).contains("new file mode"));
2996+ }
2997+
2998+ #[tokio::test]
2999+ async fn a_diff_over_the_cap_is_cut_short_with_its_counts_intact() {
3000+ // The reason the counts are asked for in the same run: they are written first,
3001+ // so they survive a patch that does not fit.
3002+ let (_dir, query) = with_history();
3003+ let head = query
3004+ .commit(&handle(), &repo_name(), &rev("main"))
3005+ .await
3006+ .expect("should read")
3007+ .expect("a commit");
3008+
3009+ let raw = query
3010+ .diff(&handle(), &repo_name(), head.parents.first(), &head.id, 90)
3011+ .await
3012+ .expect("should diff");
3013+
3014+ assert!(raw.truncated);
3015+ assert_eq!(raw.numstat.len() + raw.patch.len(), 90);
3016+ assert!(String::from_utf8_lossy(&raw.numstat).contains("src/{a.txt => b.txt}"));
3017+ }
3018+
3019+ #[tokio::test]
3020+ async fn a_merge_base_is_the_point_two_branches_share() {
3021+ let (_dir, query) = with_history();
3022+ let main = commit_id(&query, "main").await;
3023+ let next = commit_id(&query, "next").await;
3024+
3025+ let base = query
3026+ .merge_base(&handle(), &repo_name(), &main, &next)
3027+ .await
3028+ .expect("should read")
3029+ .expect("a merge base");
3030+
3031+ // `next` branched off the tip of `main`, so that tip is the base.
3032+ assert_eq!(base, main);
3033+ }
3034+
3035+ #[tokio::test]
3036+ async fn two_histories_with_no_common_ancestor_have_no_merge_base() {
3037+ // git says this with exit 1 and no output — the one documented not-found exit
3038+ // this module tolerates. It must be an answer, not a 500.
3039+ let (_dir, query) = with_history();
3040+ let main = commit_id(&query, "main").await;
3041+ let unrelated = commit_id(&query, "unrelated").await;
3042+
3043+ assert!(
3044+ query
3045+ .merge_base(&handle(), &repo_name(), &main, &unrelated)
3046+ .await
3047+ .expect("should read")
3048+ .is_none()
3049+ );
3050+ }
3051+
3052+ #[tokio::test]
3053+ async fn a_range_lists_only_what_the_head_adds() {
3054+ let (_dir, query) = with_history();
3055+ let main = commit_id(&query, "main").await;
3056+ let next = commit_id(&query, "next").await;
3057+
3058+ let commits = query
3059+ .log_between(&handle(), &repo_name(), Some(&main), &next, 50)
3060+ .await
3061+ .expect("should read");
3062+
3063+ assert_eq!(
3064+ commits
3065+ .iter()
3066+ .map(|commit| commit.summary.as_str())
3067+ .collect::<Vec<_>>(),
3068+ vec!["third"]
3069+ );
3070+
3071+ // The other way round adds nothing: `main` is contained in `next`.
3072+ assert!(
3073+ query
3074+ .log_between(&handle(), &repo_name(), Some(&next), &main, 50)
3075+ .await
3076+ .expect("should read")
3077+ .is_empty()
3078+ );
3079+ }
3080+
3081+ #[test]
3082+ fn the_counts_and_the_patch_are_split_at_the_first_file_header() {
3083+ let (numstat, patch) = split_numstat(b"1\t1\ta.txt\n\ndiff --git a/a.txt b/a.txt\n@@\n");
3084+
3085+ assert_eq!(numstat, b"1\t1\ta.txt\n\n");
3086+ assert!(patch.starts_with(b"diff --git "));
3087+ }
3088+
3089+ #[test]
3090+ fn a_diff_with_nothing_in_it_splits_into_two_empties() {
3091+ let (numstat, patch) = split_numstat(b"");
3092+
3093+ assert!(numstat.is_empty());
3094+ assert!(patch.is_empty());
3095+ }
24083096 }
src/infrastructure/web/browse.rs+11 −5View file
@@ -46,6 +46,7 @@ use crate::{
4646 };
4747
4848 use super::{
49+ commit::commit_url,
4950 context::{current_actor, memberships, orgs, queries, repos, server_error},
5051 layout::wide,
5152 refs::{branches_url, tags_url},
@@ -286,7 +287,7 @@ async fn history(cx: &Cx, rev: Option<RefName>) -> Result {
286287 // current rather than guessing. Noted in `plans/current.md`.
287288 rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
288289 )
289 commit_log(commits: &log)
290+ commit_log(handle: handle, name: name, commits: &log)
290291 )
291292 }
292293 }
@@ -1044,8 +1045,12 @@ async fn source(text: &str, file_name: &str) -> Result {
10441045 }
10451046
10461047 /// The commit log — the most recent commits, newest first, and no paging in v1.
1048+///
1049+/// Shared with the compare page, which lists the commits a branch adds in exactly this
1050+/// shape: two lists of commits that read differently would be two designs for one
1051+/// thing.
10471052 #[component]
1048async fn commit_log(commits: &[CommitSummary]) -> Result {
1053+pub(super) async fn commit_log(handle: &str, name: &str, commits: &[CommitSummary]) -> Result {
10491054 view! {
10501055 if commits.is_empty() {
10511056 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
@@ -1057,9 +1062,10 @@ async fn commit_log(commits: &[CommitSummary]) -> Result {
10571062 <li class="px-4 py-3">
10581063 <div class="flex items-baseline justify-between gap-4">
10591064 <p class="text-sm font-medium">(&commit.summary)</p>
1060 <code class="shrink-0 font-mono text-xs text-muted-foreground">
1061 (commit.id.short())
1062 </code>
1065+ <a
1066+ href=(commit_url(handle, name, commit.id.as_str()))
1067+ class="shrink-0 font-mono text-xs text-muted-foreground hover:text-foreground"
1068+ >(commit.id.short())</a>
10631069 </div>
10641070 <p class="mt-1 text-xs text-muted-foreground">
10651071 (&commit.author_name)
src/infrastructure/web/commit.rs+825 −0View file
@@ -0,0 +1,825 @@
1+//! One commit, and the comparison of two revisions.
2+//!
3+//! Three routes sharing one renderer: `/commits/{sha}` shows what a commit changed,
4+//! `/compare` asks which two revisions, and `/compare/{base}...{head}` answers.
5+//!
6+//! The diff renderer is the reason they live together. A commit's diff and a
7+//! comparison's diff are the same thing — [`Diff`] — and drawing them twice would be
8+//! two places for the line numbering to be subtly wrong in.
9+//!
10+//! # The compare URL
11+//!
12+//! `{base}...{head}` in one path segment, each half percent-encoded exactly as the tree
13+//! pages encode a revision, so `feature/login` stays inside its half. The separator is
14+//! unambiguous because [`RefName`] refuses `..` outright — a revision cannot contain
15+//! two consecutive dots, so three of them can only be the separator. It is also the
16+//! spelling git itself uses for merge-base semantics, which is what the page does.
17+//!
18+//! The form cannot build that URL: a GET form submits query parameters, not path
19+//! segments, and Steid does not require JavaScript. So the form posts nowhere — it GETs
20+//! `/compare?base=…&head=…`, and that page redirects to the path form. The shareable
21+//! URL is therefore always the path one, which is what the branches page links to.
22+
23+use topcoat::{
24+ Result,
25+ context::Cx,
26+ icon::{icon, iconify::iconify_icon},
27+ router::{
28+ error::{RouterErrorExt, not_found, redirect},
29+ page, path_param, query_params,
30+ },
31+ view::{attributes, component, view},
32+};
33+
34+use crate::{
35+ application::{
36+ COMPARE_LOG_LIMIT, Compared, Comparison, Diff, DiffLine, Error, FileChange, FileDiff,
37+ FileStat, LineKind, MAX_FILE_DIFF_LINES, RefList, RepoView, compare_revisions, list_refs,
38+ view_commit,
39+ },
40+ components::{
41+ button::{ButtonSize, ButtonVariant, button_variants},
42+ input::input,
43+ label::label,
44+ },
45+ domain::{CommitDetail, RefName, RepoPath},
46+};
47+
48+use super::{
49+ browse::{ago, commit_log, encode, timestamp, tree_url},
50+ context::{current_actor, memberships, orgs, queries, repos, server_error},
51+ layout::wide,
52+ repo::{Tab, repo_for, repo_header},
53+};
54+
55+/// `{sha}` from the path: a full or abbreviated object id, or any other revision.
56+#[path_param]
57+struct Sha(str);
58+
59+/// `{spec}` from the path: `base...head`, each half already percent-decoded.
60+#[path_param]
61+struct Spec(str);
62+
63+/// What the compare form submits, before it is turned into a path.
64+#[query_params(error = bad_request)]
65+struct CompareQuery {
66+ base: Option<String>,
67+ head: Option<String>,
68+}
69+
70+// --- Routes -----------------------------------------------------------------------
71+
72+#[page("/{handle}/repos/{name}/commits/{sha}")]
73+async fn commit_page(cx: &Cx) -> Result {
74+ // A malformed revision is a page that does not exist, the same reasoning the tree
75+ // pages apply to theirs.
76+ let rev = RefName::new(path_param::<Sha>(cx)).map_err(|_| not_found())?;
77+
78+ view! { commit_view(rev: rev) }
79+}
80+
81+#[page("/{handle}/repos/{name}/compare")]
82+async fn compare_form_page(cx: &Cx) -> Result {
83+ let repo = repo_for(cx).await?;
84+ let submitted = query_params::<CompareQuery>(cx)?;
85+
86+ // The form's own submission, on its way to the URL it should have been able to
87+ // target directly. A 307 is right here: this is a GET, so preserving the method is
88+ // exactly what is wanted.
89+ if let (Some(base), Some(head)) = (&submitted.base, &submitted.head) {
90+ let (base, head) = (base.trim(), head.trim());
91+
92+ if !base.is_empty() && !head.is_empty() {
93+ return Err(redirect(&compare_url(
94+ repo.handle.as_str(),
95+ repo.name.as_str(),
96+ base,
97+ head,
98+ ))
99+ .into());
100+ }
101+ }
102+
103+ let refs = refs_for(cx, &repo).await?;
104+ let default = default_base(cx, &repo).await?;
105+
106+ view! {
107+ wide(
108+ repo_header(repo: &repo, rev: "", active: Tab::Code)
109+ compare_form(repo: &repo, base: default.as_str(), head: "", refs: &refs, error: "")
110+ )
111+ }
112+}
113+
114+#[page("/{handle}/repos/{name}/compare/{spec}")]
115+async fn compare_page(cx: &Cx) -> Result {
116+ let spec = path_param::<Spec>(cx);
117+
118+ // Three dots, not two: `RefName` refuses `..` in a revision, so the only way this
119+ // separator can appear is as the separator.
120+ let Some((base, head)) = spec.split_once("...") else {
121+ return Err(not_found().into());
122+ };
123+
124+ let (Ok(base), Ok(head)) = (RefName::new(base), RefName::new(head)) else {
125+ return Err(not_found().into());
126+ };
127+
128+ view! { comparison_view(base: base, head: head) }
129+}
130+
131+// --- The commit page --------------------------------------------------------------
132+
133+/// A component rather than a plain function because `view!` needs the request context
134+/// in scope — the same reason the browse pages are components.
135+#[component]
136+async fn commit_view(cx: &Cx, rev: RefName) -> Result {
137+ let repo = repo_for(cx).await?;
138+
139+ let loaded = view_commit(
140+ &repo.handle,
141+ &repo.name,
142+ &rev,
143+ &current_actor(cx).await?,
144+ &orgs(cx),
145+ &memberships(cx),
146+ &repos(cx),
147+ &queries(cx),
148+ )
149+ .await;
150+
151+ let page = match unwrap_page(loaded)? {
152+ Some(page) => page,
153+ None => {
154+ return view! {
155+ wide(
156+ repo_header(repo: &repo, rev: rev.as_str(), active: Tab::Commits)
157+ took_too_long()
158+ )
159+ };
160+ }
161+ };
162+
163+ let handle = repo.handle.as_str();
164+ let name = repo.name.as_str();
165+ // The commit's own id, not the revision the URL used: a branch name in the header's
166+ // links would send someone to a different commit tomorrow.
167+ let at = page.commit.id.as_str();
168+
169+ view! {
170+ wide(
171+ repo_header(repo: &repo, rev: at, active: Tab::Commits)
172+ commit_summary(handle: handle, name: name, commit: &page.commit)
173+ diff_view(handle: handle, name: name, rev: at, diff: &page.diff)
174+ )
175+ }
176+}
177+
178+/// The commit itself: what it says, who made it, and where it sits in the history.
179+#[component]
180+async fn commit_summary(handle: &str, name: &str, commit: &CommitDetail) -> Result {
181+ let sha = commit.id.as_str();
182+
183+ view! {
184+ <article class="mb-4 rounded-lg border border-border px-4 py-3">
185+ <h2 class="text-base font-medium">(&commit.summary)</h2>
186+
187+ if !commit.body.is_empty() {
188+ // Preformatted, because a commit body is written for a fixed-width
189+ // reader — lists, wrapped prose, pasted output — and reflowing it
190+ // rewrites what somebody wrote. It wraps rather than scrolls so a long
191+ // line does not widen the page.
192+ <pre class="mt-2 whitespace-pre-wrap font-mono text-xs leading-relaxed text-muted-foreground">(&commit.body)</pre>
193+ }
194+
195+ <div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t border-border pt-2.5 text-xs text-muted-foreground">
196+ <span>
197+ <span class="text-foreground">(&commit.author_name)</span>
198+ " authored "
199+ <span title=(timestamp(commit.authored_at))>(ago(commit.authored_at))</span>
200+ </span>
201+
202+ // Named only when it differs. On an ordinary commit the two are the
203+ // same person and saying it twice is noise.
204+ if commit.has_distinct_committer() {
205+ <span>
206+ <span class="text-foreground">(&commit.committer_name)</span>
207+ " committed "
208+ <span title=(timestamp(commit.committed_at))>(ago(commit.committed_at))</span>
209+ </span>
210+ }
211+
212+ <span class="ml-auto flex flex-wrap items-center gap-x-3 gap-y-1.5">
213+ match commit.parents.len() {
214+ 0 => <span>"root commit"</span>,
215+ _ => <span class="flex items-center gap-1.5">
216+ (if commit.parents.len() == 1 { "parent" } else { "parents" })
217+ for parent in &commit.parents {
218+ <a
219+ href=(commit_url(handle, name, parent.as_str()))
220+ class="font-mono hover:text-foreground"
221+ >(parent.short())</a>
222+ }
223+ </span>,
224+ }
225+
226+ <a
227+ href=(tree_url(handle, name, &RefName::from_trusted(sha), &RepoPath::root()))
228+ class="inline-flex items-center gap-1 hover:text-foreground"
229+ >
230+ icon(data: iconify_icon!("feather:folder"), attrs: attributes! {
231+ class="size-3.5"
232+ })
233+ "Browse files"
234+ </a>
235+
236+ // The full id, never the abbreviation: this is the page you copy a
237+ // sha from, and seven characters is not a thing you can paste into
238+ // `git show` with confidence on a large repository.
239+ <code class="font-mono text-foreground">(sha)</code>
240+ </span>
241+ </div>
242+ </article>
243+ }
244+}
245+
246+// --- The compare pages ------------------------------------------------------------
247+
248+#[component]
249+async fn comparison_view(cx: &Cx, base: RefName, head: RefName) -> Result {
250+ let repo = repo_for(cx).await?;
251+
252+ let loaded = compare_revisions(
253+ &repo.handle,
254+ &repo.name,
255+ &base,
256+ &head,
257+ &current_actor(cx).await?,
258+ &orgs(cx),
259+ &memberships(cx),
260+ &repos(cx),
261+ &queries(cx),
262+ )
263+ .await;
264+
265+ let handle = repo.handle.as_str();
266+ let name = repo.name.as_str();
267+
268+ let Some(compared) = unwrap_page(loaded)? else {
269+ return view! {
270+ wide(
271+ repo_header(repo: &repo, rev: "", active: Tab::Code)
272+ took_too_long()
273+ )
274+ };
275+ };
276+
277+ // Only the states that re-render the form need the ref list, and it costs a whole
278+ // `git` process — so it is fetched for those and not for a successful comparison,
279+ // which has a switcher of neither kind.
280+ let refs = match &compared {
281+ Compared::UnknownRef { .. } => refs_for(cx, &repo).await?,
282+ _ => RefList::default(),
283+ };
284+
285+ // Built before the view, not inside it: a `format!` in an argument position is a
286+ // temporary that the borrow it produces outlives.
287+ let unknown = match &compared {
288+ Compared::UnknownRef { rev } => {
289+ format!("There is no branch, tag or commit called {rev} in this repository.")
290+ }
291+ _ => String::new(),
292+ };
293+
294+ view! {
295+ wide(
296+ repo_header(repo: &repo, rev: "", active: Tab::Code)
297+
298+ match &compared {
299+ Compared::UnknownRef { .. } => compare_form(
300+ repo: &repo,
301+ base: base.as_str(),
302+ head: head.as_str(),
303+ refs: &refs,
304+ error: unknown.as_str(),
305+ ),
306+ Compared::Identical => {
307+ compare_bar(handle: handle, name: name, base: base.as_str(), head: head.as_str())
308+ <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
309+ "Nothing to compare. These two revisions are the same commit."
310+ </p>
311+ }
312+ Compared::Unrelated => {
313+ compare_bar(handle: handle, name: name, base: base.as_str(), head: head.as_str())
314+ <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
315+ "These two revisions share no history, so there is nothing to compare them against."
316+ </p>
317+ }
318+ Compared::Ready(comparison) => comparison_body(
319+ handle: handle,
320+ name: name,
321+ comparison: comparison.as_ref(),
322+ ),
323+ }
324+ )
325+ }
326+}
327+
328+/// A successful comparison: what it is, what it adds, and what that changes.
329+#[component]
330+async fn comparison_body(handle: &str, name: &str, comparison: &Comparison) -> Result {
331+ let behind = comparison.head_is_behind();
332+
333+ view! {
334+ compare_bar(
335+ handle: handle,
336+ name: name,
337+ base: comparison.base.as_str(),
338+ head: comparison.head.as_str(),
339+ )
340+
341+ if behind {
342+ // Not an error — the comparison is simply empty, and the one they meant is
343+ // one click away.
344+ <p class="mb-4 rounded-lg border border-border px-4 py-4 text-sm text-muted-foreground">
345+ <span class="font-mono text-foreground">(comparison.head.as_str())</span>
346+ " is already contained in "
347+ <span class="font-mono text-foreground">(comparison.base.as_str())</span>
348+ ", so it adds nothing. "
349+ <a
350+ href=(compare_url(handle, name, comparison.head.as_str(), comparison.base.as_str()))
351+ class="text-primary hover:underline"
352+ >"Compare them the other way round"</a>
353+ "?"
354+ </p>
355+ } else {
356+ <div class="mb-2 flex flex-wrap items-baseline gap-x-2 text-xs text-muted-foreground">
357+ <span class="font-mono text-foreground">
358+ (counted(comparison.total_commits, "commit", "commits"))
359+ </span>
360+ " to bring across, from merge base "
361+ <a
362+ href=(commit_url(handle, name, comparison.merge_base.as_str()))
363+ class="font-mono hover:text-foreground"
364+ >(comparison.merge_base.short())</a>
365+ </div>
366+
367+ commit_log(handle: handle, name: name, commits: &comparison.commits)
368+
369+ if comparison.truncated() {
370+ <p class="mt-2 text-xs text-muted-foreground">
371+ "Showing the newest " (COMPARE_LOG_LIMIT.to_string()) " commits of "
372+ (comparison.total_commits.to_string()) "."
373+ </p>
374+ }
375+
376+ <div class="mt-4">
377+ diff_view(
378+ handle: handle,
379+ name: name,
380+ rev: comparison.head_id.as_str(),
381+ diff: &comparison.diff,
382+ )
383+ </div>
384+ }
385+ }
386+}
387+
388+/// The two revisions, restated, with the way to swap them.
389+#[component]
390+async fn compare_bar(handle: &str, name: &str, base: &str, head: &str) -> Result {
391+ view! {
392+ <div class="mb-3 flex flex-wrap items-center gap-2 text-sm">
393+ <span class="rounded-md border border-border px-2 py-0.5 font-mono text-xs">(base)</span>
394+ <span class="text-muted-foreground">"←"</span>
395+ <span class="rounded-md border border-border px-2 py-0.5 font-mono text-xs">(head)</span>
396+ <a
397+ href=(compare_url(handle, name, head, base))
398+ class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
399+ >
400+ icon(data: iconify_icon!("feather:repeat"), attrs: attributes! { class="size-3.5" })
401+ "Swap"
402+ </a>
403+ <a
404+ href=(format!("/{handle}/repos/{name}/compare"))
405+ class="ml-auto text-xs text-muted-foreground hover:text-foreground"
406+ >"Compare something else"</a>
407+ </div>
408+ }
409+}
410+
411+/// Which two revisions to compare.
412+///
413+/// Two plain text inputs with a `<datalist>` rather than two `<select>`s: a revision
414+/// need not be a branch or a tag — a sha is a perfectly good answer, and a select would
415+/// make it unsayable. The datalist gives the common case autocompletion and leaves the
416+/// rest typeable.
417+#[component]
418+async fn compare_form(
419+ repo: &RepoView,
420+ base: &str,
421+ head: &str,
422+ refs: &RefList,
423+ error: &str,
424+) -> Result {
425+ let handle = repo.handle.as_str();
426+ let name = repo.name.as_str();
427+ let names: Vec<&str> = refs
428+ .branches
429+ .iter()
430+ .chain(&refs.tags)
431+ .map(RefName::as_str)
432+ .collect();
433+
434+ view! {
435+ <form method="get" action=(format!("/{handle}/repos/{name}/compare")) class="max-w-2xl">
436+ <h2 class="text-sm font-medium">"Compare revisions"</h2>
437+ <p class="mt-1 text-xs text-muted-foreground">
438+ "What does the second revision add to the first? A branch, a tag or a commit id."
439+ </p>
440+
441+ if !error.is_empty() {
442+ <p class="mt-3 rounded-lg border border-destructive px-3 py-2 text-xs text-destructive">
443+ (error)
444+ </p>
445+ }
446+
447+ <div class="mt-4 flex flex-wrap items-end gap-3">
448+ <div class="flex min-w-48 flex-1 flex-col gap-1.5">
449+ label(attrs: attributes! { for="base" class="text-xs" }, "Base")
450+ input(attrs: attributes! {
451+ id="base" name="base" list="compare-refs" required=(true)
452+ autocomplete="off" spellcheck="false" class="font-mono"
453+ value=(base) placeholder="main"
454+ })
455+ </div>
456+
457+ <span class="pb-2.5 font-mono text-sm text-muted-foreground">"..."</span>
458+
459+ <div class="flex min-w-48 flex-1 flex-col gap-1.5">
460+ label(attrs: attributes! { for="head" class="text-xs" }, "Compare")
461+ input(attrs: attributes! {
462+ id="head" name="head" list="compare-refs" required=(true)
463+ autocomplete="off" spellcheck="false" class="font-mono"
464+ value=(head) placeholder="a branch, tag or sha"
465+ })
466+ </div>
467+
468+ <button
469+ type="submit"
470+ class=(button_variants(ButtonVariant::Primary, ButtonSize::Md))
471+ >"Compare"</button>
472+ </div>
473+
474+ <datalist id="compare-refs">
475+ for git_ref in &names {
476+ <option value=(*git_ref)></option>
477+ }
478+ </datalist>
479+ </form>
480+ }
481+}
482+
483+// --- The diff ---------------------------------------------------------------------
484+
485+/// A whole diff: the summary line, then one panel per file.
486+#[component]
487+async fn diff_view(handle: &str, name: &str, rev: &str, diff: &Diff) -> Result {
488+ view! {
489+ if diff.is_empty() {
490+ <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
491+ "This commit changed no files."
492+ </p>
493+ } else {
494+ diff_stats(diff: diff)
495+
496+ if diff.truncated {
497+ // The counts are still real — they come from git's own numstat, which
498+ // is written before the patch and therefore survives the cap. Only the
499+ // lines are missing.
500+ <p class="mb-2 rounded-lg border border-border px-4 py-3 text-xs text-muted-foreground">
501+ "This diff is too large to display. Every changed file is listed below with its counts; "
502+ "clone the repository or run "
503+ <code class="font-mono text-foreground">"git show"</code>
504+ " to read the patch."
505+ </p>
506+
507+ <ul class="divide-y divide-border rounded-lg border border-border">
508+ for file in &diff.stats {
509+ <li class="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2">
510+ stat_row(file: file)
511+ <span class="text-xs text-muted-foreground">"Diff too large to display"</span>
512+ </li>
513+ }
514+ </ul>
515+ } else {
516+ for file in &diff.files {
517+ file_diff(handle: handle, name: name, rev: rev, file: file)
518+ }
519+ }
520+ }
521+ }
522+}
523+
524+/// `N files changed · +a −b`.
525+///
526+/// The two numbers are the only place on the page the success and destructive tokens
527+/// appear as text. That is deliberate: they are the summary of the whole commit, and
528+/// colouring anything else the same way would dilute them.
529+#[component]
530+async fn diff_stats(diff: &Diff) -> Result {
531+ view! {
532+ <p class="mb-2 flex flex-wrap items-center gap-x-3 text-xs text-muted-foreground">
533+ <span>(counted(diff.files_changed(), "file changed", "files changed"))</span>
534+ <span class="font-mono">
535+ <span class="text-success">"+" (diff.added().to_string())</span>
536+ " "
537+ <span class="text-destructive">"−" (diff.removed().to_string())</span>
538+ </span>
539+ </p>
540+ }
541+}
542+
543+/// One file's path and counts, as the truncated listing shows it.
544+#[component]
545+async fn stat_row(file: &FileStat) -> Result {
546+ view! {
547+ <span class="min-w-0 truncate font-mono text-xs">
548+ match &file.old_path {
549+ Some(old) => <span class="text-muted-foreground">(old) " → "</span>,
550+ None => "",
551+ }
552+ (&file.path)
553+ </span>
554+ <span class="ml-auto shrink-0 font-mono text-xs">
555+ match (file.added, file.removed) {
556+ (Some(added), Some(removed)) => {
557+ <span class="text-success">"+" (added.to_string())</span>
558+ " "
559+ <span class="text-destructive">"−" (removed.to_string())</span>
560+ }
561+ _ => <span class="text-muted-foreground">"binary"</span>,
562+ }
563+ </span>
564+ }
565+}
566+
567+/// One file: a header that stays put, then the lines.
568+///
569+/// The header is `sticky`, which is why this panel is not `overflow-hidden` — a hidden
570+/// overflow makes an ancestor a scroll container and sticky then sticks to a box that
571+/// never scrolls, which looks exactly like sticky being broken. The corners are rounded
572+/// on the children instead.
573+#[component]
574+async fn file_diff(handle: &str, name: &str, rev: &str, file: &FileDiff) -> Result {
575+ let blob = tree_url(
576+ handle,
577+ name,
578+ &RefName::from_trusted(rev),
579+ &RepoPath::from_trusted(file.path.as_str()),
580+ );
581+
582+ view! {
583+ <section class="mb-4 rounded-lg border border-border">
584+ <div class="sticky top-0 z-10 flex flex-wrap items-center gap-x-3 gap-y-1 rounded-t-lg border-b border-border bg-surface px-4 py-2">
585+ <span class="min-w-0 truncate font-mono text-xs">
586+ match &file.old_path {
587+ Some(old) => <span class="text-muted-foreground">(old) " → "</span>,
588+ None => "",
589+ }
590+ (&file.path)
591+ </span>
592+
593+ <span class="ml-auto flex shrink-0 items-center gap-3 text-xs">
594+ if file.binary {
595+ <span class="font-mono text-muted-foreground">"binary"</span>
596+ } else {
597+ <span class="font-mono">
598+ <span class="text-success">"+" (file.added.to_string())</span>
599+ " "
600+ <span class="text-destructive">"−" (file.removed.to_string())</span>
601+ </span>
602+ }
603+ if file.change != FileChange::Deleted {
604+ <a href=(&blob) class="text-muted-foreground hover:text-foreground">"View file"</a>
605+ }
606+ </span>
607+ </div>
608+
609+ if file.binary {
610+ <p class="px-4 py-5 text-center text-sm text-muted-foreground">
611+ "Binary file changed"
612+ </p>
613+ } else if file.rows.is_empty() {
614+ // A mode change, or a rename with no edit: git wrote a header and no
615+ // hunks, and saying so is better than an empty panel.
616+ <p class="px-4 py-5 text-center text-sm text-muted-foreground">
617+ "No line changes."
618+ </p>
619+ } else {
620+ <div class="overflow-x-auto rounded-b-lg">
621+ <table class="w-full border-collapse font-mono text-xs leading-relaxed">
622+ <tbody>
623+ for row in &file.rows {
624+ diff_row(row: row)
625+ }
626+ </tbody>
627+ </table>
628+ </div>
629+
630+ if file.truncated() {
631+ <p class="border-t border-border px-4 py-2.5 text-xs text-muted-foreground">
632+ "Showing the first " (MAX_FILE_DIFF_LINES.to_string()) " lines of "
633+ (file.total_rows.to_string()) ". "
634+ <a href=(&blob) class="text-primary hover:underline">"View the whole file"</a>
635+ " at this revision."
636+ </p>
637+ }
638+ }
639+ </section>
640+ }
641+}
642+
643+/// One line of a diff, as one table row.
644+///
645+/// A row per line, rather than a `<pre>` with a gutter, so the two line numbers stay
646+/// aligned with the line they belong to when the content scrolls sideways — the same
647+/// reasoning the blob view uses for its single gutter.
648+///
649+/// The tints are classes defined in `styles.css`, not Tailwind utilities: they are a
650+/// token at low alpha, which is a colour Tailwind has no utility for and which must not
651+/// be written out as a literal.
652+#[component]
653+async fn diff_row(row: &DiffLine) -> Result {
654+ let (line_class, gutter_class, marker) = match row.kind {
655+ LineKind::Added => ("diff-line-add", "diff-gutter-add", "+"),
656+ LineKind::Removed => ("diff-line-del", "diff-gutter-del", "−"),
657+ LineKind::Hunk => ("diff-line-hunk", "", ""),
658+ LineKind::Note => ("", "", ""),
659+ LineKind::Context => ("", "", " "),
660+ };
661+ let number = |value: Option<u32>| value.map(|value| value.to_string()).unwrap_or_default();
662+
663+ view! {
664+ <tr class=(line_class)>
665+ <td class=(format!(
666+ "w-px select-none px-2 text-right align-top text-muted-foreground {gutter_class}"
667+ ))>(number(row.old))</td>
668+ <td class=(format!(
669+ "w-px select-none border-r border-border px-2 text-right align-top text-muted-foreground {gutter_class}"
670+ ))>(number(row.new))</td>
671+ <td class="whitespace-pre px-3 align-top">
672+ match row.kind {
673+ // The hunk header and the "no newline" note are git's words about
674+ // the file, not lines of it, so they are muted and unmarked.
675+ LineKind::Hunk | LineKind::Note => <span class="text-muted-foreground">(&row.text)</span>,
676+ _ => {
677+ <span class="select-none text-muted-foreground">(marker)</span>
678+ (if row.text.is_empty() { " " } else { row.text.as_str() })
679+ }
680+ }
681+ </td>
682+ </tr>
683+ }
684+}
685+
686+// --- Shared bits ------------------------------------------------------------------
687+
688+/// What the page says when git ran out of time.
689+///
690+/// A designed state rather than a 500: a timeout is this instance declining to spend
691+/// more of itself on one request, which is a thing to say plainly and to offer a retry
692+/// for — not a fault the visitor caused or can report.
693+#[component]
694+async fn took_too_long() -> Result {
695+ view! {
696+ <div class="rounded-lg border border-border px-4 py-10 text-center">
697+ <p class="text-sm">"This took too long to read."</p>
698+ <p class="mt-1.5 text-xs text-muted-foreground">
699+ "The repository is large enough that building this diff ran past the time one request is given. Reloading may work; cloning the repository certainly will."
700+ </p>
701+ </div>
702+ }
703+}
704+
705+/// Turns a use case's answer into either a page or a rendered state.
706+///
707+/// `Ok(None)` from the use case is a 404 — invisible and absent are one answer, as
708+/// everywhere else. A **timeout** is the one failure that is not a 500: it comes back
709+/// as `Ok(None)` here so the caller renders [`took_too_long`] instead.
710+fn unwrap_page<T>(loaded: crate::application::Result<Option<T>>) -> Result<Option<T>> {
711+ match loaded {
712+ Ok(Some(page)) => Ok(Some(page)),
713+ Ok(None) => Err(not_found().into()),
714+ Err(Error::GitQuery(error)) if error.is_timeout() => {
715+ eprintln!("steid: {error}");
716+ Ok(None)
717+ }
718+ Err(other) => Err(server_error(std::io::Error::other(other.to_string()))),
719+ }
720+}
721+
722+/// The branches and tags of a repository the viewer can already see, or 404.
723+///
724+/// One `git` process. Called only by the states that draw the form's datalist.
725+async fn refs_for(cx: &Cx, repo: &RepoView) -> Result<RefList> {
726+ Ok(list_refs(
727+ &repo.handle,
728+ &repo.name,
729+ &current_actor(cx).await?,
730+ &orgs(cx),
731+ &memberships(cx),
732+ &repos(cx),
733+ &queries(cx),
734+ )
735+ .await
736+ .map_err(server_error)?
737+ .ok_or_not_found()?)
738+}
739+
740+/// What the form's base field opens with: the repository's default branch.
741+///
742+/// Comparing against anything else is the unusual case, and an empty field would make
743+/// the common one typing. One `git` process; empty for a repository with no commits,
744+/// where there is nothing to prefill with.
745+async fn default_base(cx: &Cx, repo: &RepoView) -> Result<String> {
746+ use crate::application::port::GitQuery;
747+
748+ Ok(queries(cx)
749+ .default_branch(&repo.handle, &repo.name)
750+ .await
751+ .map_err(server_error)?
752+ .map(|branch| branch.as_str().to_owned())
753+ .unwrap_or_default())
754+}
755+
756+/// A count and the thing it counts, pluralised.
757+fn counted(count: usize, one: &str, many: &str) -> String {
758+ format!("{count} {}", if count == 1 { one } else { many })
759+}
760+
761+// --- URLs -------------------------------------------------------------------------
762+
763+/// The page for one commit.
764+///
765+/// Takes a `&str` rather than an [`ObjectId`] because the sha in a URL may equally be a
766+/// branch name someone typed — the page resolves whatever it is given.
767+pub(super) fn commit_url(handle: &str, name: &str, sha: &str) -> String {
768+ format!("/{handle}/repos/{name}/commits/{}", encode(sha, false))
769+}
770+
771+/// The comparison of two revisions.
772+///
773+/// Each half is encoded on its own, so a slash inside a branch name cannot be mistaken
774+/// for a path separator and the `...` between them is unambiguous.
775+pub(super) fn compare_url(handle: &str, name: &str, base: &str, head: &str) -> String {
776+ format!(
777+ "/{handle}/repos/{name}/compare/{}...{}",
778+ encode(base, false),
779+ encode(head, false)
780+ )
781+}
782+
783+#[cfg(test)]
784+mod tests {
785+ use super::*;
786+
787+ #[test]
788+ fn a_commit_url_carries_the_whole_sha() {
789+ assert_eq!(
790+ commit_url("ada", "steid", "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"),
791+ "/ada/repos/steid/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
792+ );
793+ }
794+
795+ #[test]
796+ fn a_compare_url_encodes_each_half_on_its_own() {
797+ // A slash inside a branch name must not become a path separator, and the `...`
798+ // between the halves must survive as the separator it is.
799+ assert_eq!(
800+ compare_url("ada", "steid", "main", "feature/login"),
801+ "/ada/repos/steid/compare/main...feature%2Flogin"
802+ );
803+ }
804+
805+ #[test]
806+ fn a_compare_url_round_trips_through_its_separator() {
807+ // What `compare_page` does with the segment it is handed, in reverse.
808+ let url = compare_url("ada", "steid", "v1.0", "v2.0");
809+ let spec = url.rsplit('/').next().expect("a last segment");
810+
811+ assert_eq!(spec.split_once("..."), Some(("v1.0", "v2.0")));
812+ }
813+
814+ #[test]
815+ fn counts_read_as_english() {
816+ assert_eq!(
817+ counted(1, "file changed", "files changed"),
818+ "1 file changed"
819+ );
820+ assert_eq!(
821+ counted(3, "file changed", "files changed"),
822+ "3 files changed"
823+ );
824+ }
825+}
src/infrastructure/web/mod.rs+1 −0View file
@@ -3,6 +3,7 @@
33 pub mod api;
44 pub mod archive;
55 pub mod browse;
6+pub mod commit;
67 pub mod context;
78 pub mod git;
89 pub mod health;
src/infrastructure/web/repo.rs+9 −7View file
@@ -45,6 +45,7 @@ use super::{
4545 browse::{
4646 ago, blob, browsed_at, browsed_rev, directory, empty_repo, log_url, repo_toolbar, tree_url,
4747 },
48+ commit::commit_url,
4849 context::{
4950 current_actor, location, memberships, orgs, public_origin, queries, repos, server_error,
5051 storage,
@@ -228,7 +229,6 @@ async fn repo_page(cx: &Cx) -> Result {
228229 Some(commit) => latest_commit(
229230 handle: repo.handle.as_str(),
230231 name: repo.name.as_str(),
231 rev: rev.as_str(),
232232 commit: commit,
233233 ),
234234 None => "",
@@ -401,17 +401,19 @@ pub(super) async fn repo_header(
401401 /// the primary colour, which is the only mark on the row: a commit is the thing that
402402 /// changed most recently, and the eye should land on it.
403403 #[component]
404async fn latest_commit(handle: &str, name: &str, rev: &str, commit: &CommitSummary) -> Result {
404+async fn latest_commit(handle: &str, name: &str, commit: &CommitSummary) -> Result {
405405 view! {
406406 <div class="mb-2 flex items-center gap-2.5 rounded-lg border border-border px-4 py-2 text-sm">
407407 <span class="size-1.5 shrink-0 rounded-full bg-primary"></span>
408408 <span class="truncate">(&commit.summary)</span>
409409 <span class="ml-auto flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
410 // The log is where the rest of the history is, and the only place this
411 // commit can currently be seen in context — there is no commit page yet.
412 <a href=(log_url(handle, name, rev)) class="font-mono hover:text-foreground">
413 (commit.id.short())
414 </a>
410+ // Straight to the commit rather than to the log: the commit page is
411+ // where what it changed lives, and the log is one click further on the
412+ // Commits tab.
413+ <a
414+ href=(commit_url(handle, name, commit.id.as_str()))
415+ class="font-mono hover:text-foreground"
416+ >(commit.id.short())</a>
415417 <span>(ago(commit.committed_at))</span>
416418 </span>
417419 </div>
styles.css+39 −0View file
@@ -227,3 +227,42 @@
227227 .hl-invalid {
228228 color: var(--destructive);
229229 }
230+
231+/* Unified diff rows.
232+
233+ Plain CSS rather than Tailwind utilities because these are theme tokens at
234+ low alpha, and `color-mix` against a custom property is not something a
235+ utility class can express. The alpha is the point: a tint has to sit under
236+ text that stays readable in both color schemes, which a solid `--success`
237+ or `--destructive` would not.
238+
239+ Never a raw color here — every value is a token mixed with transparency, so
240+ the diff follows a palette change like everything else. The gutters take a
241+ stronger mix than the line so the numbers stay legible against it. */
242+.diff-line-add {
243+ background-color: color-mix(in oklab, var(--success) 12%, transparent);
244+}
245+
246+.diff-line-del {
247+ background-color: color-mix(in oklab, var(--destructive) 12%, transparent);
248+}
249+
250+.diff-gutter-add {
251+ background-color: color-mix(in oklab, var(--success) 20%, transparent);
252+}
253+
254+.diff-gutter-del {
255+ background-color: color-mix(in oklab, var(--destructive) 20%, transparent);
256+}
257+
258+/* A hunk header separates two parts of a file, so it reads as a rule rather
259+ than as a change: no tint, a hairline above it, and muted text. */
260+.diff-line-hunk td {
261+ border-top: 1px solid var(--border);
262+ padding-top: 0.25rem;
263+ padding-bottom: 0.25rem;
264+}
265+
266+.diff-line-hunk:first-child td {
267+ border-top: 0;
268+}