steid

@jamesgill /

feat: the branches and tags a page shows, and what it says when there are none

Two use cases beside `list_refs`, authorized the same way. Each returns
its rows plus whether the repository has any history at all, because "no
tags yet" and "nothing pushed yet" are different sentences and only the
second wants the push snippet under it. Learning that costs one extra git
process, asked only when the list came back empty.

The default branch is pinned to the top here rather than in the adapter:
it is the branch a visitor came for and it is not reliably the most
recently pushed, so a repository whose work happens on feature branches
would otherwise bury `main` halfway down the page. A stable partition, so
git's date order survives beneath it.

No ahead/behind counts, deliberately — that is a `rev-list` per branch,
which is the fork-per-fact cost 0006 exists to bound.

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

2 files changed+255 −3

src/application/browse.rs+253 −1View file
@@ -5,7 +5,8 @@
55 //! invisible in its file tree, by construction rather than by remembering to check.
66
77 use crate::domain::{
8 Actor, CommitSummary, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
8+ Actor, BranchRow, CommitSummary, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
9+ TagRow,
910 repository::{MembershipRepository, OrgRepository, RepoRepository},
1011 };
1112
@@ -247,6 +248,103 @@ pub fn organise_refs(refs: Vec<crate::domain::GitRef>) -> RefList {
247248 list
248249 }
249250
251+/// A page of refs, and whether the repository has any history at all.
252+///
253+/// The second field is the distinction the branches and tags pages need but a list
254+/// alone cannot make: "no tags yet" and "nothing pushed yet" are different sentences,
255+/// and only the second one wants the push snippet beneath it.
256+///
257+/// It costs **one extra `git` process**, and only when the list came back empty — which
258+/// is exactly when the page has nothing else to spend.
259+#[derive(Debug, Clone, PartialEq, Eq)]
260+pub struct RefPage<Row> {
261+ pub rows: Vec<Row>,
262+ /// No commits have been pushed: `HEAD` names a branch that does not exist.
263+ pub repo_is_empty: bool,
264+}
265+
266+/// Every branch, in the order the branches page shows them.
267+///
268+/// `Ok(None)` on the same terms as [`browse_repo`]: invisible and absent are one answer.
269+///
270+/// **One `git` process** — see [`branches`](super::port::GitQuery::branches) — plus one
271+/// more only when there are no branches, to tell an empty repository from a repository
272+/// whose refs are all tags.
273+///
274+/// Deliberately **no ahead/behind counts**: a count against the default branch is a
275+/// `rev-list` per branch, so a repository with twenty branches would fork twenty extra
276+/// processes to decorate one page. That is exactly the cost
277+/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md) bounds, and it
278+/// stays out until something keeps git alive between questions.
279+pub async fn list_branches(
280+ handle: &OrgName,
281+ name: &RepoName,
282+ actor: &Actor,
283+ orgs: &impl OrgRepository,
284+ memberships: &impl MembershipRepository,
285+ repos: &impl RepoRepository,
286+ queries: &impl GitQuery,
287+) -> Result<Option<RefPage<BranchRow>>> {
288+ if view_repo(handle, name, actor, orgs, memberships, repos)
289+ .await?
290+ .is_none()
291+ {
292+ return Ok(None);
293+ }
294+
295+ let rows = pin_default(queries.branches(handle, name).await?);
296+
297+ Ok(Some(RefPage {
298+ repo_is_empty: rows.is_empty() && !has_commits(handle, name, queries).await?,
299+ rows,
300+ }))
301+}
302+
303+/// Every tag, newest first. Authorized and costed exactly as [`list_branches`] is.
304+pub async fn list_tags(
305+ handle: &OrgName,
306+ name: &RepoName,
307+ actor: &Actor,
308+ orgs: &impl OrgRepository,
309+ memberships: &impl MembershipRepository,
310+ repos: &impl RepoRepository,
311+ queries: &impl GitQuery,
312+) -> Result<Option<RefPage<TagRow>>> {
313+ if view_repo(handle, name, actor, orgs, memberships, repos)
314+ .await?
315+ .is_none()
316+ {
317+ return Ok(None);
318+ }
319+
320+ let rows = queries.tags(handle, name).await?;
321+
322+ Ok(Some(RefPage {
323+ repo_is_empty: rows.is_empty() && !has_commits(handle, name, queries).await?,
324+ rows,
325+ }))
326+}
327+
328+/// Whether anything has been pushed. One `git` process, asked only on an empty page.
329+async fn has_commits(handle: &OrgName, name: &RepoName, queries: &impl GitQuery) -> Result<bool> {
330+ Ok(queries.default_branch(handle, name).await?.is_some())
331+}
332+
333+/// Moves the default branch to the front, leaving everything else in git's date order.
334+///
335+/// A display decision rather than the adapter's: the default branch is the one a
336+/// visitor came for, and it is not reliably the most recently pushed — a repository
337+/// whose work happens on feature branches would bury `main` halfway down a page
338+/// otherwise. Everything below it stays newest-first, which is the ordering that
339+/// answers "what is being worked on".
340+///
341+/// A stable partition rather than a sort, so the date order it was given survives.
342+fn pin_default(rows: Vec<BranchRow>) -> Vec<BranchRow> {
343+ let (default, rest): (Vec<_>, Vec<_>) = rows.into_iter().partition(|row| row.is_default);
344+
345+ default.into_iter().chain(rest).collect()
346+}
347+
250348 /// A file as it is served rather than rendered.
251349 #[derive(Debug, Clone, PartialEq, Eq)]
252350 pub enum RawFile {
@@ -463,6 +561,160 @@ mod tests {
463561 }
464562 }
465563
564+ // --- list_branches and list_tags ------------------------------------------------
565+
566+ fn at(offset: u64) -> SystemTime {
567+ std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000 + offset)
568+ }
569+
570+ #[tokio::test]
571+ async fn the_default_branch_is_pinned_above_the_rest() {
572+ let f = fixture(Visibility::Public).await;
573+ // Seeded in git's order — newest tip first — with the default branch stalest,
574+ // which is the case pinning exists for.
575+ let queries = InMemoryGitQuery::new()
576+ .with_branch_row("feature/login", false, at(200))
577+ .with_branch_row("spike", false, at(100))
578+ .with_branch_row("main", true, at(0));
579+
580+ let page = list_branches(
581+ &f.handle,
582+ &repo_name(),
583+ &f.owner,
584+ &f.orgs,
585+ &f.memberships,
586+ &f.repos,
587+ &queries,
588+ )
589+ .await
590+ .expect("should read")
591+ .expect("visible");
592+
593+ assert_eq!(
594+ page.rows
595+ .iter()
596+ .map(|row| row.name.as_str())
597+ .collect::<Vec<_>>(),
598+ vec!["main", "feature/login", "spike"]
599+ );
600+ // Pinning must not disturb the date order of everything below it.
601+ assert!(!page.repo_is_empty);
602+ }
603+
604+ #[tokio::test]
605+ async fn a_repository_with_no_branches_says_it_is_empty() {
606+ // `InMemoryGitQuery::empty()` has no default branch, which is what "nothing has
607+ // been pushed" looks like — and what makes the page show the push snippet.
608+ let f = fixture(Visibility::Public).await;
609+
610+ let page = list_branches(
611+ &f.handle,
612+ &repo_name(),
613+ &f.owner,
614+ &f.orgs,
615+ &f.memberships,
616+ &f.repos,
617+ &InMemoryGitQuery::empty(),
618+ )
619+ .await
620+ .expect("should read")
621+ .expect("visible");
622+
623+ assert!(page.rows.is_empty());
624+ assert!(page.repo_is_empty);
625+ }
626+
627+ #[tokio::test]
628+ async fn a_repository_with_commits_but_no_tags_is_not_empty() {
629+ // The distinction the page needs: "no tags yet" rather than "nothing pushed".
630+ let f = fixture(Visibility::Public).await;
631+
632+ let page = list_tags(
633+ &f.handle,
634+ &repo_name(),
635+ &f.owner,
636+ &f.orgs,
637+ &f.memberships,
638+ &f.repos,
639+ &InMemoryGitQuery::new(),
640+ )
641+ .await
642+ .expect("should read")
643+ .expect("visible");
644+
645+ assert!(page.rows.is_empty());
646+ assert!(!page.repo_is_empty);
647+ }
648+
649+ #[tokio::test]
650+ async fn tags_keep_the_order_git_sorted_them_into() {
651+ let f = fixture(Visibility::Public).await;
652+ let queries = InMemoryGitQuery::new()
653+ .with_tag_row("v2.0", true, at(200))
654+ .with_tag_row("v0.9", false, at(100));
655+
656+ let page = list_tags(
657+ &f.handle,
658+ &repo_name(),
659+ &f.owner,
660+ &f.orgs,
661+ &f.memberships,
662+ &f.repos,
663+ &queries,
664+ )
665+ .await
666+ .expect("should read")
667+ .expect("visible");
668+
669+ assert_eq!(
670+ page.rows
671+ .iter()
672+ .map(|row| row.name.as_str())
673+ .collect::<Vec<_>>(),
674+ vec!["v2.0", "v0.9"]
675+ );
676+ assert_eq!(page.rows[0].message.as_deref(), Some("release v2.0"));
677+ assert_eq!(page.rows[1].message, None);
678+ }
679+
680+ #[tokio::test]
681+ async fn a_private_repositorys_branches_and_tags_are_invisible_to_a_stranger() {
682+ // A branch name is content, the same way the switcher's list is.
683+ let f = fixture(Visibility::Private).await;
684+ let queries = InMemoryGitQuery::new()
685+ .with_branch_row("secret-work", true, at(0))
686+ .with_tag_row("unreleased", true, at(0));
687+
688+ assert!(
689+ list_branches(
690+ &f.handle,
691+ &repo_name(),
692+ &f.stranger,
693+ &f.orgs,
694+ &f.memberships,
695+ &f.repos,
696+ &queries,
697+ )
698+ .await
699+ .expect("should read")
700+ .is_none()
701+ );
702+ assert!(
703+ list_tags(
704+ &f.handle,
705+ &repo_name(),
706+ &f.stranger,
707+ &f.orgs,
708+ &f.memberships,
709+ &f.repos,
710+ &queries,
711+ )
712+ .await
713+ .expect("should read")
714+ .is_none()
715+ );
716+ }
717+
466718 // --- list_refs ----------------------------------------------------------------
467719
468720 #[tokio::test]
src/application/mod.rs+2 −2View file
@@ -19,8 +19,8 @@ pub mod summary;
1919 pub mod token;
2020
2121 pub use browse::{
22 Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, MAX_RAW_BYTES, RawFile, RefList, browse_repo,
23 list_refs, read_raw_file, repo_log,
22+ Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, MAX_RAW_BYTES, RawFile, RefList, RefPage,
23+ browse_repo, list_branches, list_refs, list_tags, read_raw_file, repo_log,
2424 };
2525 pub use claim::{OwnerSpec, claim_instance, is_claimed, sole_owner_handle};
2626 pub use config::{AppConfig, Secret};