steid

@jamesgill /

feat: a branch and a tag are rows, not just names

The switcher wants names; a branches page wants what each branch's tip
says. Widening `list_refs` would make every tree page pay for commit
subjects it never shows, so `branches()` and `tags()` are separate ports
returning typed rows and `list_refs` is untouched.

Each is one `git for-each-ref` for a whole page. `%(HEAD)` carries which
branch is the default, so nothing has to ask `symbolic-ref` as well, and
git's own `--sort` orders the rows because it is free there and a second
pass in Rust would give the same answer.

Empty fields are kept when splitting the output: a lightweight tag has no
peeled object, and filtering empties the way the latest-tag parser can
afford to would read that record's date as its commit id.

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

5 files changed+566 −7

src/application/port.rs+35 −2View file
@@ -8,8 +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,
12 TagSummary, TreeEntry,
11+ BranchRow, CommitSummary, GitRef, ObjectId, OrgName, PasswordHash, RefName, RepoName, RepoPath,
12+ TagRow, TagSummary, TreeEntry,
1313 };
1414
1515 /// Hashes and verifies passwords.
@@ -388,6 +388,39 @@ pub trait GitQuery: Send + Sync {
388388 handle: &OrgName,
389389 name: &RepoName,
390390 ) -> impl Future<Output = Result<Option<TagSummary>, GitQueryError>> + Send;
391+
392+ /// Every branch, with what its tip commit says, newest commit first.
393+ ///
394+ /// Richer than [`list_refs`](Self::list_refs) and for a different page: the switcher
395+ /// wants names, the branches page wants a row. Kept separate rather than widening
396+ /// `list_refs`, because a switcher that paid for commit subjects it never shows
397+ /// would make every tree page slower.
398+ ///
399+ /// **One `git` process for the whole page**, including which branch is the default
400+ /// — git's own `%(HEAD)` marker carries that, so nothing has to ask `symbolic-ref`
401+ /// as well.
402+ ///
403+ /// Ordered by committer date, newest first, because git sorts for free and the
404+ /// alternative is re-sorting the whole list in Rust for the same answer. Pinning
405+ /// the default branch to the top is a display decision and belongs to the use case.
406+ ///
407+ /// An empty repository has no branches and answers with an empty list.
408+ fn branches(
409+ &self,
410+ handle: &OrgName,
411+ name: &RepoName,
412+ ) -> impl Future<Output = Result<Vec<BranchRow>, GitQueryError>> + Send;
413+
414+ /// Every tag, newest first by creation date.
415+ ///
416+ /// The counterpart to [`branches`](Self::branches), and one `git` process for the
417+ /// same reason. Both kinds of tag are reported: the commit is peeled through an
418+ /// annotated tag's object so a row always names something browsable.
419+ fn tags(
420+ &self,
421+ handle: &OrgName,
422+ name: &RepoName,
423+ ) -> impl Future<Output = Result<Vec<TagRow>, GitQueryError>> + Send;
391424 }
392425
393426 /// A repository could not be read.
src/domain/mod.rs+3 −1View file
@@ -24,7 +24,9 @@ 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, TagSummary, TreeEntry};
27+pub use object::{
28+ BranchRow, CommitSummary, EntryKind, GitRef, ObjectId, RefKind, TagRow, TagSummary, TreeEntry,
29+};
2830 pub use org::{OrgName, Organization};
2931 pub use password::PasswordHash;
3032 pub use reference::{RefName, RepoPath};
src/domain/object.rs+38 −0View file
@@ -191,6 +191,44 @@ pub struct CommitSummary {
191191 pub committed_at: SystemTime,
192192 }
193193
194+/// A branch, with everything one row of the branches page shows.
195+///
196+/// The commit fields are the branch tip's, read in the same `for-each-ref` that named
197+/// the branch — a page listing thirty branches cannot afford a `git log` each.
198+///
199+/// `is_default` comes from git's own `%(HEAD)` marker rather than from a second
200+/// `symbolic-ref` call, which is what keeps the whole page to one process.
201+#[derive(Debug, Clone, PartialEq, Eq)]
202+pub struct BranchRow {
203+ pub name: RefName,
204+ /// Whether `HEAD` points at this branch: the repository's default.
205+ pub is_default: bool,
206+ pub commit: ObjectId,
207+ /// The first line of the tip commit's message.
208+ pub summary: String,
209+ pub committed_at: SystemTime,
210+}
211+
212+/// A tag, with everything one row of the tags page shows.
213+///
214+/// An annotated tag is a git object of its own carrying a message and a date; a
215+/// lightweight tag is just a name for a commit. Both are here, and
216+/// [`annotated`](Self::annotated) is what tells them apart — not the presence of a
217+/// message, because an annotated tag may have an empty one.
218+#[derive(Debug, Clone, PartialEq, Eq)]
219+pub struct TagRow {
220+ pub name: RefName,
221+ /// The commit the tag names, peeled through the tag object when there is one, so
222+ /// both kinds of tag report the thing a visitor would browse.
223+ pub commit: ObjectId,
224+ /// The first line of an annotated tag's own message. `None` for a lightweight tag,
225+ /// which has none — the commit's subject is the commit's, not the tag's.
226+ pub message: Option<String>,
227+ pub annotated: bool,
228+ /// git's `creatordate`: the tag's own date when it has one, the commit's otherwise.
229+ pub created_at: SystemTime,
230+}
231+
194232 #[cfg(test)]
195233 mod tests {
196234 use super::*;
src/infrastructure/git.rs+52 −2View file
@@ -25,8 +25,8 @@ use crate::{
2525 GitResponse, GitStorage, GitStorageError,
2626 },
2727 domain::{
28 CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath, TagSummary,
29 TreeEntry,
28+ BranchRow, CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
29+ TagRow, TagSummary, TreeEntry,
3030 },
3131 };
3232
@@ -491,6 +491,10 @@ pub struct InMemoryGitQuery {
491491 /// repository with more history than it wants to write out.
492492 commit_count: Option<u64>,
493493 latest_tag: Option<TagSummary>,
494+ /// The branches and tags pages' richer rows, seeded separately from `refs`: the two
495+ /// answer different questions and a test usually wants only one of them.
496+ branch_rows: Vec<BranchRow>,
497+ tag_rows: Vec<TagRow>,
494498 }
495499
496500 impl InMemoryGitQuery {
@@ -553,6 +557,36 @@ impl InMemoryGitQuery {
553557 self
554558 }
555559
560+ /// One row of the branches page. `is_default` is what git's `%(HEAD)` marks.
561+ pub fn with_branch_row(
562+ mut self,
563+ name: &str,
564+ is_default: bool,
565+ committed_at: SystemTime,
566+ ) -> Self {
567+ self.branch_rows.push(BranchRow {
568+ name: RefName::from_trusted(name),
569+ is_default,
570+ commit: ObjectId::from_trusted("2".repeat(40)),
571+ summary: format!("work on {name}"),
572+ committed_at,
573+ });
574+ self
575+ }
576+
577+ /// One row of the tags page. An annotated tag carries a message; a lightweight one
578+ /// has none of its own.
579+ pub fn with_tag_row(mut self, name: &str, annotated: bool, created_at: SystemTime) -> Self {
580+ self.tag_rows.push(TagRow {
581+ name: RefName::from_trusted(name),
582+ commit: ObjectId::from_trusted("3".repeat(40)),
583+ message: annotated.then(|| format!("release {name}")),
584+ annotated,
585+ created_at,
586+ });
587+ self
588+ }
589+
556590 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
557591 self.refs.push(GitRef {
558592 name: RefName::from_trusted(name),
@@ -648,6 +682,22 @@ impl GitQuery for InMemoryGitQuery {
648682 ) -> Result<Option<TagSummary>, GitQueryError> {
649683 Ok(self.latest_tag.clone())
650684 }
685+
686+ async fn branches(
687+ &self,
688+ _handle: &OrgName,
689+ _name: &RepoName,
690+ ) -> Result<Vec<BranchRow>, GitQueryError> {
691+ Ok(self.branch_rows.clone())
692+ }
693+
694+ async fn tags(
695+ &self,
696+ _handle: &OrgName,
697+ _name: &RepoName,
698+ ) -> Result<Vec<TagRow>, GitQueryError> {
699+ Ok(self.tag_rows.clone())
700+ }
651701 }
652702
653703 #[cfg(test)]
src/infrastructure/git_query.rs+438 −2View file
@@ -40,8 +40,8 @@ use tokio::io::AsyncWriteExt;
4040 use crate::{
4141 application::port::{Blob, GitQuery, GitQueryError},
4242 domain::{
43 CommitSummary, EntryKind, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
44 TagSummary, TreeEntry,
43+ BranchRow, CommitSummary, EntryKind, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName,
44+ RepoPath, TagRow, TagSummary, TreeEntry,
4545 },
4646 infrastructure::git::git_command,
4747 };
@@ -349,6 +349,48 @@ impl GitQuery for DiskGitQuery {
349349
350350 Ok(parse_latest_tag(&output.stdout))
351351 }
352+
353+ async fn branches(
354+ &self,
355+ handle: &OrgName,
356+ name: &RepoName,
357+ ) -> Result<Vec<BranchRow>, GitQueryError> {
358+ let repo = self.repo_path(handle, name);
359+
360+ // One process for the whole branches page. The sort is git's because it is
361+ // free there and would otherwise be a second pass in Rust over the same rows,
362+ // and `%(HEAD)` is what saves the page a `symbolic-ref` for the default branch.
363+ // Every argument is a literal — nothing from a URL reaches this call.
364+ let output = run(
365+ &repo,
366+ [
367+ OsStr::new("for-each-ref"),
368+ OsStr::new("--sort=-committerdate"),
369+ OsStr::new(BRANCH_FORMAT),
370+ OsStr::new("refs/heads/"),
371+ ],
372+ )
373+ .await?;
374+
375+ parse_branches(&output.stdout)
376+ }
377+
378+ async fn tags(&self, handle: &OrgName, name: &RepoName) -> Result<Vec<TagRow>, GitQueryError> {
379+ let repo = self.repo_path(handle, name);
380+
381+ let output = run(
382+ &repo,
383+ [
384+ OsStr::new("for-each-ref"),
385+ OsStr::new("--sort=-creatordate"),
386+ OsStr::new(TAG_ROW_FORMAT),
387+ OsStr::new("refs/tags/"),
388+ ],
389+ )
390+ .await?;
391+
392+ parse_tags(&output.stdout)
393+ }
352394 }
353395
354396 /// What `cat-file --batch-check` said about one object.
@@ -672,6 +714,162 @@ fn parse_latest_tag(stdout: &[u8]) -> Option<TagSummary> {
672714 })
673715 }
674716
717+/// What the branches page asks for, per branch.
718+///
719+/// `%(HEAD)` is git's own marker for the branch `HEAD` points at — `*` for it and a
720+/// space for everything else. It is asked for here rather than resolved separately
721+/// because a second process to learn one bit is the cost 0006 is about.
722+///
723+/// `%(objectname)` is the tip commit itself: a branch, unlike a tag, never points at
724+/// anything else.
725+const BRANCH_FORMAT: &str = "--format=%(refname)%00%(HEAD)%00%(objectname)%00%(committerdate:unix)%00%(contents:subject)%00";
726+
727+/// What the tags page asks for, per tag.
728+///
729+/// `%(objecttype)` is `tag` for an annotated tag and `commit` for a lightweight one,
730+/// which is the only reliable way to tell them apart. `%(*objectname)` is the peeled
731+/// object and is empty for a lightweight tag, so the commit is "the peeled one if there
732+/// is one". `%(contents:subject)` is the *tag's* message for an annotated tag and the
733+/// *commit's* for a lightweight one — so it is read only when the type says `tag`,
734+/// otherwise a lightweight tag would appear to carry a message it does not have.
735+const TAG_ROW_FORMAT: &str = "--format=%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%00";
736+
737+/// Splits `for-each-ref` output into its NUL-terminated fields.
738+///
739+/// Every field ends with a NUL and git adds a newline after each record that the format
740+/// cannot suppress, so the split yields exactly one field per `%00` plus a trailing
741+/// remainder holding that last newline — dropped here.
742+///
743+/// **Empty fields are kept.** A lightweight tag has no peeled object, and filtering
744+/// empties the way [`parse_latest_tag`] can afford to would shift every later field of
745+/// that record onto the wrong name.
746+fn ref_fields(stdout: &[u8]) -> Vec<&[u8]> {
747+ let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect();
748+ fields.pop();
749+ fields
750+}
751+
752+/// The first line of git's subject, or `None` when there is nothing to show.
753+///
754+/// `%(contents:subject)` is already one line, but that is git's invariant rather than
755+/// something this parser should assume — the same reason [`parse_log`] trims `%s`.
756+fn subject(field: &[u8]) -> Option<String> {
757+ let line = String::from_utf8_lossy(field)
758+ .lines()
759+ .next()
760+ .unwrap_or_default()
761+ .trim()
762+ .to_owned();
763+
764+ (!line.is_empty()).then_some(line)
765+}
766+
767+/// Parses [`BRANCH_FORMAT`] into rows, in the order git sorted them.
768+///
769+/// A record whose name or commit id Steid cannot use is skipped rather than failing the
770+/// page, exactly as [`parse_refs`] skips one: a branch that cannot be linked to is a
771+/// reason to leave a row out, not to refuse the whole list. A record with the wrong
772+/// number of fields is different — that is git saying something this code does not
773+/// understand, and it is an error.
774+fn parse_branches(stdout: &[u8]) -> Result<Vec<BranchRow>, GitQueryError> {
775+ let fields = ref_fields(stdout);
776+ let mut rows = Vec::with_capacity(fields.len() / 5);
777+
778+ for record in fields.chunks(5) {
779+ let [name, head, commit, committed_at, summary] = record[..] else {
780+ return Err(GitQueryError::new(
781+ "git listed a branch with missing fields",
782+ ));
783+ };
784+
785+ // git's trailing newline arrives in front of the next record's first field.
786+ // A ref name can contain neither a newline nor a space, so trimming cannot eat
787+ // part of one.
788+ let Some(name) = short_ref(name.trim_ascii(), "refs/heads/") else {
789+ continue;
790+ };
791+
792+ let Ok(commit) = ObjectId::new(String::from_utf8_lossy(commit).trim()) else {
793+ continue;
794+ };
795+
796+ let committed_at = String::from_utf8_lossy(committed_at);
797+ let Ok(committed_at) = committed_at.trim().parse::<i64>() else {
798+ continue;
799+ };
800+
801+ rows.push(BranchRow {
802+ name,
803+ // `*` for the branch HEAD names, a space for the rest.
804+ is_default: head.trim_ascii() == b"*",
805+ commit,
806+ summary: subject(summary).unwrap_or_default(),
807+ committed_at: unix_time(committed_at),
808+ });
809+ }
810+
811+ Ok(rows)
812+}
813+
814+/// Parses [`TAG_ROW_FORMAT`] into rows, in the order git sorted them.
815+///
816+/// Skips and errors on the same terms as [`parse_branches`].
817+fn parse_tags(stdout: &[u8]) -> Result<Vec<TagRow>, GitQueryError> {
818+ let fields = ref_fields(stdout);
819+ let mut rows = Vec::with_capacity(fields.len() / 6);
820+
821+ for record in fields.chunks(6) {
822+ let [name, kind, object, peeled, created_at, message] = record[..] else {
823+ return Err(GitQueryError::new("git listed a tag with missing fields"));
824+ };
825+
826+ let Some(name) = short_ref(name.trim_ascii(), "refs/tags/") else {
827+ continue;
828+ };
829+
830+ // An annotated tag's `objectname` is the tag object, so the thing worth linking
831+ // to is the peeled one. A lightweight tag has no peel and already names its
832+ // commit.
833+ let annotated = kind.trim_ascii() == b"tag";
834+ let id = if peeled.trim_ascii().is_empty() {
835+ object
836+ } else {
837+ peeled
838+ };
839+
840+ let Ok(commit) = ObjectId::new(String::from_utf8_lossy(id).trim()) else {
841+ continue;
842+ };
843+
844+ let created_at = String::from_utf8_lossy(created_at);
845+ let Ok(created_at) = created_at.trim().parse::<i64>() else {
846+ continue;
847+ };
848+
849+ rows.push(TagRow {
850+ name,
851+ commit,
852+ // Only an annotated tag has a message of its own; for a lightweight one
853+ // this field is the commit's subject, which belongs to the commit.
854+ message: annotated.then(|| subject(message)).flatten(),
855+ annotated,
856+ created_at: unix_time(created_at),
857+ });
858+ }
859+
860+ Ok(rows)
861+}
862+
863+/// A full ref name reduced to the short form Steid puts in a URL, or `None` when it is
864+/// outside the namespace asked for or is not a name Steid will hand back to git.
865+///
866+/// Validated rather than trusted for the reason [`parse_refs`] gives: this name is
867+/// about to become a link.
868+fn short_ref(full: &[u8], namespace: &str) -> Option<RefName> {
869+ let full = std::str::from_utf8(full).ok()?;
870+ RefName::new(full.strip_prefix(namespace)?).ok()
871+}
872+
675873 /// Runs a git command inside a repository and fails on a non-zero exit.
676874 ///
677875 /// Only ever used for commands whose subject has already been confirmed to exist, so a
@@ -1785,6 +1983,244 @@ mod tests {
17851983 );
17861984 }
17871985
1986+ // --- branches and tags ---------------------------------------------------------
1987+
1988+ /// The populated repository with two more branches, each left at an older commit so
1989+ /// the three tips carry three different dates — otherwise "newest first" is not
1990+ /// something a test can see.
1991+ fn with_branches() -> (TempDir, DiskGitQuery) {
1992+ let (dir, query) = populated();
1993+ let repo = query.repo_path(&handle(), &repo_name());
1994+ let work = dir.path().join("work");
1995+ let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1996+
1997+ git(&work, THIRD_COMMIT, &["branch", "stale", "main~2"]);
1998+ // A slash in the name, because that is the case the `/-/` separator exists for.
1999+ git(&work, THIRD_COMMIT, &["branch", "feature/login", "main~1"]);
2000+ git(
2001+ &work,
2002+ THIRD_COMMIT,
2003+ &["push", "--quiet", &target, "stale", "feature/login"],
2004+ );
2005+
2006+ (dir, query)
2007+ }
2008+
2009+ /// The populated repository with one lightweight tag and two annotated ones, made
2010+ /// on three different dates so ordering and the annotated/lightweight split can be
2011+ /// asserted together.
2012+ fn with_mixed_tags() -> (TempDir, DiskGitQuery) {
2013+ let (dir, query) = populated();
2014+ let repo = query.repo_path(&handle(), &repo_name());
2015+ let work = dir.path().join("work");
2016+ let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2017+
2018+ // Lightweight: no object of its own, so its date is the commit's.
2019+ git(&work, FIRST_COMMIT, &["tag", "v0.5", "main~2"]);
2020+ git(
2021+ &work,
2022+ SECOND_COMMIT,
2023+ &["tag", "-a", "v1.0", "-m", "first release"],
2024+ );
2025+ git(
2026+ &work,
2027+ THIRD_COMMIT,
2028+ &["tag", "-a", "v2.0", "-m", "second release\n\nnotes below"],
2029+ );
2030+ git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
2031+
2032+ (dir, query)
2033+ }
2034+
2035+ #[tokio::test]
2036+ async fn branches_are_newest_first_with_the_default_marked() {
2037+ let (_dir, query) = with_branches();
2038+
2039+ let rows = query
2040+ .branches(&handle(), &repo_name())
2041+ .await
2042+ .expect("should read");
2043+
2044+ assert_eq!(
2045+ rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
2046+ vec!["main", "feature/login", "stale"]
2047+ );
2048+ // `%(HEAD)` marks exactly one branch, and it is the one a bare repository's
2049+ // HEAD names — which is what the page pins to the top.
2050+ assert_eq!(
2051+ rows.iter()
2052+ .filter(|row| row.is_default)
2053+ .map(|row| row.name.as_str())
2054+ .collect::<Vec<_>>(),
2055+ vec!["main"]
2056+ );
2057+ }
2058+
2059+ #[tokio::test]
2060+ async fn a_branch_row_carries_its_tip_commit() {
2061+ let (_dir, query) = with_branches();
2062+
2063+ let rows = query
2064+ .branches(&handle(), &repo_name())
2065+ .await
2066+ .expect("should read");
2067+
2068+ let main = rows.first().expect("main is first");
2069+
2070+ // The subject only, from a message whose body would leak into it if the format
2071+ // were read line-wise.
2072+ assert_eq!(main.summary, "third: 'quotes', \"doubles\" | pipes");
2073+ assert_eq!(main.committed_at, unix_time(THIRD_COMMIT));
2074+ assert_eq!(main.commit.as_str().len(), 40);
2075+
2076+ let stale = rows.last().expect("stale is last");
2077+ assert_eq!(stale.summary, "first");
2078+ assert_eq!(stale.committed_at, unix_time(FIRST_COMMIT));
2079+ }
2080+
2081+ #[tokio::test]
2082+ async fn an_empty_repository_has_no_branches() {
2083+ // The same answer `list_refs` gives, and for the same reason: nothing pushed
2084+ // yet is not a failure. It is also how the page knows to show the push snippet.
2085+ let (_dir, query) = empty();
2086+
2087+ assert_eq!(
2088+ query
2089+ .branches(&handle(), &repo_name())
2090+ .await
2091+ .expect("should read"),
2092+ Vec::new()
2093+ );
2094+ }
2095+
2096+ #[tokio::test]
2097+ async fn tags_are_newest_first_and_only_annotated_ones_carry_a_message() {
2098+ let (_dir, query) = with_mixed_tags();
2099+
2100+ let rows = query
2101+ .tags(&handle(), &repo_name())
2102+ .await
2103+ .expect("should read");
2104+
2105+ assert_eq!(
2106+ rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
2107+ vec!["v2.0", "v1.0", "v0.5"]
2108+ );
2109+
2110+ let newest = &rows[0];
2111+ assert!(newest.annotated);
2112+ // The subject of the tag's own message, not its body.
2113+ assert_eq!(newest.message.as_deref(), Some("second release"));
2114+ assert_eq!(newest.created_at, unix_time(THIRD_COMMIT));
2115+
2116+ let lightweight = &rows[2];
2117+ assert!(!lightweight.annotated);
2118+ // A lightweight tag has no message of its own; the commit's subject is the
2119+ // commit's, and reporting it would invent one.
2120+ assert_eq!(lightweight.message, None);
2121+ assert_eq!(lightweight.created_at, unix_time(FIRST_COMMIT));
2122+ }
2123+
2124+ #[tokio::test]
2125+ async fn an_annotated_tag_reports_the_commit_it_peels_to() {
2126+ // Its `objectname` is the tag object, which is not what a visitor browses.
2127+ let (_dir, query) = with_mixed_tags();
2128+
2129+ let tip = query
2130+ .branches(&handle(), &repo_name())
2131+ .await
2132+ .expect("should read")
2133+ .into_iter()
2134+ .find(|row| row.name.as_str() == "main")
2135+ .expect("main");
2136+
2137+ let annotated = query
2138+ .tags(&handle(), &repo_name())
2139+ .await
2140+ .expect("should read")
2141+ .into_iter()
2142+ .find(|row| row.name.as_str() == "v1.0")
2143+ .expect("v1.0");
2144+
2145+ assert_eq!(annotated.commit, tip.commit);
2146+ }
2147+
2148+ #[tokio::test]
2149+ async fn a_repository_with_no_tags_lists_none() {
2150+ let (_dir, query) = populated();
2151+ assert_eq!(
2152+ query
2153+ .tags(&handle(), &repo_name())
2154+ .await
2155+ .expect("should read"),
2156+ Vec::new()
2157+ );
2158+
2159+ let (_dir, empty_query) = empty();
2160+ assert_eq!(
2161+ empty_query
2162+ .tags(&handle(), &repo_name())
2163+ .await
2164+ .expect("should read"),
2165+ Vec::new()
2166+ );
2167+ }
2168+
2169+ #[tokio::test]
2170+ async fn listing_rows_of_a_repository_that_is_not_on_disk_is_an_error() {
2171+ let (_dir, query) = empty();
2172+ let missing = RepoName::new("never-created").expect("valid repository name");
2173+
2174+ assert!(query.branches(&handle(), &missing).await.is_err());
2175+ assert!(query.tags(&handle(), &missing).await.is_err());
2176+ }
2177+
2178+ #[test]
2179+ fn branch_records_survive_the_newline_git_puts_between_them() {
2180+ let rows = parse_branches(
2181+ b"refs/heads/main\x00*\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x001700000000\x00first\x00\nrefs/heads/side\x00 \x00bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x001700000100\x00second\x00\n",
2182+ )
2183+ .expect("should parse");
2184+
2185+ assert_eq!(rows.len(), 2);
2186+ assert!(rows[0].is_default);
2187+ assert_eq!(rows[0].summary, "first");
2188+ // The newline in front of `refs/heads/side` is git's record separator, not part
2189+ // of the name.
2190+ assert_eq!(rows[1].name.as_str(), "side");
2191+ assert!(!rows[1].is_default);
2192+ assert_eq!(rows[1].committed_at, unix_time(1_700_000_100));
2193+ }
2194+
2195+ #[test]
2196+ fn a_lightweight_tags_empty_peel_does_not_shift_the_fields_after_it() {
2197+ // The reason `ref_fields` keeps empty fields: filtering them would read this
2198+ // record's date as its commit id.
2199+ let rows = parse_tags(
2200+ b"refs/tags/v1.0\x00commit\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x00\x001700000000\x00a commit subject\x00\n",
2201+ )
2202+ .expect("should parse");
2203+
2204+ assert_eq!(rows.len(), 1);
2205+ assert_eq!(rows[0].name.as_str(), "v1.0");
2206+ assert_eq!(rows[0].commit.as_str(), "a".repeat(40));
2207+ assert!(!rows[0].annotated);
2208+ assert_eq!(rows[0].message, None);
2209+ assert_eq!(rows[0].created_at, unix_time(1_700_000_000));
2210+ }
2211+
2212+ #[test]
2213+ fn nothing_is_parsed_from_an_empty_row_listing() {
2214+ assert_eq!(parse_branches(b"").expect("should parse"), Vec::new());
2215+ assert_eq!(parse_tags(b"").expect("should parse"), Vec::new());
2216+ }
2217+
2218+ #[test]
2219+ fn a_record_with_the_wrong_number_of_fields_is_a_fault() {
2220+ // Skipping a ref Steid cannot link to is right; misreading git's output is not.
2221+ assert!(parse_branches(b"refs/heads/main\x00*\x00\n").is_err());
2222+ }
2223+
17882224 // --- helpers ------------------------------------------------------------------
17892225
17902226 #[tokio::test]