steid

@jamesgill /

feat: the facts a repository page states about itself

The landing page is about to grow an About sidebar, which needs numbers the
browse port cannot answer: how many commits there are, and what the newest tag
is. Both arrive as `GitQuery` methods rather than as a page asking git itself,
so a future `/api` and the page cannot disagree.

`count_commits` resolves before counting, which costs a second process. That is
deliberate: `rev-list` is fatal on an empty repository and on a branch that is
not there, and this module's rule is that a non-zero exit from git is always a
real fault, never a 404. `--ignore-missing` was tried and does not cover a bad
revision *name*, only a bad object id.

`latest_tag` sorts on git's `creatordate` — the tag's own date when it is
annotated, the commit's when it is lightweight — because version numbers stop
sorting chronologically as soon as there is a v1.10 beside a v1.9.

`repo_summary` composes five git calls concurrently and lives in the
application layer rather than in a component: `architecture.md` lets a
straightforward read query directly, and five questions plus a licence rule is
not one. Licence detection is filename-first over the root listing the page
already has, then a phrase match over the first kilobyte — a kilobyte because
BSD is told from BSD by its third clause, which sits ~900 bytes in. A file it
cannot name stays unnamed and is linked to instead; guessing at someone's legal
terms is worse than saying nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqTELeRD57xF54euni5VuH
JamesPatrickGill authored 16 hours agoparent31a6a7eBrowse filesdd5b6008e4afeaec0337508e6517ad7628380433

8 files changed+881 −6

