steid

@jamesgill /

steid/src/infrastructure/git_query.rs
77.2 KBCode·Blame·Raw
1//! Reading repository contents through the `git` binary.
2//!
3//! One process per question, per the Milestone 5 amendment to
4//! [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md): ~11–12ms of
5//! that is `execve`, and the upgrade to a kept-alive `cat-file --batch` is an adapter
6//! change behind this unchanged port.
7//!
8//! # Telling "not there" from "broken"
9//!
10//! git reports both with a non-zero exit, and at different call sites with *different*
11//! non-zero exits: `rev-parse --verify --quiet` says 1 for an unknown ref but 128 for a
12//! missing repository, while `ls-tree` pointed at a blob says 128 for what is, to a
13//! visitor, a 404. Keying off exit codes therefore either turns a typo'd URL into a 500
14//! or buries a corrupt repository behind a "not found".
15//!
16//! So every existence question here goes through one command that does not use its exit
17//! status to answer: `git cat-file --batch-check` writes `<spec> missing` on stdout and
18//! **exits 0** for anything it cannot resolve — an unknown ref, an absent path, a path
19//! traversing through a blob, a submodule's commit that lives in another repository.
20//! That gives a single rule for this whole module:
21//!
22//! **A non-zero exit from git is always an error.** "Not found" is a value read off
23//! stdout, never an exit code. Everything else — git missing from `PATH`, a repository
24//! directory that is gone, an unreadable object store — surfaces as [`GitQueryError`]
25//! carrying git's own words.
26//!
27//! The listing and content commands are only ever reached *after* `--batch-check` has
28//! confirmed the object and its type, and are handed the resolved object id rather than
29//! the user's revision, so their failure modes are genuinely faults.
30
31use std::{
32 ffi::OsStr,
33 path::{Path, PathBuf},
34 process::{Output, Stdio},
35 time::{Duration, SystemTime, UNIX_EPOCH},
36};
37
38use tokio::io::AsyncWriteExt;
39
40use crate::{
41 application::port::{Blob, GitQuery, GitQueryError},
42 domain::{
43 BranchRow, CommitSummary, EntryKind, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName,
44 RepoPath, TagRow, TagSummary, TreeEntry,
45 },
46 infrastructure::git::git_command,
47};
48
49/// The words `cat-file --batch-check` ends a line with when it did not resolve a spec.
50///
51/// `missing` covers the common cases; `ambiguous` is an abbreviated id matching more
52/// than one object, and `dangling` and `notdir` appear when following a `^{}` or a path
53/// through something that cannot hold one. None of them is a failure — they are the
54/// answer "no such thing here".
55const NOT_FOUND_MARKERS: [&str; 4] = ["missing", "ambiguous", "dangling", "notdir"];
56
57/// Repository contents, read from bare repositories under a data directory.
58#[derive(Debug, Clone)]
59pub struct DiskGitQuery {
60 data_dir: PathBuf,
61}
62
63impl DiskGitQuery {
64 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
65 Self {
66 data_dir: data_dir.into(),
67 }
68 }
69
70 /// Where a repository lives, matching `DiskGitStorage`'s layout.
71 pub(crate) fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
72 self.data_dir
73 .join(handle.as_str())
74 .join(format!("{name}.git"))
75 }
76}
77
78impl GitQuery for DiskGitQuery {
79 async fn default_branch(
80 &self,
81 handle: &OrgName,
82 name: &RepoName,
83 ) -> Result<Option<RefName>, GitQueryError> {
84 let repo = self.repo_path(handle, name);
85
86 // An empty repository's HEAD names a branch that does not exist yet, so
87 // `symbolic-ref` happily answers `main` for a repository with nothing in it.
88 // Whether HEAD *resolves* is the actual question, and it is asked first.
89 let Some(head) = object_info(&repo, "HEAD").await? else {
90 return Ok(None);
91 };
92
93 let branch = run(&repo, [OsStr::new("symbolic-ref"), OsStr::new("HEAD")]).await;
94
95 match branch {
96 Ok(output) => {
97 let full = String::from_utf8_lossy(&output.stdout).trim().to_owned();
98 // `refs/heads/main` rather than `--short`, because `--short` shortens
99 // only as far as is unambiguous and would hand back `heads/main` for a
100 // repository that also has a tag called `main`.
101 let short = full.strip_prefix("refs/heads/").unwrap_or(&full);
102
103 Ok(Some(RefName::from_trusted(short)))
104 }
105 // A detached HEAD is not a state Steid creates, but a repository pushed into
106 // from elsewhere can be in it. The commit is still browsable, so name it
107 // rather than claiming the repository is empty — which is what `Ok(None)`
108 // would mean to a page.
109 Err(_) => Ok(Some(RefName::from_trusted(head.id.as_str()))),
110 }
111 }
112
113 async fn resolve(
114 &self,
115 handle: &OrgName,
116 name: &RepoName,
117 rev: &RefName,
118 ) -> Result<Option<ObjectId>, GitQueryError> {
119 let repo = self.repo_path(handle, name);
120
121 // `^{commit}` peels an annotated tag to what it points at, and refuses a
122 // revision that names a tree or a blob — a browse page wants a commit, and
123 // returning a tree id here would fail confusingly two calls later.
124 let spec = format!("{}^{{commit}}", rev.as_str());
125
126 Ok(object_info(&repo, &spec).await?.map(|info| info.id))
127 }
128
129 async fn list_tree(
130 &self,
131 handle: &OrgName,
132 name: &RepoName,
133 rev: &RefName,
134 path: &RepoPath,
135 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
136 let repo = self.repo_path(handle, name);
137
138 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
139 return Ok(None);
140 };
141
142 // A file is not a directory. Asking `ls-tree` anyway is a fatal error, which is
143 // exactly the confusion this check exists to avoid.
144 if info.kind != ObjectKind::Tree {
145 return Ok(None);
146 }
147
148 // `-z` because a filename may contain a newline, and `--long` for blob sizes.
149 // The already-resolved tree id is passed rather than the user's revision, so
150 // nothing here has to think about what git's revision parser might make of it.
151 let output = run(
152 &repo,
153 [
154 OsStr::new("ls-tree"),
155 OsStr::new("-z"),
156 OsStr::new("--long"),
157 OsStr::new(info.id.as_str()),
158 ],
159 )
160 .await?;
161
162 parse_tree(&output.stdout).map(Some)
163 }
164
165 async fn read_blob(
166 &self,
167 handle: &OrgName,
168 name: &RepoName,
169 rev: &RefName,
170 path: &RepoPath,
171 max_bytes: u64,
172 ) -> Result<Option<Blob>, GitQueryError> {
173 let repo = self.repo_path(handle, name);
174
175 // The root is a tree, and `{rev}:` is how git spells it — but a caller asking to
176 // read the root is asking for a file that is not there.
177 if path.is_root() {
178 return Ok(None);
179 }
180
181 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
182 return Ok(None);
183 };
184
185 // Symlinks are blobs whose content is the target path, and are read as such:
186 // showing where a link points is more use than a blank page. Trees and
187 // submodules are not files.
188 if info.kind != ObjectKind::Blob {
189 return Ok(None);
190 }
191
192 // The size comes from the object header, so an oversized file is never read.
193 // Doing this the other way round — read, then measure — is how one URL becomes
194 // an out-of-memory kill.
195 let content = if info.size > max_bytes {
196 None
197 } else {
198 let output = run(
199 &repo,
200 [
201 OsStr::new("cat-file"),
202 OsStr::new("blob"),
203 OsStr::new(info.id.as_str()),
204 ],
205 )
206 .await?;
207
208 Some(output.stdout)
209 };
210
211 Ok(Some(Blob {
212 id: info.id,
213 size: info.size,
214 content,
215 }))
216 }
217
218 async fn log(
219 &self,
220 handle: &OrgName,
221 name: &RepoName,
222 rev: &RefName,
223 limit: usize,
224 ) -> Result<Vec<CommitSummary>, GitQueryError> {
225 let repo = self.repo_path(handle, name);
226
227 // `git log` on a repository with no commits is a fatal error, and so is a log of
228 // a branch that does not exist. Resolving first turns both into the empty list
229 // the port's signature promises, without having to read meaning into a stderr
230 // string that is localised and free to change between git versions.
231 let Some(commit) = self.resolve(handle, name, rev).await? else {
232 return Ok(Vec::new());
233 };
234
235 if limit == 0 {
236 return Ok(Vec::new());
237 }
238
239 // Every separator is a NUL: `-z` between commits, `%x00` between fields. A
240 // commit message contains newlines as a matter of course, and a name can contain
241 // almost anything, so splitting on lines or whitespace would misread real
242 // history rather than exotic history.
243 let format = "--format=%H%x00%ct%x00%an%x00%s";
244 let count = format!("--max-count={limit}");
245
246 let output = run(
247 &repo,
248 [
249 OsStr::new("log"),
250 OsStr::new("-z"),
251 OsStr::new(&count),
252 OsStr::new(format),
253 OsStr::new(commit.as_str()),
254 ],
255 )
256 .await?;
257
258 parse_log(&output.stdout)
259 }
260
261 async fn list_refs(
262 &self,
263 handle: &OrgName,
264 name: &RepoName,
265 ) -> Result<Vec<GitRef>, GitQueryError> {
266 let repo = self.repo_path(handle, name);
267
268 // One fork, ~14ms — see the port's note. `for-each-ref` is asked for both
269 // namespaces at once rather than once each, because the cost here is the
270 // process, not the question.
271 //
272 // Every field separator is a NUL, for the same reason `log` uses one: a tag name
273 // is close to arbitrary text once git's own restrictions are met, and splitting
274 // on whitespace would misread a real name. The patterns are literals, so unlike
275 // a revision from a URL there is nothing here that could be read as a flag.
276 let output = run(
277 &repo,
278 [
279 OsStr::new("for-each-ref"),
280 OsStr::new(REF_FORMAT),
281 OsStr::new("refs/heads/"),
282 OsStr::new("refs/tags/"),
283 ],
284 )
285 .await?;
286
287 Ok(parse_refs(&output.stdout))
288 }
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 }
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 }
394}
395
396/// What `cat-file --batch-check` said about one object.
397#[derive(Debug, Clone, PartialEq, Eq)]
398struct ObjectInfo {
399 id: ObjectId,
400 kind: ObjectKind,
401 size: u64,
402}
403
404/// A git object's type, as its header spells it.
405///
406/// Distinct from [`EntryKind`], which is about what a tree entry *means* — the object
407/// store cannot tell a symlink from a file, because both are blobs.
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409enum ObjectKind {
410 Blob,
411 Tree,
412 Commit,
413 Tag,
414}
415
416impl ObjectKind {
417 fn from_str(value: &str) -> Option<Self> {
418 match value {
419 "blob" => Some(Self::Blob),
420 "tree" => Some(Self::Tree),
421 "commit" => Some(Self::Commit),
422 "tag" => Some(Self::Tag),
423 _ => None,
424 }
425 }
426}
427
428/// How git addresses a path inside a revision: `{rev}:{path}`, and `{rev}:` for the root.
429fn tree_spec(rev: &RefName, path: &RepoPath) -> String {
430 format!("{}:{}", rev.as_str(), path.as_str())
431}
432
433/// Asks git what one revision-and-path resolves to, or `None` if it resolves to nothing.
434///
435/// The spec goes over stdin rather than in an argument, so no revision or path can ever
436/// be read as a flag regardless of what validation upstream does or stops doing.
437async fn object_info(repo: &Path, spec: &str) -> Result<Option<ObjectInfo>, GitQueryError> {
438 let mut command = git_command();
439 command
440 .arg("-C")
441 .arg(repo)
442 .arg("cat-file")
443 .arg("--batch-check")
444 .stdin(Stdio::piped())
445 .stdout(Stdio::piped())
446 .stderr(Stdio::piped());
447
448 let mut child = command
449 .spawn()
450 .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
451
452 let mut stdin = child.stdin.take().expect("stdin was piped");
453
454 // One short line, far below a pipe's buffer, so writing before waiting cannot
455 // deadlock. Dropping stdin is what ends the batch — git would otherwise wait for
456 // another spec forever.
457 stdin
458 .write_all(format!("{spec}\n").as_bytes())
459 .await
460 .map_err(|error| {
461 GitQueryError::new(format!("could not ask git about {spec:?}: {error}"))
462 })?;
463 drop(stdin);
464
465 let output = child
466 .wait_with_output()
467 .await
468 .map_err(|error| GitQueryError::new(format!("could not wait for git: {error}")))?;
469
470 // Per the module note: a non-zero exit here is never "not found".
471 if !output.status.success() {
472 return Err(GitQueryError::new(format!(
473 "git exited with {} looking up {spec:?}: {}",
474 output.status,
475 String::from_utf8_lossy(&output.stderr).trim()
476 )));
477 }
478
479 let line = String::from_utf8_lossy(&output.stdout);
480 let line = line.trim_end_matches('\n');
481
482 // The marker is checked before the field count, because a not-found line echoes the
483 // spec back — and a spec naming a file with spaces in it has no fixed field count.
484 if line
485 .rsplit(' ')
486 .next()
487 .is_some_and(|last| NOT_FOUND_MARKERS.contains(&last))
488 {
489 return Ok(None);
490 }
491
492 let fields: Vec<&str> = line.split_whitespace().collect();
493 let [id, kind, size] = fields[..] else {
494 return Err(GitQueryError::new(format!(
495 "git described {spec:?} in a shape we do not understand: {line:?}"
496 )));
497 };
498
499 Ok(Some(ObjectInfo {
500 // Validated rather than trusted. git's ids are trustworthy, but this is a parse
501 // of text whose layout we have assumed, and an id is about to appear in a URL —
502 // a misread field should stop here rather than surface as a broken link. The
503 // cost is a length and hex check next to a process spawn.
504 id: ObjectId::new(id)
505 .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))?,
506 kind: ObjectKind::from_str(kind).ok_or_else(|| {
507 GitQueryError::new(format!("git reported an unknown object type {kind:?}"))
508 })?,
509 size: size.parse().map_err(|_| {
510 GitQueryError::new(format!("git reported an unreadable object size {size:?}"))
511 })?,
512 }))
513}
514
515/// Parses `ls-tree -z --long` output.
516///
517/// Each record is `<mode> SP <type> SP <id> SP <size> TAB <name>`, NUL-terminated, where
518/// the size is space-padded and `-` for anything that is not a blob. The name is
519/// everything after the first tab and is *raw bytes* — which is why the split happens
520/// before any attempt to read it as text.
521fn parse_tree(stdout: &[u8]) -> Result<Vec<TreeEntry>, GitQueryError> {
522 let mut entries = Vec::new();
523
524 for record in stdout.split(|byte| *byte == 0) {
525 if record.is_empty() {
526 continue;
527 }
528
529 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
530 return Err(GitQueryError::new(
531 "git listed a tree entry with no name separator",
532 ));
533 };
534
535 let (meta, name) = record.split_at(tab);
536 let name = &name[1..];
537
538 let meta = std::str::from_utf8(meta).map_err(|_| {
539 GitQueryError::new("git listed a tree entry whose metadata is not text")
540 })?;
541
542 let fields: Vec<&str> = meta.split_whitespace().collect();
543 let [mode, _type, id, size] = fields[..] else {
544 return Err(GitQueryError::new(format!(
545 "git listed a tree entry in a shape we do not understand: {meta:?}"
546 )));
547 };
548
549 entries.push(TreeEntry {
550 // Lossy, because `TreeEntry::name` is a `String` and a filename is not
551 // required to be UTF-8. A replacement character renders; refusing to list
552 // the whole directory because one file has an odd name does not.
553 name: String::from_utf8_lossy(name).into_owned(),
554 kind: EntryKind::from_mode(mode)
555 .map_err(|error| GitQueryError::new(format!("git listed {name:?}: {error}")))?,
556 id: ObjectId::new(id).map_err(|error| {
557 GitQueryError::new(format!("git named a bad object id: {error}"))
558 })?,
559 // `-` for a tree or a submodule, which have no size a listing can show.
560 size: size.parse().ok(),
561 });
562 }
563
564 // Unsorted on purpose: ordering is `TreeEntry::ordering_key`'s decision, made once
565 // in the application rather than differently in each adapter.
566 Ok(entries)
567}
568
569/// Parses the NUL-separated `log` stream into four-field records.
570fn parse_log(stdout: &[u8]) -> Result<Vec<CommitSummary>, GitQueryError> {
571 // `-z` terminates the last record too, so the split leaves a trailing empty field
572 // that is not a commit.
573 let fields: Vec<&[u8]> = stdout
574 .split(|byte| *byte == 0)
575 .filter(|field| !field.is_empty())
576 .collect();
577
578 let mut commits = Vec::with_capacity(fields.len() / 4);
579
580 for record in fields.chunks(4) {
581 let [id, committed_at, author_name, summary] = record[..] else {
582 return Err(GitQueryError::new(
583 "git logged a commit with missing fields",
584 ));
585 };
586
587 let id = String::from_utf8_lossy(id);
588 let committed_at = String::from_utf8_lossy(committed_at);
589 let committed_at: i64 = committed_at.trim().parse().map_err(|_| {
590 GitQueryError::new(format!(
591 "git logged an unreadable commit time {committed_at:?}"
592 ))
593 })?;
594
595 commits.push(CommitSummary {
596 id: ObjectId::new(id.trim()).map_err(|error| {
597 GitQueryError::new(format!("git named a bad object id: {error}"))
598 })?,
599 // `%s` is git's subject: the first paragraph, joined into one line. Trimmed
600 // to the first line anyway, because that invariant is git's rather than
601 // something this parser should assume.
602 summary: String::from_utf8_lossy(summary)
603 .lines()
604 .next()
605 .unwrap_or_default()
606 .to_owned(),
607 author_name: String::from_utf8_lossy(author_name).into_owned(),
608 committed_at: unix_time(committed_at),
609 });
610 }
611
612 Ok(commits)
613}
614
615/// A unix timestamp as a `SystemTime`, including the negative ones.
616///
617/// A commit dated before 1970 is either a lie or an import from something older than
618/// git, and both exist in real repositories. `UNIX_EPOCH + Duration` would panic on the
619/// subtraction it cannot do.
620fn unix_time(seconds: i64) -> SystemTime {
621 match u64::try_from(seconds) {
622 Ok(seconds) => UNIX_EPOCH + Duration::from_secs(seconds),
623 Err(_) => UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs()),
624 }
625}
626
627/// What `for-each-ref` prints per ref: the full name and the object it names,
628/// NUL-separated and NUL-terminated.
629///
630/// The kind is *not* asked for. `%(objecttype)` says `commit` for both a branch and a
631/// lightweight tag, so the namespace in the name is the only thing that answers which
632/// one a visitor asked for.
633const REF_FORMAT: &str = "--format=%(refname)%00";
634
635/// Parses `for-each-ref`'s NUL-separated output into branches and tags.
636///
637/// Each record is `<full refname> NUL`, and git ends every record with a newline of its
638/// own that the format cannot suppress — so the newline arrives at the *front* of the
639/// next record's first field and is trimmed off. A ref name can contain neither a
640/// newline nor a space, so trimming cannot eat part of a name.
641fn parse_refs(stdout: &[u8]) -> Vec<GitRef> {
642 let mut refs = Vec::new();
643
644 for record in stdout.split(|byte| *byte == 0) {
645 let record = record.trim_ascii();
646
647 if record.is_empty() {
648 continue;
649 }
650
651 // Lossy would be wrong here: a name that is not UTF-8 cannot be put in a URL,
652 // and offering a link that cannot work is worse than leaving the ref out of the
653 // switcher. It is still browsable by object id.
654 let Ok(full) = std::str::from_utf8(record) else {
655 continue;
656 };
657
658 let (kind, short) = if let Some(short) = full.strip_prefix("refs/heads/") {
659 (RefKind::Branch, short)
660 } else if let Some(short) = full.strip_prefix("refs/tags/") {
661 (RefKind::Tag, short)
662 } else {
663 // Only the two namespaces were asked for, so this cannot happen — and if a
664 // future pattern is added and this is forgotten, skipping is the safe half
665 // of the mistake.
666 continue;
667 };
668
669 // Validated rather than trusted: this name is about to become a URL, and
670 // `RefName` is what decides a name is safe to hand back to git. A ref git
671 // accepts but Steid's rules do not is left out rather than linked to.
672 let Ok(name) = RefName::new(short) else {
673 continue;
674 };
675
676 refs.push(GitRef { name, kind });
677 }
678
679 refs
680}
681
682/// What the latest-tag query asks for: the full ref name and its creation time.
683///
684/// `creatordate` rather than `taggerdate`, which is empty for a lightweight tag, or
685/// `committerdate`, which is empty for an annotated one. `creatordate` is git's own
686/// "whichever of those this ref has".
687const TAG_FORMAT: &str = "--format=%(refname)%00%(creatordate:unix)%00";
688
689/// Parses the one record [`TAG_FORMAT`] produces, or `None` for a repository with no
690/// tags.
691///
692/// Anything unreadable is `None` rather than an error: this decorates a page with a
693/// fact, and a tag whose name is not UTF-8 or whose date git spelled unexpectedly is a
694/// reason to say nothing, not to fail the repository's landing page.
695fn parse_latest_tag(stdout: &[u8]) -> Option<TagSummary> {
696 let fields: Vec<&[u8]> = stdout
697 .split(|byte| *byte == 0)
698 .map(<[u8]>::trim_ascii)
699 .filter(|field| !field.is_empty())
700 .collect();
701
702 let [name, created_at] = fields[..] else {
703 return None;
704 };
705
706 let short = std::str::from_utf8(name).ok()?.strip_prefix("refs/tags/")?;
707 let created_at: i64 = std::str::from_utf8(created_at).ok()?.parse().ok()?;
708
709 Some(TagSummary {
710 // Validated rather than trusted, exactly as `parse_refs` does: the name is
711 // about to become a link.
712 name: RefName::new(short).ok()?,
713 created_at: unix_time(created_at),
714 })
715}
716
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.
725const 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.
735const 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.
746fn 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`.
756fn 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.
774fn 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`].
817fn 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.
868fn 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
873/// Runs a git command inside a repository and fails on a non-zero exit.
874///
875/// Only ever used for commands whose subject has already been confirmed to exist, so a
876/// failure really is a failure. Built from [`git_command`] so the host isolation 0006
877/// insists on cannot drift out of this module.
878/// How long one read-side `git` process may run before it is killed.
879///
880/// Every read here is a subprocess on a request path, and nothing bounded it before
881/// blame and grep arrived — either can run for a long time on a large repository, and
882/// a request that never finishes holds a worker for as long as the client waits.
883/// Twenty seconds is far above any read a page should make and far below "hung".
884const GIT_TIMEOUT: Duration = Duration::from_secs(20);
885
886async fn run<I, S>(repo: &Path, args: I) -> Result<Output, GitQueryError>
887where
888 I: IntoIterator<Item = S>,
889 S: AsRef<OsStr>,
890{
891 run_within(repo, args, GIT_TIMEOUT).await
892}
893
894/// [`run`] with an explicit limit, so the timeout path can be tested without waiting
895/// twenty seconds for it.
896async fn run_within<I, S>(repo: &Path, args: I, limit: Duration) -> Result<Output, GitQueryError>
897where
898 I: IntoIterator<Item = S>,
899 S: AsRef<OsStr>,
900{
901 let mut command = git_command();
902 command
903 .arg("-C")
904 .arg(repo)
905 .args(args)
906 .stdin(Stdio::null())
907 // Dropping the future on timeout must take the process with it, or a killed
908 // request leaves git running to completion for nobody.
909 .kill_on_drop(true);
910
911 let output = match tokio::time::timeout(limit, command.output()).await {
912 Ok(result) => {
913 result.map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?
914 }
915 Err(_elapsed) => return Err(GitQueryError::timed_out(limit)),
916 };
917
918 if !output.status.success() {
919 return Err(GitQueryError::new(format!(
920 "git exited with {}: {}",
921 output.status,
922 String::from_utf8_lossy(&output.stderr).trim()
923 )));
924 }
925
926 Ok(output)
927}
928
929#[cfg(test)]
930mod tests {
931 use std::collections::HashMap;
932
933 use tempfile::TempDir;
934
935 use super::*;
936 use crate::domain::EntryKind;
937
938 /// Fixed so a timestamp assertion is exact rather than approximate.
939 const FIRST_COMMIT: i64 = 1_700_000_000;
940 const SECOND_COMMIT: i64 = 1_700_000_100;
941 const THIRD_COMMIT: i64 = 1_700_000_200;
942
943 /// A subject with the punctuation a naive parser splits on, followed by a body — so
944 /// a test can prove the body does not leak into the summary.
945 const ODD_MESSAGE: &str =
946 "third: 'quotes', \"doubles\" | pipes\n\nbody line one\nbody line two";
947
948 const BINARY: &[u8] = &[0x00, 0x01, 0xff, 0xfe, 0x80];
949
950 fn handle() -> OrgName {
951 OrgName::new("jamesgill").expect("valid handle")
952 }
953
954 fn repo_name() -> RepoName {
955 RepoName::new("steid").expect("valid repository name")
956 }
957
958 fn rev(value: &str) -> RefName {
959 RefName::new(value).expect("valid revision")
960 }
961
962 fn path(value: &str) -> RepoPath {
963 RepoPath::new(value).expect("valid path")
964 }
965
966 /// Runs git in a fixture, isolated from the host's configuration the same way the
967 /// adapter is — otherwise a developer's `commit.gpgsign` or `init.defaultBranch`
968 /// decides whether the suite passes.
969 fn git(dir: &Path, when: i64, args: &[&str]) {
970 let date = format!("@{when} +0000");
971
972 let output = std::process::Command::new("git")
973 .arg("-C")
974 .arg(dir)
975 .args(args)
976 .env("GIT_CONFIG_GLOBAL", "/dev/null")
977 .env("GIT_CONFIG_SYSTEM", "/dev/null")
978 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
979 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
980 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
981 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
982 .env("GIT_AUTHOR_DATE", &date)
983 .env("GIT_COMMITTER_DATE", &date)
984 .output()
985 .expect("git should be on PATH");
986
987 assert!(
988 output.status.success(),
989 "git {args:?} failed: {}",
990 String::from_utf8_lossy(&output.stderr)
991 );
992 }
993
994 /// A data directory holding one empty bare repository, exactly as Steid creates it.
995 ///
996 /// The `TempDir` is returned because dropping it deletes the fixture.
997 fn empty() -> (TempDir, DiskGitQuery) {
998 let dir = TempDir::new().expect("temp dir");
999 let query = DiskGitQuery::new(dir.path());
1000 let repo = query.repo_path(&handle(), &repo_name());
1001
1002 std::fs::create_dir_all(repo.parent().expect("has a parent")).expect("create handle dir");
1003 git(
1004 dir.path(),
1005 FIRST_COMMIT,
1006 &[
1007 "init",
1008 "--bare",
1009 "--quiet",
1010 "--template=",
1011 "--initial-branch=main",
1012 "--",
1013 repo.to_str().expect("utf-8 fixture path"),
1014 ],
1015 );
1016
1017 (dir, query)
1018 }
1019
1020 /// The empty repository with three commits pushed into it, the way a real one fills
1021 /// up — a working copy and a push, rather than plumbing straight into the object
1022 /// store.
1023 fn populated() -> (TempDir, DiskGitQuery) {
1024 let (dir, query) = empty();
1025 let repo = query.repo_path(&handle(), &repo_name());
1026 let work = dir.path().join("work");
1027
1028 std::fs::create_dir_all(work.join("src/deep")).expect("create work tree");
1029 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
1030
1031 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
1032 std::fs::write(work.join("with space.txt"), b"spaced\n").expect("write");
1033 std::fs::write(work.join("bin.dat"), BINARY).expect("write");
1034 std::fs::write(work.join("big.txt"), vec![b'x'; 100]).expect("write");
1035 std::fs::write(work.join("src/deep/file.rs"), b"fn main() {}\n").expect("write");
1036 std::os::unix::fs::symlink("README.md", work.join("link")).expect("symlink");
1037
1038 git(&work, FIRST_COMMIT, &["add", "-A"]);
1039 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
1040
1041 std::fs::write(work.join("README.md"), b"hello again\n").expect("write");
1042 git(&work, SECOND_COMMIT, &["add", "-A"]);
1043 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
1044
1045 git(
1046 &work,
1047 THIRD_COMMIT,
1048 &["commit", "--quiet", "--allow-empty", "-m", ODD_MESSAGE],
1049 );
1050
1051 git(
1052 &work,
1053 THIRD_COMMIT,
1054 &[
1055 "push",
1056 "--quiet",
1057 repo.to_str().expect("utf-8 fixture path"),
1058 "main",
1059 ],
1060 );
1061
1062 (dir, query)
1063 }
1064
1065 /// A listing keyed by name, so an assertion does not depend on an order the port
1066 /// explicitly does not promise.
1067 fn by_name(entries: Vec<TreeEntry>) -> HashMap<String, TreeEntry> {
1068 entries
1069 .into_iter()
1070 .map(|entry| (entry.name.clone(), entry))
1071 .collect()
1072 }
1073
1074 // --- an empty repository ---------------------------------------------------
1075
1076 #[tokio::test]
1077 async fn an_empty_repository_has_no_default_branch() {
1078 // The distinction the port exists for: HEAD names `main`, but `main` has no
1079 // commits, so "nothing pushed yet" rather than a branch a page can browse.
1080 let (_dir, query) = empty();
1081
1082 assert_eq!(
1083 query
1084 .default_branch(&handle(), &repo_name())
1085 .await
1086 .expect("should read"),
1087 None
1088 );
1089 }
1090
1091 #[tokio::test]
1092 async fn nothing_resolves_in_an_empty_repository() {
1093 let (_dir, query) = empty();
1094
1095 for revision in ["main", "HEAD", "v1.0"] {
1096 assert_eq!(
1097 query
1098 .resolve(&handle(), &repo_name(), &rev(revision))
1099 .await
1100 .expect("should read"),
1101 None,
1102 "{revision} should not resolve"
1103 );
1104 }
1105 }
1106
1107 #[tokio::test]
1108 async fn an_empty_repository_lists_nothing_and_reads_nothing() {
1109 let (_dir, query) = empty();
1110
1111 assert_eq!(
1112 query
1113 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1114 .await
1115 .expect("should read"),
1116 None
1117 );
1118 assert_eq!(
1119 query
1120 .read_blob(
1121 &handle(),
1122 &repo_name(),
1123 &rev("main"),
1124 &path("README.md"),
1125 1024
1126 )
1127 .await
1128 .expect("should read"),
1129 None
1130 );
1131 }
1132
1133 #[tokio::test]
1134 async fn an_empty_repository_has_an_empty_log() {
1135 // `git log` is a fatal error here, and an empty list is what the port promises.
1136 let (_dir, query) = empty();
1137
1138 assert_eq!(
1139 query
1140 .log(&handle(), &repo_name(), &rev("main"), 10)
1141 .await
1142 .expect("should read"),
1143 Vec::new()
1144 );
1145 }
1146
1147 // --- a missing repository is a failure, not a 404 ---------------------------
1148
1149 #[tokio::test]
1150 async fn a_repository_that_is_not_on_disk_is_an_error() {
1151 // A record with no directory is a fault to investigate, not a "no such branch".
1152 // Answering `Ok(None)` here would hide it behind a plausible-looking 404.
1153 let (_dir, query) = empty();
1154 let missing = RepoName::new("never-created").expect("valid repository name");
1155
1156 assert!(query.default_branch(&handle(), &missing).await.is_err());
1157 assert!(
1158 query
1159 .resolve(&handle(), &missing, &rev("main"))
1160 .await
1161 .is_err()
1162 );
1163 assert!(
1164 query
1165 .list_tree(&handle(), &missing, &rev("main"), &RepoPath::root())
1166 .await
1167 .is_err()
1168 );
1169 assert!(
1170 query
1171 .log(&handle(), &missing, &rev("main"), 10)
1172 .await
1173 .is_err()
1174 );
1175 }
1176
1177 // --- default_branch and resolve --------------------------------------------
1178
1179 #[tokio::test]
1180 async fn a_repository_with_commits_reports_its_default_branch() {
1181 let (_dir, query) = populated();
1182
1183 assert_eq!(
1184 query
1185 .default_branch(&handle(), &repo_name())
1186 .await
1187 .expect("should read"),
1188 Some(RefName::from_trusted("main"))
1189 );
1190 }
1191
1192 #[tokio::test]
1193 async fn a_branch_and_head_resolve_to_the_same_commit() {
1194 let (_dir, query) = populated();
1195
1196 let main = query
1197 .resolve(&handle(), &repo_name(), &rev("main"))
1198 .await
1199 .expect("should read")
1200 .expect("main should resolve");
1201 let head = query
1202 .resolve(&handle(), &repo_name(), &rev("HEAD"))
1203 .await
1204 .expect("should read");
1205
1206 assert_eq!(head, Some(main));
1207 }
1208
1209 #[tokio::test]
1210 async fn a_commit_id_resolves_to_itself() {
1211 let (_dir, query) = populated();
1212
1213 let main = query
1214 .resolve(&handle(), &repo_name(), &rev("main"))
1215 .await
1216 .expect("should read")
1217 .expect("main should resolve");
1218
1219 assert_eq!(
1220 query
1221 .resolve(&handle(), &repo_name(), &rev(main.as_str()))
1222 .await
1223 .expect("should read"),
1224 Some(main)
1225 );
1226 }
1227
1228 #[tokio::test]
1229 async fn an_unknown_revision_resolves_to_nothing() {
1230 let (_dir, query) = populated();
1231
1232 assert_eq!(
1233 query
1234 .resolve(&handle(), &repo_name(), &rev("no-such-branch"))
1235 .await
1236 .expect("looking up a missing branch is not a failure"),
1237 None
1238 );
1239 }
1240
1241 // --- list_tree --------------------------------------------------------------
1242
1243 #[tokio::test]
1244 async fn the_root_lists_every_top_level_entry() {
1245 let (_dir, query) = populated();
1246
1247 let entries = by_name(
1248 query
1249 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1250 .await
1251 .expect("should read")
1252 .expect("the root is a directory"),
1253 );
1254
1255 let mut names: Vec<&str> = entries.keys().map(String::as_str).collect();
1256 names.sort_unstable();
1257 assert_eq!(
1258 names,
1259 vec![
1260 "README.md",
1261 "big.txt",
1262 "bin.dat",
1263 "link",
1264 "src",
1265 "with space.txt"
1266 ]
1267 );
1268 assert_eq!(entries["src"].kind, EntryKind::Tree);
1269 assert_eq!(entries["README.md"].kind, EntryKind::Blob);
1270 assert_eq!(
1271 entries["link"].kind,
1272 EntryKind::Symlink,
1273 "a symlink is its own kind, not a file"
1274 );
1275 }
1276
1277 #[tokio::test]
1278 async fn a_listing_carries_blob_sizes_but_not_tree_sizes() {
1279 let (_dir, query) = populated();
1280
1281 let entries = by_name(
1282 query
1283 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1284 .await
1285 .expect("should read")
1286 .expect("the root is a directory"),
1287 );
1288
1289 assert_eq!(entries["big.txt"].size, Some(100));
1290 assert_eq!(
1291 entries["src"].size, None,
1292 "a directory has no size a listing can show"
1293 );
1294 }
1295
1296 #[tokio::test]
1297 async fn a_filename_containing_a_space_survives_the_listing() {
1298 // The reason `-z` is not optional: split on whitespace and this name becomes two.
1299 let (_dir, query) = populated();
1300
1301 let entries = by_name(
1302 query
1303 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1304 .await
1305 .expect("should read")
1306 .expect("the root is a directory"),
1307 );
1308
1309 assert_eq!(entries["with space.txt"].kind, EntryKind::Blob);
1310 assert_eq!(entries["with space.txt"].size, Some(7));
1311 }
1312
1313 #[tokio::test]
1314 async fn a_nested_directory_lists_only_its_own_entries() {
1315 let (_dir, query) = populated();
1316
1317 let entries = query
1318 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src"))
1319 .await
1320 .expect("should read")
1321 .expect("src is a directory");
1322
1323 assert_eq!(entries.len(), 1);
1324 assert_eq!(entries[0].name, "deep", "names are entry names, not paths");
1325 assert_eq!(entries[0].kind, EntryKind::Tree);
1326
1327 let deeper = query
1328 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src/deep"))
1329 .await
1330 .expect("should read")
1331 .expect("src/deep is a directory");
1332
1333 assert_eq!(deeper.len(), 1);
1334 assert_eq!(deeper[0].name, "file.rs");
1335 }
1336
1337 #[tokio::test]
1338 async fn listing_a_file_as_a_directory_finds_nothing() {
1339 // git calls this a fatal error; to a visitor it is a wrong URL.
1340 let (_dir, query) = populated();
1341
1342 assert_eq!(
1343 query
1344 .list_tree(&handle(), &repo_name(), &rev("main"), &path("README.md"))
1345 .await
1346 .expect("a file is not a failure"),
1347 None
1348 );
1349 }
1350
1351 #[tokio::test]
1352 async fn listing_a_path_that_is_not_there_finds_nothing() {
1353 let (_dir, query) = populated();
1354
1355 for missing in ["nope", "src/nope", "README.md/nope"] {
1356 assert_eq!(
1357 query
1358 .list_tree(&handle(), &repo_name(), &rev("main"), &path(missing))
1359 .await
1360 .expect("should read"),
1361 None,
1362 "{missing} should not be found"
1363 );
1364 }
1365 }
1366
1367 #[tokio::test]
1368 async fn listing_at_an_unknown_revision_finds_nothing() {
1369 let (_dir, query) = populated();
1370
1371 assert_eq!(
1372 query
1373 .list_tree(
1374 &handle(),
1375 &repo_name(),
1376 &rev("no-such-branch"),
1377 &RepoPath::root()
1378 )
1379 .await
1380 .expect("should read"),
1381 None
1382 );
1383 }
1384
1385 #[tokio::test]
1386 async fn a_listing_reflects_the_revision_it_was_asked_for() {
1387 // Proves the revision is actually used rather than HEAD being read every time.
1388 let (_dir, query) = populated();
1389
1390 let first = query
1391 .log(&handle(), &repo_name(), &rev("main"), 10)
1392 .await
1393 .expect("should read")
1394 .last()
1395 .expect("three commits")
1396 .id
1397 .clone();
1398
1399 let old = query
1400 .read_blob(
1401 &handle(),
1402 &repo_name(),
1403 &rev(first.as_str()),
1404 &path("README.md"),
1405 1024,
1406 )
1407 .await
1408 .expect("should read")
1409 .expect("README existed in the first commit");
1410
1411 assert_eq!(old.content.as_deref(), Some(b"hello\n".as_slice()));
1412 }
1413
1414 // --- read_blob --------------------------------------------------------------
1415
1416 #[tokio::test]
1417 async fn a_file_is_read_with_its_size_and_content() {
1418 let (_dir, query) = populated();
1419
1420 let blob = query
1421 .read_blob(
1422 &handle(),
1423 &repo_name(),
1424 &rev("main"),
1425 &path("README.md"),
1426 1024,
1427 )
1428 .await
1429 .expect("should read")
1430 .expect("README.md is a file");
1431
1432 assert_eq!(blob.size, 12);
1433 assert_eq!(blob.content.as_deref(), Some(b"hello again\n".as_slice()));
1434 }
1435
1436 #[tokio::test]
1437 async fn a_binary_file_survives_intact() {
1438 // Nothing in this adapter may assume UTF-8: a lossy conversion here would swap
1439 // bytes for replacement characters and quietly corrupt every download.
1440 let (_dir, query) = populated();
1441
1442 let blob = query
1443 .read_blob(
1444 &handle(),
1445 &repo_name(),
1446 &rev("main"),
1447 &path("bin.dat"),
1448 1024,
1449 )
1450 .await
1451 .expect("should read")
1452 .expect("bin.dat is a file");
1453
1454 assert_eq!(blob.size, BINARY.len() as u64);
1455 assert_eq!(blob.content.as_deref(), Some(BINARY));
1456 }
1457
1458 #[tokio::test]
1459 async fn a_file_over_the_cap_reports_its_size_without_its_content() {
1460 let (_dir, query) = populated();
1461
1462 let blob = query
1463 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 10)
1464 .await
1465 .expect("should read")
1466 .expect("big.txt is a file");
1467
1468 assert_eq!(blob.size, 100, "the page still says how big it is");
1469 assert_eq!(blob.content, None);
1470 }
1471
1472 #[tokio::test]
1473 async fn a_file_exactly_at_the_cap_is_still_read() {
1474 let (_dir, query) = populated();
1475
1476 let blob = query
1477 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 100)
1478 .await
1479 .expect("should read")
1480 .expect("big.txt is a file");
1481
1482 assert_eq!(blob.content.map(|content| content.len()), Some(100));
1483 }
1484
1485 #[tokio::test]
1486 async fn a_file_with_a_space_in_its_name_can_be_read() {
1487 let (_dir, query) = populated();
1488
1489 let blob = query
1490 .read_blob(
1491 &handle(),
1492 &repo_name(),
1493 &rev("main"),
1494 &path("with space.txt"),
1495 1024,
1496 )
1497 .await
1498 .expect("should read")
1499 .expect("the file is there");
1500
1501 assert_eq!(blob.content.as_deref(), Some(b"spaced\n".as_slice()));
1502 }
1503
1504 #[tokio::test]
1505 async fn reading_a_directory_as_a_file_finds_nothing() {
1506 let (_dir, query) = populated();
1507
1508 for directory in ["src", "src/deep", ""] {
1509 assert_eq!(
1510 query
1511 .read_blob(
1512 &handle(),
1513 &repo_name(),
1514 &rev("main"),
1515 &path(directory),
1516 1024
1517 )
1518 .await
1519 .expect("a directory is not a failure"),
1520 None,
1521 "{directory:?} is a directory"
1522 );
1523 }
1524 }
1525
1526 #[tokio::test]
1527 async fn reading_a_path_that_is_not_there_finds_nothing() {
1528 let (_dir, query) = populated();
1529
1530 for missing in ["nope.txt", "src/nope.txt", "README.md/nope"] {
1531 assert_eq!(
1532 query
1533 .read_blob(&handle(), &repo_name(), &rev("main"), &path(missing), 1024)
1534 .await
1535 .expect("should read"),
1536 None,
1537 "{missing} should not be found"
1538 );
1539 }
1540 }
1541
1542 #[tokio::test]
1543 async fn a_blobs_id_matches_the_listing() {
1544 // Two commands, one object: if they disagree, one of the two parsers is wrong.
1545 let (_dir, query) = populated();
1546
1547 let entries = by_name(
1548 query
1549 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1550 .await
1551 .expect("should read")
1552 .expect("the root is a directory"),
1553 );
1554 let blob = query
1555 .read_blob(
1556 &handle(),
1557 &repo_name(),
1558 &rev("main"),
1559 &path("README.md"),
1560 1024,
1561 )
1562 .await
1563 .expect("should read")
1564 .expect("README.md is a file");
1565
1566 assert_eq!(blob.id, entries["README.md"].id);
1567 assert_eq!(Some(blob.size), entries["README.md"].size);
1568 }
1569
1570 #[tokio::test]
1571 async fn a_symlink_reads_as_its_target_path() {
1572 // The object store cannot tell a symlink from a file — both are blobs — and its
1573 // content is the path it points at. Showing that is more use than a blank page,
1574 // so this is a deliberate choice rather than an oversight.
1575 let (_dir, query) = populated();
1576
1577 let blob = query
1578 .read_blob(&handle(), &repo_name(), &rev("main"), &path("link"), 1024)
1579 .await
1580 .expect("should read")
1581 .expect("a symlink is readable");
1582
1583 assert_eq!(blob.content.as_deref(), Some(b"README.md".as_slice()));
1584 }
1585
1586 // --- log ---------------------------------------------------------------------
1587
1588 #[tokio::test]
1589 async fn the_log_is_newest_first() {
1590 let (_dir, query) = populated();
1591
1592 let commits = query
1593 .log(&handle(), &repo_name(), &rev("main"), 10)
1594 .await
1595 .expect("should read");
1596
1597 assert_eq!(commits.len(), 3);
1598 assert_eq!(
1599 commits
1600 .iter()
1601 .map(|commit| commit.summary.as_str())
1602 .collect::<Vec<_>>(),
1603 vec!["third: 'quotes', \"doubles\" | pipes", "second", "first"]
1604 );
1605 }
1606
1607 #[tokio::test]
1608 async fn the_log_stops_at_the_limit() {
1609 let (_dir, query) = populated();
1610
1611 let commits = query
1612 .log(&handle(), &repo_name(), &rev("main"), 2)
1613 .await
1614 .expect("should read");
1615
1616 assert_eq!(commits.len(), 2);
1617 assert_eq!(commits[0].summary, "third: 'quotes', \"doubles\" | pipes");
1618
1619 assert!(
1620 query
1621 .log(&handle(), &repo_name(), &rev("main"), 0)
1622 .await
1623 .expect("should read")
1624 .is_empty()
1625 );
1626 }
1627
1628 #[tokio::test]
1629 async fn a_commit_message_body_does_not_leak_into_the_summary() {
1630 // The message has a blank line and two body lines. A parser that split the
1631 // stream on newlines would report "body line one" as a separate commit.
1632 let (_dir, query) = populated();
1633
1634 let commits = query
1635 .log(&handle(), &repo_name(), &rev("main"), 10)
1636 .await
1637 .expect("should read");
1638
1639 assert_eq!(commits.len(), 3, "three commits, not five");
1640 assert!(
1641 !commits[0].summary.contains("body line"),
1642 "got: {:?}",
1643 commits[0].summary
1644 );
1645 }
1646
1647 #[tokio::test]
1648 async fn a_log_entry_carries_its_author_and_time() {
1649 let (_dir, query) = populated();
1650
1651 let commits = query
1652 .log(&handle(), &repo_name(), &rev("main"), 10)
1653 .await
1654 .expect("should read");
1655
1656 assert_eq!(commits[0].author_name, "Ada Lovelace");
1657 assert_eq!(
1658 commits[0].committed_at,
1659 UNIX_EPOCH + Duration::from_secs(THIRD_COMMIT as u64)
1660 );
1661 assert_eq!(
1662 commits[2].committed_at,
1663 UNIX_EPOCH + Duration::from_secs(FIRST_COMMIT as u64)
1664 );
1665 }
1666
1667 #[tokio::test]
1668 async fn a_log_entry_id_is_the_commit_the_revision_resolves_to() {
1669 let (_dir, query) = populated();
1670
1671 let head = query
1672 .resolve(&handle(), &repo_name(), &rev("main"))
1673 .await
1674 .expect("should read")
1675 .expect("main resolves");
1676 let commits = query
1677 .log(&handle(), &repo_name(), &rev("main"), 1)
1678 .await
1679 .expect("should read");
1680
1681 assert_eq!(commits[0].id, head);
1682 }
1683
1684 #[tokio::test]
1685 async fn the_log_of_an_unknown_revision_is_empty_rather_than_a_failure() {
1686 let (_dir, query) = populated();
1687
1688 assert_eq!(
1689 query
1690 .log(&handle(), &repo_name(), &rev("no-such-branch"), 10)
1691 .await
1692 .expect("an unknown branch is not a failure"),
1693 Vec::new()
1694 );
1695 }
1696
1697 #[tokio::test]
1698 async fn a_log_can_start_from_an_older_commit() {
1699 let (_dir, query) = populated();
1700
1701 let all = query
1702 .log(&handle(), &repo_name(), &rev("main"), 10)
1703 .await
1704 .expect("should read");
1705 let from_second = query
1706 .log(&handle(), &repo_name(), &rev(all[1].id.as_str()), 10)
1707 .await
1708 .expect("should read");
1709
1710 assert_eq!(from_second.len(), 2, "history behind the second commit");
1711 assert_eq!(from_second[0].id, all[1].id);
1712 }
1713
1714 // --- list_refs ---------------------------------------------------------------
1715
1716 /// The populated repository with a second branch and two tags pushed into it — one
1717 /// lightweight, one annotated, because they are different objects and the switcher
1718 /// must not care.
1719 fn with_refs() -> (TempDir, DiskGitQuery) {
1720 let (dir, query) = populated();
1721 let repo = query.repo_path(&handle(), &repo_name());
1722 let work = dir.path().join("work");
1723 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1724
1725 // A slash in the name, because that is what makes a ref name interesting: it is
1726 // the case the `/-/` separator in the URL exists for.
1727 git(&work, THIRD_COMMIT, &["branch", "feature/login"]);
1728 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1729 git(
1730 &work,
1731 THIRD_COMMIT,
1732 &["tag", "-a", "v2.0", "-m", "second release"],
1733 );
1734 git(
1735 &work,
1736 THIRD_COMMIT,
1737 &["push", "--quiet", &target, "feature/login"],
1738 );
1739 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1740
1741 (dir, query)
1742 }
1743
1744 fn named(refs: &[GitRef], kind: RefKind) -> Vec<String> {
1745 let mut names: Vec<String> = refs
1746 .iter()
1747 .filter(|git_ref| git_ref.kind == kind)
1748 .map(|git_ref| git_ref.name.to_string())
1749 .collect();
1750
1751 // The port promises no order, so a test that asserted one would be asserting
1752 // something the adapter is free to change.
1753 names.sort();
1754 names
1755 }
1756
1757 #[tokio::test]
1758 async fn branches_and_tags_are_listed_and_told_apart() {
1759 let (_dir, query) = with_refs();
1760
1761 let refs = query
1762 .list_refs(&handle(), &repo_name())
1763 .await
1764 .expect("should read");
1765
1766 assert_eq!(
1767 named(&refs, RefKind::Branch),
1768 vec!["feature/login".to_owned(), "main".to_owned()]
1769 );
1770 // An annotated tag points at a tag object rather than a commit, and a
1771 // lightweight one points straight at the commit. Both are tags.
1772 assert_eq!(
1773 named(&refs, RefKind::Tag),
1774 vec!["v1.0".to_owned(), "v2.0".to_owned()]
1775 );
1776 }
1777
1778 #[tokio::test]
1779 async fn a_repository_with_one_branch_lists_just_it() {
1780 let (_dir, query) = populated();
1781
1782 let refs = query
1783 .list_refs(&handle(), &repo_name())
1784 .await
1785 .expect("should read");
1786
1787 assert_eq!(refs.len(), 1);
1788 assert_eq!(refs[0].name.as_str(), "main");
1789 assert_eq!(refs[0].kind, RefKind::Branch);
1790 }
1791
1792 #[tokio::test]
1793 async fn an_empty_repository_lists_no_refs() {
1794 // HEAD names `main`, but no ref exists, so there is nothing to switch to. An
1795 // empty list rather than an error: nothing pushed yet is not a failure.
1796 let (_dir, query) = empty();
1797
1798 assert_eq!(
1799 query
1800 .list_refs(&handle(), &repo_name())
1801 .await
1802 .expect("should read"),
1803 Vec::new()
1804 );
1805 }
1806
1807 #[tokio::test]
1808 async fn listing_refs_of_a_repository_that_is_not_on_disk_is_an_error() {
1809 let (_dir, query) = empty();
1810 let missing = RepoName::new("never-created").expect("valid repository name");
1811
1812 assert!(query.list_refs(&handle(), &missing).await.is_err());
1813 }
1814
1815 #[test]
1816 fn refs_are_parsed_from_nul_terminated_records() {
1817 // git ends each record with a newline the format cannot suppress, so it arrives
1818 // in front of the next record's name. Anything outside the two namespaces is
1819 // dropped rather than guessed at.
1820 let stdout = b"refs/heads/main\0\nrefs/tags/v1.0\0\nrefs/pull/7/head\0\n";
1821 let refs = parse_refs(stdout);
1822
1823 assert_eq!(refs.len(), 2);
1824 assert_eq!(refs[0].name.as_str(), "main");
1825 assert_eq!(refs[0].kind, RefKind::Branch);
1826 assert_eq!(refs[1].name.as_str(), "v1.0");
1827 assert_eq!(refs[1].kind, RefKind::Tag);
1828 }
1829
1830 #[test]
1831 fn nothing_is_parsed_from_an_empty_listing() {
1832 assert!(parse_refs(b"").is_empty());
1833 }
1834
1835 // --- count_commits ------------------------------------------------------------
1836
1837 #[tokio::test]
1838 async fn commits_are_counted_from_the_revision_asked_about() {
1839 let (_dir, query) = populated();
1840
1841 assert_eq!(
1842 query
1843 .count_commits(&handle(), &repo_name(), &rev("main"))
1844 .await
1845 .expect("should count"),
1846 3
1847 );
1848 }
1849
1850 #[tokio::test]
1851 async fn a_revision_with_no_commits_counts_zero_rather_than_failing() {
1852 // Both spellings of "nothing here": an empty repository, and a branch that is
1853 // not there. `rev-list` is fatal for each, and a page asking how big a
1854 // repository is wants a number.
1855 let (_dir, empty_query) = empty();
1856 assert_eq!(
1857 empty_query
1858 .count_commits(&handle(), &repo_name(), &rev("main"))
1859 .await
1860 .expect("should count"),
1861 0
1862 );
1863
1864 let (_dir, query) = populated();
1865 assert_eq!(
1866 query
1867 .count_commits(&handle(), &repo_name(), &rev("no-such-branch"))
1868 .await
1869 .expect("should count"),
1870 0
1871 );
1872 }
1873
1874 #[tokio::test]
1875 async fn counting_a_repository_that_is_not_on_disk_is_an_error() {
1876 // Same rule as everywhere else here: absent from disk is a fault, not a zero.
1877 let (_dir, query) = empty();
1878 let missing = RepoName::new("gone").expect("valid repository name");
1879
1880 assert!(
1881 query
1882 .count_commits(&handle(), &missing, &rev("main"))
1883 .await
1884 .is_err()
1885 );
1886 }
1887
1888 // --- latest_tag ---------------------------------------------------------------
1889
1890 /// The populated repository with two annotated tags whose dates disagree with their
1891 /// names, so a test can tell "newest" from "last alphabetically".
1892 fn with_dated_tags() -> (TempDir, DiskGitQuery) {
1893 let (dir, query) = populated();
1894 let repo = query.repo_path(&handle(), &repo_name());
1895 let work = dir.path().join("work");
1896 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1897
1898 git(&work, FIRST_COMMIT, &["tag", "-a", "v1.0", "-m", "first"]);
1899 // Made later but named lower: sorting by name would pick `v1.0`.
1900 git(
1901 &work,
1902 THIRD_COMMIT,
1903 &["tag", "-a", "v0.9", "-m", "backport"],
1904 );
1905 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1906
1907 (dir, query)
1908 }
1909
1910 #[tokio::test]
1911 async fn the_latest_tag_is_the_newest_one_not_the_last_one_alphabetically() {
1912 let (_dir, query) = with_dated_tags();
1913
1914 let tag = query
1915 .latest_tag(&handle(), &repo_name())
1916 .await
1917 .expect("should read")
1918 .expect("a tag");
1919
1920 assert_eq!(tag.name.as_str(), "v0.9");
1921 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
1922 }
1923
1924 #[tokio::test]
1925 async fn a_lightweight_tag_is_dated_by_the_commit_it_points_at() {
1926 // It has no date of its own, and `creatordate` is what fills that in.
1927 let (dir, query) = populated();
1928 let repo = query.repo_path(&handle(), &repo_name());
1929 let work = dir.path().join("work");
1930 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1931
1932 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1933 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1934
1935 let tag = query
1936 .latest_tag(&handle(), &repo_name())
1937 .await
1938 .expect("should read")
1939 .expect("a tag");
1940
1941 assert_eq!(tag.name.as_str(), "v1.0");
1942 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
1943 }
1944
1945 #[tokio::test]
1946 async fn a_repository_with_no_tags_has_no_latest_tag() {
1947 let (_dir, query) = populated();
1948 assert_eq!(
1949 query
1950 .latest_tag(&handle(), &repo_name())
1951 .await
1952 .expect("should read"),
1953 None
1954 );
1955
1956 let (_dir, empty_query) = empty();
1957 assert_eq!(
1958 empty_query
1959 .latest_tag(&handle(), &repo_name())
1960 .await
1961 .expect("should read"),
1962 None
1963 );
1964 }
1965
1966 #[test]
1967 fn a_tag_record_is_parsed_past_the_trailing_newline() {
1968 // `for-each-ref` ends every record with a newline the format cannot suppress,
1969 // exactly as it does for `parse_refs`.
1970 let tag = parse_latest_tag(b"refs/tags/v1.0\x001700000000\x00\n").expect("a tag");
1971
1972 assert_eq!(tag.name.as_str(), "v1.0");
1973 assert_eq!(tag.created_at, unix_time(1_700_000_000));
1974 }
1975
1976 #[test]
1977 fn nothing_is_parsed_from_an_empty_tag_listing() {
1978 assert_eq!(parse_latest_tag(b""), None);
1979 // A ref outside the tags namespace is not a tag, whatever asked for it.
1980 assert_eq!(
1981 parse_latest_tag(b"refs/heads/main\x001700000000\x00\n"),
1982 None
1983 );
1984 }
1985
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
2224 // --- helpers ------------------------------------------------------------------
2225
2226 #[tokio::test]
2227 async fn repo_path_lands_under_the_data_directory() {
2228 let query = DiskGitQuery::new("/data");
2229
2230 assert_eq!(
2231 query.repo_path(&handle(), &repo_name()),
2232 PathBuf::from("/data/jamesgill/steid.git")
2233 );
2234 }
2235
2236 #[test]
2237 fn a_pre_epoch_commit_time_does_not_panic() {
2238 // git will hand back a negative `%ct` for an imported history, and
2239 // `UNIX_EPOCH + Duration` cannot represent it.
2240 assert!(unix_time(-1) < UNIX_EPOCH);
2241 assert_eq!(unix_time(0), UNIX_EPOCH);
2242 }
2243
2244 #[tokio::test]
2245 async fn a_read_that_exceeds_its_limit_is_a_timeout_not_a_fault() {
2246 let (_dir, repo) = fixture_repo_for_timeout().await;
2247 let error = run_within(&repo, ["rev-parse", "HEAD"], Duration::ZERO)
2248 .await
2249 .expect_err("a zero limit cannot be met");
2250 assert!(error.is_timeout(), "{error}");
2251 }
2252
2253 /// A bare repository with nothing in it: `rev-parse` failing is not the point, the
2254 /// process being cut off before it can answer is.
2255 async fn fixture_repo_for_timeout() -> (TempDir, std::path::PathBuf) {
2256 let dir = TempDir::new().unwrap();
2257 let repo = dir.path().join("t.git");
2258 let status = git_command()
2259 .args(["init", "--bare", "-q"])
2260 .arg(&repo)
2261 .status()
2262 .await
2263 .unwrap();
2264 assert!(status.success());
2265 (dir, repo)
2266 }
2267}