src/application/browse.rs+11 −2View file
@@ -217,9 +217,18 @@ pub async fn list_refs(
217217 return Ok(None);
218218 }
219219
220+ Ok(Some(organise_refs(queries.list_refs(handle, name).await?)))
221+}
222+
223+/// Splits a raw ref list into branches and tags, each in the order a switcher shows.
224+///
225+/// Separate from [`list_refs`] because [`repo_summary`](super::summary::repo_summary)
226+/// asks the port itself — it wants the counts *and* the switcher's list from one `git`
227+/// process — and two places deciding the order is two places to change it.
228+pub fn organise_refs(refs: Vec<crate::domain::GitRef>) -> RefList {
220229 let mut list = RefList::default();
221230
222 for git_ref in queries.list_refs(handle, name).await? {
231+ for git_ref in refs {
223232 match git_ref.kind {
224233 RefKind::Branch => list.branches.push(git_ref.name),
225234 RefKind::Tag => list.tags.push(git_ref.name),
@@ -235,7 +244,7 @@ pub async fn list_refs(
235244 });
236245 }
237246
238 Ok(Some(list))
247+ list
239248 }
240249
241250 /// A file as it is served rather than rendered.
src/application/mod.rs+2 −0View file
@@ -15,6 +15,7 @@ pub mod port;
1515 pub mod profile;
1616 pub mod repo;
1717 pub mod session;
18+pub mod summary;
1819 pub mod token;
1920
2021 pub use browse::{
@@ -33,6 +34,7 @@ pub use repo::{
3334 view_repo,
3435 };
3536 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
37+pub use summary::{Licence, RepoFacts, repo_summary};
3638 pub use token::{
3739 IssuedToken, TokenSummary, authenticate_token, issue_token, list_tokens, revoke_token,
3840 };
src/application/port.rs+30 −1View file
@@ -8,7 +8,8 @@ use std::{path::PathBuf, pin::Pin};
88 use tokio::io::AsyncRead;
99
1010 use crate::domain::{
11 CommitSummary, GitRef, ObjectId, OrgName, PasswordHash, RefName, RepoName, RepoPath, TreeEntry,
11+ CommitSummary, GitRef, ObjectId, OrgName, PasswordHash, RefName, RepoName, RepoPath,
12+ TagSummary, TreeEntry,
1213 };
1314
1415 /// Hashes and verifies passwords.
@@ -359,6 +360,34 @@ pub trait GitQuery: Send + Sync {
359360 handle: &OrgName,
360361 name: &RepoName,
361362 ) -> impl Future<Output = Result<Vec<GitRef>, GitQueryError>> + Send;
363+
364+ /// How many commits are reachable from a revision.
365+ ///
366+ /// The number a repository page states about itself. A revision that resolves to
367+ /// nothing — an empty repository, a branch that is not there — counts `0` rather
368+ /// than failing, matching [`log`](Self::log): a page asking how big a repository is
369+ /// does not want an error for the answer "nothing yet".
370+ fn count_commits(
371+ &self,
372+ handle: &OrgName,
373+ name: &RepoName,
374+ rev: &RefName,
375+ ) -> impl Future<Output = Result<u64, GitQueryError>> + Send;
376+
377+ /// The most recently created tag, or `None` if the repository has none.
378+ ///
379+ /// "Most recent" is by creation date, not by name: version numbers do not sort
380+ /// chronologically once there is a `v1.10` beside a `v1.9`, and a repository's own
381+ /// history is the only ordering that is always right.
382+ ///
383+ /// One `git` process, the same cost as [`list_refs`](Self::list_refs). Asked
384+ /// separately rather than derived from that list because the ordering needs git's
385+ /// `creatordate`, which the ref list deliberately does not carry.
386+ fn latest_tag(
387+ &self,
388+ handle: &OrgName,
389+ name: &RepoName,
390+ ) -> impl Future<Output = Result<Option<TagSummary>, GitQueryError>> + Send;
362391 }
363392
364393 /// A repository could not be read.
src/application/summary.rs+538 −0View file
@@ -0,0 +1,538 @@
1+//! The facts a repository's landing page states about itself.
2+//!
3+//! A read that composes several git calls, given a home in the application layer
4+//! rather than left in a component. `architecture.md` allows a straightforward read to
5+//! query directly; this is not one — it is five questions whose answers are gathered
6+//! concurrently and whose licence detection is a rule rather than a lookup, and a page
7+//! is the wrong place for either.
8+//!
9+//! Authorization is not re-implemented: like everything in
10+//! [`browse`](super::browse), it goes through
11+//! [`view_repo`](super::repo::view_repo), so an invisible repository has no facts.
12+
13+use crate::domain::{
14+ Actor, CommitSummary, EntryKind, OrgName, RefName, RepoName, RepoPath, TagSummary, TreeEntry,
15+ repository::{MembershipRepository, OrgRepository, RepoRepository},
16+};
17+
18+use super::{
19+ browse::{MAX_BLOB_BYTES, RefList, organise_refs},
20+ error::Result,
21+ port::GitQuery,
22+ repo::view_repo,
23+};
24+
25+/// What the About sidebar states about a repository.
26+#[derive(Debug, Clone, PartialEq, Eq)]
27+pub struct RepoFacts {
28+ /// The most recent commit on the revision being shown, for the commit bar.
29+ pub latest_commit: Option<CommitSummary>,
30+ pub commits: u64,
31+ /// Carried whole rather than as two counts, because the page that wants the counts
32+ /// also draws the revision switcher — and asking git for the ref list twice on one
33+ /// page would be a whole process spent on a number it already had.
34+ pub refs: RefList,
35+ pub latest_tag: Option<TagSummary>,
36+ pub licence: Option<Licence>,
37+}
38+
39+/// The licence a repository appears to be under.
40+///
41+/// `name` is `None` when a licence file is there but its text names nothing
42+/// recognisable. That is deliberately not a guess: the page says "Licence" and links to
43+/// the file, which is honest, where naming the wrong licence is a claim about someone's
44+/// legal terms.
45+#[derive(Debug, Clone, PartialEq, Eq)]
46+pub struct Licence {
47+ /// The file it was found in, so the page can link to it.
48+ pub path: RepoPath,
49+ pub name: Option<&'static str>,
50+}
51+
52+/// Gathers everything the repository page states about itself.
53+///
54+/// Takes the revision and the root listing the page has already read, rather than
55+/// reading them again: the caller has just browsed the root to render the file list,
56+/// and a second `list_tree` would be a `git` process spent on bytes already in hand.
57+///
58+/// The five git calls run concurrently, so the page waits for the slowest rather than
59+/// for the sum. They are still five processes — see this step's note in
60+/// `plans/progress.md` for the count and
61+/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md) for the bill
62+/// that is eventually coming due.
63+///
64+/// `Ok(None)` on the same terms as [`browse_repo`](super::browse::browse_repo):
65+/// invisible and absent are one answer.
66+#[allow(clippy::too_many_arguments)]
67+pub async fn repo_summary(
68+ handle: &OrgName,
69+ name: &RepoName,
70+ rev: &RefName,
71+ root: &[TreeEntry],
72+ actor: &Actor,
73+ orgs: &impl OrgRepository,
74+ memberships: &impl MembershipRepository,
75+ repos: &impl RepoRepository,
76+ queries: &impl GitQuery,
77+) -> Result<Option<RepoFacts>> {
78+ if view_repo(handle, name, actor, orgs, memberships, repos)
79+ .await?
80+ .is_none()
81+ {
82+ return Ok(None);
83+ }
84+
85+ let licence_path = licence_file(root);
86+
87+ let (log, commits, refs, latest_tag, licence_text) = tokio::try_join!(
88+ // One commit: the bar above the file list shows the latest, and nothing here
89+ // needs the rest of the history.
90+ queries.log(handle, name, rev, 1),
91+ queries.count_commits(handle, name, rev),
92+ queries.list_refs(handle, name),
93+ queries.latest_tag(handle, name),
94+ async {
95+ let Some(path) = &licence_path else {
96+ return Ok(None);
97+ };
98+
99+ // Read whole rather than capped at the sniffing window: the port has no
100+ // partial read, and a cap below a licence's real size comes back as "too
101+ // large" — which would leave Apache-2.0, at 11 KB, permanently unnamed.
102+ Ok(queries
103+ .read_blob(handle, name, rev, path, MAX_BLOB_BYTES)
104+ .await?
105+ .and_then(|blob| blob.content)
106+ .and_then(|bytes| String::from_utf8(bytes).ok()))
107+ },
108+ )?;
109+
110+ Ok(Some(RepoFacts {
111+ latest_commit: log.into_iter().next(),
112+ commits,
113+ refs: organise_refs(refs),
114+ latest_tag,
115+ licence: licence_path.map(|path| Licence {
116+ path,
117+ name: licence_text.as_deref().and_then(sniff_licence),
118+ }),
119+ }))
120+}
121+
122+/// The filenames a licence lives under, lowercased.
123+///
124+/// Filename-only, and a short list: this runs over a listing the page already has, so
125+/// it costs nothing, whereas guessing at `LICENSE-MIT` and friends would multiply the
126+/// blob reads. A repository with several licence files gets whichever comes first in
127+/// this order.
128+const LICENCE_FILES: [&str; 4] = ["license", "license.md", "license.txt", "copying"];
129+
130+/// How much of a licence to look at.
131+///
132+/// A kilobyte rather than the couple of hundred bytes a title needs, because BSD is
133+/// identified by *which clauses it has* and the third one sits about 900 bytes in. Past
134+/// that there is nothing left that distinguishes one licence from another.
135+const SNIFF_BYTES: usize = 1024;
136+
137+/// The licence file in a root listing, if there is one.
138+///
139+/// Case-insensitive, because all of `LICENSE`, `License` and `license` appear in the
140+/// wild and mean the same thing.
141+fn licence_file(entries: &[TreeEntry]) -> Option<RepoPath> {
142+ entries
143+ .iter()
144+ .filter(|entry| entry.kind == EntryKind::Blob)
145+ .filter_map(|entry| {
146+ let lowered = entry.name.to_ascii_lowercase();
147+ let rank = LICENCE_FILES.iter().position(|name| *name == lowered)?;
148+
149+ Some((rank, entry))
150+ })
151+ .min_by_key(|(rank, _)| *rank)
152+ // The entry came out of a root listing, so its name is a single component and
153+ // the only way this fails is a name `RepoPath` refuses — in which case there is
154+ // no link to offer and no licence to name.
155+ .and_then(|(_, entry)| RepoPath::new(&entry.name).ok())
156+}
157+
158+/// Which licence a text appears to be, by the phrases only that licence uses.
159+///
160+/// Deliberately not a classifier. Every arm here is a phrase from the licence's own
161+/// preamble, matched case-insensitively, and anything that matches nothing is `None` —
162+/// see [`Licence`] for why a guess is worse than silence.
163+///
164+/// Order matters where one licence's opening is a prefix of another's: ISC and the
165+/// Unlicense both read like MIT for the first few words, and Affero opens with the GPL's
166+/// own title.
167+fn sniff_licence(text: &str) -> Option<&'static str> {
168+ let head: String = text
169+ .chars()
170+ .take(SNIFF_BYTES)
171+ .collect::<String>()
172+ .to_ascii_lowercase();
173+ let has = |needle: &str| head.contains(needle);
174+
175+ if has("gnu affero general public license") {
176+ return Some("AGPL-3.0");
177+ }
178+
179+ if has("gnu general public license") && has("version 3") {
180+ return Some("GPL-3.0");
181+ }
182+
183+ if has("apache license") && has("version 2.0") {
184+ return Some("Apache-2.0");
185+ }
186+
187+ if has("mozilla public license") && has("version 2.0") {
188+ return Some("MPL-2.0");
189+ }
190+
191+ if has("this is free and unencumbered software released into the public domain") {
192+ return Some("Unlicense");
193+ }
194+
195+ // ISC's grant is MIT's with "and/or distribute" in place of MIT's much longer list
196+ // of verbs, so it has to be ruled out before MIT is considered.
197+ if has("permission to use, copy, modify, and/or distribute") {
198+ return Some("ISC");
199+ }
200+
201+ if has("mit license") || has("permission is hereby granted, free of charge") {
202+ return Some("MIT");
203+ }
204+
205+ // The BSD family shares one opening and differs only in its clauses.
206+ if has("redistribution and use in source and binary forms") {
207+ return Some(if has("neither the name") {
208+ "BSD-3-Clause"
209+ } else {
210+ "BSD-2-Clause"
211+ });
212+ }
213+
214+ None
215+}
216+
217+#[cfg(test)]
218+mod tests {
219+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
220+
221+ use super::*;
222+ use crate::{
223+ domain::{
224+ Membership, MembershipId, ObjectId, OrgId, Organization, RepoId, Repository, UserId,
225+ Visibility,
226+ },
227+ infrastructure::{
228+ git::InMemoryGitQuery,
229+ repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
230+ },
231+ };
232+
233+ fn entry(name: &str, kind: EntryKind) -> TreeEntry {
234+ TreeEntry {
235+ name: name.to_owned(),
236+ kind,
237+ id: ObjectId::from_trusted("0".repeat(40)),
238+ size: Some(0),
239+ }
240+ }
241+
242+ // --- finding the file ---------------------------------------------------------
243+
244+ #[test]
245+ fn the_usual_licence_filenames_are_all_found() {
246+ for name in [
247+ "LICENSE",
248+ "license",
249+ "License.md",
250+ "LICENSE.txt",
251+ "COPYING",
252+ "copying",
253+ ] {
254+ let entries = [entry(name, EntryKind::Blob)];
255+
256+ assert_eq!(
257+ licence_file(&entries).as_ref().map(RepoPath::as_str),
258+ Some(name),
259+ "{name} should be a licence"
260+ );
261+ }
262+ }
263+
264+ #[test]
265+ fn files_that_merely_mention_a_licence_are_not_the_licence() {
266+ for name in ["LICENSE-MIT", "licensing.md", "NOTICE", "README.md"] {
267+ let entries = [entry(name, EntryKind::Blob)];
268+
269+ assert!(
270+ licence_file(&entries).is_none(),
271+ "{name} should not be a licence"
272+ );
273+ }
274+ }
275+
276+ #[test]
277+ fn a_directory_called_license_is_not_a_licence() {
278+ // Reading it would ask git for a blob at a tree's path and get nothing.
279+ let entries = [entry("LICENSE", EntryKind::Tree)];
280+
281+ assert!(licence_file(&entries).is_none());
282+ }
283+
284+ #[test]
285+ fn the_plainest_spelling_wins_when_there_are_several() {
286+ let entries = [
287+ entry("COPYING", EntryKind::Blob),
288+ entry("LICENSE", EntryKind::Blob),
289+ ];
290+
291+ assert_eq!(
292+ licence_file(&entries).as_ref().map(RepoPath::as_str),
293+ Some("LICENSE")
294+ );
295+ }
296+
297+ // --- naming it ----------------------------------------------------------------
298+
299+ #[test]
300+ fn the_common_licences_are_recognised_from_their_own_words() {
301+ let cases = [
302+ (
303+ " GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n",
304+ "AGPL-3.0",
305+ ),
306+ (
307+ " GNU GENERAL PUBLIC LICENSE\n Version 3, 29 June 2007\n",
308+ "GPL-3.0",
309+ ),
310+ (
311+ " Apache License\n Version 2.0, January 2004\n",
312+ "Apache-2.0",
313+ ),
314+ (
315+ "Mozilla Public License Version 2.0\n==================================\n",
316+ "MPL-2.0",
317+ ),
318+ (
319+ "MIT License\n\nCopyright (c) 2026 Ada\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n",
320+ "MIT",
321+ ),
322+ (
323+ "Copyright (c) 2026 Ada\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n",
324+ "ISC",
325+ ),
326+ (
327+ "This is free and unencumbered software released into the public domain.\n",
328+ "Unlicense",
329+ ),
330+ ];
331+
332+ for (text, expected) in cases {
333+ assert_eq!(sniff_licence(text), Some(expected), "for {text:?}");
334+ }
335+ }
336+
337+ #[test]
338+ fn the_bsd_licences_are_told_apart_by_their_third_clause() {
339+ let shared = "Copyright (c) 2026 Ada. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice.\n\n2. Redistributions in binary form must reproduce the above copyright notice.\n";
340+
341+ assert_eq!(sniff_licence(shared), Some("BSD-2-Clause"));
342+ assert_eq!(
343+ sniff_licence(&format!(
344+ "{shared}\n3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse products.\n"
345+ )),
346+ Some("BSD-3-Clause")
347+ );
348+ }
349+
350+ #[test]
351+ fn a_licence_it_does_not_know_is_left_unnamed_rather_than_guessed() {
352+ // The page still links to the file — see `Licence`.
353+ assert_eq!(sniff_licence("All rights reserved. Ask me nicely.\n"), None);
354+ assert_eq!(sniff_licence(""), None);
355+ }
356+
357+ #[test]
358+ fn only_the_head_of_a_file_is_read() {
359+ // A licence that names another one deep in its text — Apache's appendix, a
360+ // vendored notice — must not change what the file itself is.
361+ let text = format!(
362+ "MIT License\n{}\nGNU AFFERO GENERAL PUBLIC LICENSE\n",
363+ "x".repeat(SNIFF_BYTES)
364+ );
365+
366+ assert_eq!(sniff_licence(&text), Some("MIT"));
367+ }
368+
369+ // --- the whole read -----------------------------------------------------------
370+
371+ struct Fixture {
372+ orgs: InMemoryOrgRepo,
373+ memberships: InMemoryMembershipRepo,
374+ repos: InMemoryRepoRepo,
375+ handle: OrgName,
376+ owner: Actor,
377+ stranger: Actor,
378+ }
379+
380+ /// One organisation with an owner, and a `steid` repository of the given visibility.
381+ async fn fixture(visibility: Visibility) -> Fixture {
382+ let orgs = InMemoryOrgRepo::new();
383+ let memberships = InMemoryMembershipRepo::new();
384+ let repos = InMemoryRepoRepo::new();
385+
386+ let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
387+ orgs.save(&org).await.expect("save org");
388+
389+ let owner = UserId::generate();
390+ memberships
391+ .save(&Membership::new(
392+ MembershipId::generate(),
393+ org.id.clone(),
394+ owner.clone(),
395+ crate::domain::Role::Owner,
396+ ))
397+ .await
398+ .expect("save membership");
399+
400+ repos
401+ .save(
402+ &Repository::new(
403+ RepoId::generate(),
404+ org.id.clone(),
405+ "steid",
406+ None,
407+ visibility,
408+ SystemTime::now(),
409+ )
410+ .expect("valid repository"),
411+ )
412+ .await
413+ .expect("save repo");
414+
415+ Fixture {
416+ orgs,
417+ memberships,
418+ repos,
419+ handle: org.name,
420+ owner: Actor::User(owner),
421+ stranger: Actor::Anonymous,
422+ }
423+ }
424+
425+ impl Fixture {
426+ async fn facts(
427+ &self,
428+ actor: &Actor,
429+ root: &[TreeEntry],
430+ queries: &InMemoryGitQuery,
431+ ) -> Result<Option<RepoFacts>> {
432+ repo_summary(
433+ &self.handle,
434+ &RepoName::new("steid").expect("valid repository name"),
435+ &RefName::from_trusted("main"),
436+ root,
437+ actor,
438+ &self.orgs,
439+ &self.memberships,
440+ &self.repos,
441+ queries,
442+ )
443+ .await
444+ }
445+ }
446+
447+ fn commit(summary: &str) -> CommitSummary {
448+ CommitSummary {
449+ id: ObjectId::from_trusted("a".repeat(40)),
450+ summary: summary.to_owned(),
451+ author_name: "Ada Lovelace".to_owned(),
452+ committed_at: UNIX_EPOCH + Duration::from_secs(1_700_000_000),
453+ }
454+ }
455+
456+ #[tokio::test]
457+ async fn the_facts_come_back_composed_from_every_source() {
458+ let f = fixture(Visibility::Public).await;
459+ let root = [
460+ entry("src", EntryKind::Tree),
461+ entry("LICENSE", EntryKind::Blob),
462+ ];
463+ let queries = InMemoryGitQuery::new()
464+ .with_log(vec![commit("feat: a global top bar")])
465+ .with_commit_count(128)
466+ .with_branch("main")
467+ .with_branch("next")
468+ .with_tag("v0.1.0")
469+ .with_latest_tag("v0.1.0", UNIX_EPOCH + Duration::from_secs(1_700_000_000))
470+ .with_blob(
471+ "main",
472+ "LICENSE",
473+ b" GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n".to_vec(),
474+ );
475+
476+ let facts = f
477+ .facts(&f.owner, &root, &queries)
478+ .await
479+ .expect("should read")
480+ .expect("visible");
481+
482+ assert_eq!(
483+ facts.latest_commit.map(|commit| commit.summary),
484+ Some("feat: a global top bar".to_owned())
485+ );
486+ assert_eq!(facts.commits, 128);
487+ assert_eq!(facts.refs.branches.len(), 2);
488+ assert_eq!(facts.refs.tags.len(), 1);
489+ assert_eq!(
490+ facts.latest_tag.map(|tag| tag.name.to_string()),
491+ Some("v0.1.0".to_owned())
492+ );
493+ assert_eq!(
494+ facts.licence,
495+ Some(Licence {
496+ path: RepoPath::from_trusted("LICENSE"),
497+ name: Some("AGPL-3.0"),
498+ })
499+ );
500+ }
501+
502+ #[tokio::test]
503+ async fn a_repository_with_no_licence_file_states_no_licence() {
504+ // The sparse state: nothing is read, and nothing is claimed.
505+ let f = fixture(Visibility::Public).await;
506+ let root = [entry("src", EntryKind::Tree)];
507+
508+ let facts = f
509+ .facts(&f.owner, &root, &InMemoryGitQuery::new())
510+ .await
511+ .expect("should read")
512+ .expect("visible");
513+
514+ assert_eq!(facts.licence, None);
515+ assert_eq!(facts.latest_commit, None);
516+ assert_eq!(facts.commits, 0);
517+ }
518+
519+ #[tokio::test]
520+ async fn a_private_repositorys_facts_are_invisible_to_a_stranger() {
521+ // A commit count and a branch list are content. Same answer as the page.
522+ let f = fixture(Visibility::Private).await;
523+ let queries = InMemoryGitQuery::new().with_branch("secret-work");
524+
525+ assert!(
526+ f.facts(&f.stranger, &[], &queries)
527+ .await
528+ .expect("should read")
529+ .is_none()
530+ );
531+ assert!(
532+ f.facts(&f.owner, &[], &queries)
533+ .await
534+ .expect("should read")
535+ .is_some()
536+ );
537+ }
538+}
src/domain/mod.rs+1 −1View file
@@ -24,7 +24,7 @@ pub use email::Email;
2424 pub use error::DomainError;
2525 pub use id::{MembershipId, OrgId, RepoId, TokenId, UserId};
2626 pub use membership::{Membership, Role};
27pub use object::{CommitSummary, EntryKind, GitRef, ObjectId, RefKind, TreeEntry};
27+pub use object::{CommitSummary, EntryKind, GitRef, ObjectId, RefKind, TagSummary, TreeEntry};
2828 pub use org::{OrgName, Organization};
2929 pub use password::PasswordHash;
3030 pub use reference::{RefName, RepoPath};
src/domain/object.rs+12 −0View file
@@ -169,6 +169,18 @@ pub struct GitRef {
169169 pub kind: RefKind,
170170 }
171171
172+/// A tag and when it was made, for the "latest tag" a repository page states.
173+///
174+/// The time is git's `creatordate`: the tag's own date for an annotated tag, and the
175+/// date of the commit it points at for a lightweight one. That is the only definition
176+/// that gives both kinds a usable answer — a lightweight tag has no date of its own,
177+/// and reporting nothing for one would make the newest tag look older than it is.
178+#[derive(Debug, Clone, PartialEq, Eq)]
179+pub struct TagSummary {
180+ pub name: RefName,
181+ pub created_at: SystemTime,
182+}
183+
172184 /// A commit, reduced to what a log entry shows.
173185 #[derive(Debug, Clone, PartialEq, Eq)]
174186 pub struct CommitSummary {
src/infrastructure/git.rs+37 −1View file
@@ -11,6 +11,7 @@ use std::{
1111 path::PathBuf,
1212 process::{Output, Stdio},
1313 sync::{Arc, Mutex},
14+ time::SystemTime,
1415 };
1516
1617 use tokio::{
@@ -24,7 +25,8 @@ use crate::{
2425 GitResponse, GitStorage, GitStorageError,
2526 },
2627 domain::{
27 CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath, TreeEntry,
28+ CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath, TagSummary,
29+ TreeEntry,
2830 },
2931 };
3032
@@ -485,6 +487,10 @@ pub struct InMemoryGitQuery {
485487 /// Branches and tags, in whatever order a test seeded them — the real adapter makes
486488 /// no ordering promise either.
487489 refs: Vec<GitRef>,
490+ /// Overrides the count derived from the seeded log, for a test that wants a
491+ /// repository with more history than it wants to write out.
492+ commit_count: Option<u64>,
493+ latest_tag: Option<TagSummary>,
488494 }
489495
490496 impl InMemoryGitQuery {
@@ -534,6 +540,19 @@ impl InMemoryGitQuery {
534540 self.with_ref(name, RefKind::Tag)
535541 }
536542
543+ pub fn with_commit_count(mut self, count: u64) -> Self {
544+ self.commit_count = Some(count);
545+ self
546+ }
547+
548+ pub fn with_latest_tag(mut self, name: &str, created_at: SystemTime) -> Self {
549+ self.latest_tag = Some(TagSummary {
550+ name: RefName::from_trusted(name),
551+ created_at,
552+ });
553+ self
554+ }
555+
537556 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
538557 self.refs.push(GitRef {
539558 name: RefName::from_trusted(name),
@@ -612,6 +631,23 @@ impl GitQuery for InMemoryGitQuery {
612631 ) -> Result<Vec<GitRef>, GitQueryError> {
613632 Ok(self.refs.clone())
614633 }
634+
635+ async fn count_commits(
636+ &self,
637+ _handle: &OrgName,
638+ _name: &RepoName,
639+ _rev: &RefName,
640+ ) -> Result<u64, GitQueryError> {
641+ Ok(self.commit_count.unwrap_or(self.commits.len() as u64))
642+ }
643+
644+ async fn latest_tag(
645+ &self,
646+ _handle: &OrgName,
647+ _name: &RepoName,
648+ ) -> Result<Option<TagSummary>, GitQueryError> {
649+ Ok(self.latest_tag.clone())
650+ }
615651 }
616652
617653 #[cfg(test)]
src/infrastructure/git_query.rs+250 −1View file
@@ -41,7 +41,7 @@ use crate::{
4141 application::port::{Blob, GitQuery, GitQueryError},
4242 domain::{
4343 CommitSummary, EntryKind, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
44 TreeEntry,
44+ TagSummary, TreeEntry,
4545 },
4646 infrastructure::git::git_command,
4747 };
@@ -286,6 +286,69 @@ impl GitQuery for DiskGitQuery {
286286
287287 Ok(parse_refs(&output.stdout))
288288 }
289+
290+ async fn count_commits(
291+ &self,
292+ handle: &OrgName,
293+ name: &RepoName,
294+ rev: &RefName,
295+ ) -> Result<u64, GitQueryError> {
296+ let repo = self.repo_path(handle, name);
297+
298+ // Resolved first, for the same reason `log` resolves first: `rev-list` on an
299+ // empty repository or an unknown branch is a fatal error, and the port promises
300+ // a count rather than a failure. **That makes this two processes, not one** —
301+ // the price of keeping the module's rule that a non-zero exit is always a real
302+ // fault. Handing the resolved id to `rev-list` also means nothing from a URL
303+ // reaches git's revision parser here.
304+ let Some(commit) = self.resolve(handle, name, rev).await? else {
305+ return Ok(0);
306+ };
307+
308+ let output = run(
309+ &repo,
310+ [
311+ OsStr::new("rev-list"),
312+ OsStr::new("--count"),
313+ OsStr::new(commit.as_str()),
314+ ],
315+ )
316+ .await?;
317+
318+ let count = String::from_utf8_lossy(&output.stdout);
319+ let count = count.trim();
320+
321+ count.parse().map_err(|_| {
322+ GitQueryError::new(format!(
323+ "git counted commits as {count:?}, which is not a number"
324+ ))
325+ })
326+ }
327+
328+ async fn latest_tag(
329+ &self,
330+ handle: &OrgName,
331+ name: &RepoName,
332+ ) -> Result<Option<TagSummary>, GitQueryError> {
333+ let repo = self.repo_path(handle, name);
334+
335+ // `--count=1` after `--sort` is the whole of the work: git does the ordering,
336+ // so this is one process regardless of how many tags a repository carries.
337+ // Every argument is a literal — nothing from a URL reaches this call.
338+ let output = run(
339+ &repo,
340+ [
341+ OsStr::new("for-each-ref"),
342+ OsStr::new("--sort=-creatordate"),
343+ OsStr::new("--count=1"),
344+ OsStr::new(TAG_FORMAT),
345+ OsStr::new("refs/tags/"),
346+ ],
347+ )
348+ .await?;
349+
350+ Ok(parse_latest_tag(&output.stdout))
351+ }
289352 }
290353
291354 /// What `cat-file --batch-check` said about one object.
@@ -574,6 +637,41 @@ fn parse_refs(stdout: &[u8]) -> Vec<GitRef> {
574637 refs
575638 }
576639
640+/// What the latest-tag query asks for: the full ref name and its creation time.
641+///
642+/// `creatordate` rather than `taggerdate`, which is empty for a lightweight tag, or
643+/// `committerdate`, which is empty for an annotated one. `creatordate` is git's own
644+/// "whichever of those this ref has".
645+const TAG_FORMAT: &str = "--format=%(refname)%00%(creatordate:unix)%00";
646+
647+/// Parses the one record [`TAG_FORMAT`] produces, or `None` for a repository with no
648+/// tags.
649+///
650+/// Anything unreadable is `None` rather than an error: this decorates a page with a
651+/// fact, and a tag whose name is not UTF-8 or whose date git spelled unexpectedly is a
652+/// reason to say nothing, not to fail the repository's landing page.
653+fn parse_latest_tag(stdout: &[u8]) -> Option<TagSummary> {
654+ let fields: Vec<&[u8]> = stdout
655+ .split(|byte| *byte == 0)
656+ .map(<[u8]>::trim_ascii)
657+ .filter(|field| !field.is_empty())
658+ .collect();
659+
660+ let [name, created_at] = fields[..] else {
661+ return None;
662+ };
663+
664+ let short = std::str::from_utf8(name).ok()?.strip_prefix("refs/tags/")?;
665+ let created_at: i64 = std::str::from_utf8(created_at).ok()?.parse().ok()?;
666+
667+ Some(TagSummary {
668+ // Validated rather than trusted, exactly as `parse_refs` does: the name is
669+ // about to become a link.
670+ name: RefName::new(short).ok()?,
671+ created_at: unix_time(created_at),
672+ })
673+}
674+
577675 /// Runs a git command inside a repository and fails on a non-zero exit.
578676 ///
579677 /// Only ever used for commands whose subject has already been confirmed to exist, so a
@@ -1509,6 +1607,157 @@ mod tests {
15091607 assert!(parse_refs(b"").is_empty());
15101608 }
15111609
1610+ // --- count_commits ------------------------------------------------------------
1611+
1612+ #[tokio::test]
1613+ async fn commits_are_counted_from_the_revision_asked_about() {
1614+ let (_dir, query) = populated();
1615+
1616+ assert_eq!(
1617+ query
1618+ .count_commits(&handle(), &repo_name(), &rev("main"))
1619+ .await
1620+ .expect("should count"),
1621+ 3
1622+ );
1623+ }
1624+
1625+ #[tokio::test]
1626+ async fn a_revision_with_no_commits_counts_zero_rather_than_failing() {
1627+ // Both spellings of "nothing here": an empty repository, and a branch that is
1628+ // not there. `rev-list` is fatal for each, and a page asking how big a
1629+ // repository is wants a number.
1630+ let (_dir, empty_query) = empty();
1631+ assert_eq!(
1632+ empty_query
1633+ .count_commits(&handle(), &repo_name(), &rev("main"))
1634+ .await
1635+ .expect("should count"),
1636+ 0
1637+ );
1638+
1639+ let (_dir, query) = populated();
1640+ assert_eq!(
1641+ query
1642+ .count_commits(&handle(), &repo_name(), &rev("no-such-branch"))
1643+ .await
1644+ .expect("should count"),
1645+ 0
1646+ );
1647+ }
1648+
1649+ #[tokio::test]
1650+ async fn counting_a_repository_that_is_not_on_disk_is_an_error() {
1651+ // Same rule as everywhere else here: absent from disk is a fault, not a zero.
1652+ let (_dir, query) = empty();
1653+ let missing = RepoName::new("gone").expect("valid repository name");
1654+
1655+ assert!(
1656+ query
1657+ .count_commits(&handle(), &missing, &rev("main"))
1658+ .await
1659+ .is_err()
1660+ );
1661+ }
1662+
1663+ // --- latest_tag ---------------------------------------------------------------
1664+
1665+ /// The populated repository with two annotated tags whose dates disagree with their
1666+ /// names, so a test can tell "newest" from "last alphabetically".
1667+ fn with_dated_tags() -> (TempDir, DiskGitQuery) {
1668+ let (dir, query) = populated();
1669+ let repo = query.repo_path(&handle(), &repo_name());
1670+ let work = dir.path().join("work");
1671+ let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1672+
1673+ git(&work, FIRST_COMMIT, &["tag", "-a", "v1.0", "-m", "first"]);
1674+ // Made later but named lower: sorting by name would pick `v1.0`.
1675+ git(
1676+ &work,
1677+ THIRD_COMMIT,
1678+ &["tag", "-a", "v0.9", "-m", "backport"],
1679+ );
1680+ git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1681+
1682+ (dir, query)
1683+ }
1684+
1685+ #[tokio::test]
1686+ async fn the_latest_tag_is_the_newest_one_not_the_last_one_alphabetically() {
1687+ let (_dir, query) = with_dated_tags();
1688+
1689+ let tag = query
1690+ .latest_tag(&handle(), &repo_name())
1691+ .await
1692+ .expect("should read")
1693+ .expect("a tag");
1694+
1695+ assert_eq!(tag.name.as_str(), "v0.9");
1696+ assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
1697+ }
1698+
1699+ #[tokio::test]
1700+ async fn a_lightweight_tag_is_dated_by_the_commit_it_points_at() {
1701+ // It has no date of its own, and `creatordate` is what fills that in.
1702+ let (dir, query) = populated();
1703+ let repo = query.repo_path(&handle(), &repo_name());
1704+ let work = dir.path().join("work");
1705+ let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1706+
1707+ git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1708+ git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1709+
1710+ let tag = query
1711+ .latest_tag(&handle(), &repo_name())
1712+ .await
1713+ .expect("should read")
1714+ .expect("a tag");
1715+
1716+ assert_eq!(tag.name.as_str(), "v1.0");
1717+ assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
1718+ }
1719+
1720+ #[tokio::test]
1721+ async fn a_repository_with_no_tags_has_no_latest_tag() {
1722+ let (_dir, query) = populated();
1723+ assert_eq!(
1724+ query
1725+ .latest_tag(&handle(), &repo_name())
1726+ .await
1727+ .expect("should read"),
1728+ None
1729+ );
1730+
1731+ let (_dir, empty_query) = empty();
1732+ assert_eq!(
1733+ empty_query
1734+ .latest_tag(&handle(), &repo_name())
1735+ .await
1736+ .expect("should read"),
1737+ None
1738+ );
1739+ }
1740+
1741+ #[test]
1742+ fn a_tag_record_is_parsed_past_the_trailing_newline() {
1743+ // `for-each-ref` ends every record with a newline the format cannot suppress,
1744+ // exactly as it does for `parse_refs`.
1745+ let tag = parse_latest_tag(b"refs/tags/v1.0\x001700000000\x00\n").expect("a tag");
1746+
1747+ assert_eq!(tag.name.as_str(), "v1.0");
1748+ assert_eq!(tag.created_at, unix_time(1_700_000_000));
1749+ }
1750+
1751+ #[test]
1752+ fn nothing_is_parsed_from_an_empty_tag_listing() {
1753+ assert_eq!(parse_latest_tag(b""), None);
1754+ // A ref outside the tags namespace is not a tag, whatever asked for it.
1755+ assert_eq!(
1756+ parse_latest_tag(b"refs/heads/main\x001700000000\x00\n"),
1757+ None
1758+ );
1759+ }
1760+
15121761 // --- helpers ------------------------------------------------------------------
15131762
15141763 #[tokio::test]