steid

@jamesgill /

steid/src/infrastructure/git_query.rs
82.1 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::{
42 port::{Blob, GitQuery, GitQueryError},
43 search::parse_grep_output,
44 },
45 domain::{
46 BranchRow, CommitSummary, EntryKind, GitRef, GrepHit, ObjectId, OrgName, RefKind, RefName,
47 RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
48 },
49 infrastructure::git::git_command,
50};
51
52/// The words `cat-file --batch-check` ends a line with when it did not resolve a spec.
53///
54/// `missing` covers the common cases; `ambiguous` is an abbreviated id matching more
55/// than one object, and `dangling` and `notdir` appear when following a `^{}` or a path
56/// through something that cannot hold one. None of them is a failure — they are the
57/// answer "no such thing here".
58const NOT_FOUND_MARKERS: [&str; 4] = ["missing", "ambiguous", "dangling", "notdir"];
59
60/// Repository contents, read from bare repositories under a data directory.
61#[derive(Debug, Clone)]
62pub struct DiskGitQuery {
63 data_dir: PathBuf,
64}
65
66impl DiskGitQuery {
67 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
68 Self {
69 data_dir: data_dir.into(),
70 }
71 }
72
73 /// Where a repository lives, matching `DiskGitStorage`'s layout.
74 pub(crate) fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
75 self.data_dir
76 .join(handle.as_str())
77 .join(format!("{name}.git"))
78 }
79}
80
81impl GitQuery for DiskGitQuery {
82 async fn default_branch(
83 &self,
84 handle: &OrgName,
85 name: &RepoName,
86 ) -> Result<Option<RefName>, GitQueryError> {
87 let repo = self.repo_path(handle, name);
88
89 // An empty repository's HEAD names a branch that does not exist yet, so
90 // `symbolic-ref` happily answers `main` for a repository with nothing in it.
91 // Whether HEAD *resolves* is the actual question, and it is asked first.
92 let Some(head) = object_info(&repo, "HEAD").await? else {
93 return Ok(None);
94 };
95
96 let branch = run(&repo, [OsStr::new("symbolic-ref"), OsStr::new("HEAD")]).await;
97
98 match branch {
99 Ok(output) => {
100 let full = String::from_utf8_lossy(&output.stdout).trim().to_owned();
101 // `refs/heads/main` rather than `--short`, because `--short` shortens
102 // only as far as is unambiguous and would hand back `heads/main` for a
103 // repository that also has a tag called `main`.
104 let short = full.strip_prefix("refs/heads/").unwrap_or(&full);
105
106 Ok(Some(RefName::from_trusted(short)))
107 }
108 // A detached HEAD is not a state Steid creates, but a repository pushed into
109 // from elsewhere can be in it. The commit is still browsable, so name it
110 // rather than claiming the repository is empty — which is what `Ok(None)`
111 // would mean to a page.
112 Err(_) => Ok(Some(RefName::from_trusted(head.id.as_str()))),
113 }
114 }
115
116 async fn resolve(
117 &self,
118 handle: &OrgName,
119 name: &RepoName,
120 rev: &RefName,
121 ) -> Result<Option<ObjectId>, GitQueryError> {
122 let repo = self.repo_path(handle, name);
123
124 // `^{commit}` peels an annotated tag to what it points at, and refuses a
125 // revision that names a tree or a blob — a browse page wants a commit, and
126 // returning a tree id here would fail confusingly two calls later.
127 let spec = format!("{}^{{commit}}", rev.as_str());
128
129 Ok(object_info(&repo, &spec).await?.map(|info| info.id))
130 }
131
132 async fn list_tree(
133 &self,
134 handle: &OrgName,
135 name: &RepoName,
136 rev: &RefName,
137 path: &RepoPath,
138 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
139 let repo = self.repo_path(handle, name);
140
141 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
142 return Ok(None);
143 };
144
145 // A file is not a directory. Asking `ls-tree` anyway is a fatal error, which is
146 // exactly the confusion this check exists to avoid.
147 if info.kind != ObjectKind::Tree {
148 return Ok(None);
149 }
150
151 // `-z` because a filename may contain a newline, and `--long` for blob sizes.
152 // The already-resolved tree id is passed rather than the user's revision, so
153 // nothing here has to think about what git's revision parser might make of it.
154 let output = run(
155 &repo,
156 [
157 OsStr::new("ls-tree"),
158 OsStr::new("-z"),
159 OsStr::new("--long"),
160 OsStr::new(info.id.as_str()),
161 ],
162 )
163 .await?;
164
165 parse_tree(&output.stdout).map(Some)
166 }
167
168 async fn read_blob(
169 &self,
170 handle: &OrgName,
171 name: &RepoName,
172 rev: &RefName,
173 path: &RepoPath,
174 max_bytes: u64,
175 ) -> Result<Option<Blob>, GitQueryError> {
176 let repo = self.repo_path(handle, name);
177
178 // The root is a tree, and `{rev}:` is how git spells it — but a caller asking to
179 // read the root is asking for a file that is not there.
180 if path.is_root() {
181 return Ok(None);
182 }
183
184 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
185 return Ok(None);
186 };
187
188 // Symlinks are blobs whose content is the target path, and are read as such:
189 // showing where a link points is more use than a blank page. Trees and
190 // submodules are not files.
191 if info.kind != ObjectKind::Blob {
192 return Ok(None);
193 }
194
195 // The size comes from the object header, so an oversized file is never read.
196 // Doing this the other way round — read, then measure — is how one URL becomes
197 // an out-of-memory kill.
198 let content = if info.size > max_bytes {
199 None
200 } else {
201 let output = run(
202 &repo,
203 [
204 OsStr::new("cat-file"),
205 OsStr::new("blob"),
206 OsStr::new(info.id.as_str()),
207 ],
208 )
209 .await?;
210
211 Some(output.stdout)
212 };
213
214 Ok(Some(Blob {
215 id: info.id,
216 size: info.size,
217 content,
218 }))
219 }
220
221 async fn log(
222 &self,
223 handle: &OrgName,
224 name: &RepoName,
225 rev: &RefName,
226 limit: usize,
227 ) -> Result<Vec<CommitSummary>, GitQueryError> {
228 let repo = self.repo_path(handle, name);
229
230 // `git log` on a repository with no commits is a fatal error, and so is a log of
231 // a branch that does not exist. Resolving first turns both into the empty list
232 // the port's signature promises, without having to read meaning into a stderr
233 // string that is localised and free to change between git versions.
234 let Some(commit) = self.resolve(handle, name, rev).await? else {
235 return Ok(Vec::new());
236 };
237
238 if limit == 0 {
239 return Ok(Vec::new());
240 }
241
242 // Every separator is a NUL: `-z` between commits, `%x00` between fields. A
243 // commit message contains newlines as a matter of course, and a name can contain
244 // almost anything, so splitting on lines or whitespace would misread real
245 // history rather than exotic history.
246 let format = "--format=%H%x00%ct%x00%an%x00%s";
247 let count = format!("--max-count={limit}");
248
249 let output = run(
250 &repo,
251 [
252 OsStr::new("log"),
253 OsStr::new("-z"),
254 OsStr::new(&count),
255 OsStr::new(format),
256 OsStr::new(commit.as_str()),
257 ],
258 )
259 .await?;
260
261 parse_log(&output.stdout)
262 }
263
264 async fn list_refs(
265 &self,
266 handle: &OrgName,
267 name: &RepoName,
268 ) -> Result<Vec<GitRef>, GitQueryError> {
269 let repo = self.repo_path(handle, name);
270
271 // One fork, ~14ms — see the port's note. `for-each-ref` is asked for both
272 // namespaces at once rather than once each, because the cost here is the
273 // process, not the question.
274 //
275 // Every field separator is a NUL, for the same reason `log` uses one: a tag name
276 // is close to arbitrary text once git's own restrictions are met, and splitting
277 // on whitespace would misread a real name. The patterns are literals, so unlike
278 // a revision from a URL there is nothing here that could be read as a flag.
279 let output = run(
280 &repo,
281 [
282 OsStr::new("for-each-ref"),
283 OsStr::new(REF_FORMAT),
284 OsStr::new("refs/heads/"),
285 OsStr::new("refs/tags/"),
286 ],
287 )
288 .await?;
289
290 Ok(parse_refs(&output.stdout))
291 }
292
293 async fn count_commits(
294 &self,
295 handle: &OrgName,
296 name: &RepoName,
297 rev: &RefName,
298 ) -> Result<u64, GitQueryError> {
299 let repo = self.repo_path(handle, name);
300
301 // Resolved first, for the same reason `log` resolves first: `rev-list` on an
302 // empty repository or an unknown branch is a fatal error, and the port promises
303 // a count rather than a failure. **That makes this two processes, not one** —
304 // the price of keeping the module's rule that a non-zero exit is always a real
305 // fault. Handing the resolved id to `rev-list` also means nothing from a URL
306 // reaches git's revision parser here.
307 let Some(commit) = self.resolve(handle, name, rev).await? else {
308 return Ok(0);
309 };
310
311 let output = run(
312 &repo,
313 [
314 OsStr::new("rev-list"),
315 OsStr::new("--count"),
316 OsStr::new(commit.as_str()),
317 ],
318 )
319 .await?;
320
321 let count = String::from_utf8_lossy(&output.stdout);
322 let count = count.trim();
323
324 count.parse().map_err(|_| {
325 GitQueryError::new(format!(
326 "git counted commits as {count:?}, which is not a number"
327 ))
328 })
329 }
330
331 async fn latest_tag(
332 &self,
333 handle: &OrgName,
334 name: &RepoName,
335 ) -> Result<Option<TagSummary>, GitQueryError> {
336 let repo = self.repo_path(handle, name);
337
338 // `--count=1` after `--sort` is the whole of the work: git does the ordering,
339 // so this is one process regardless of how many tags a repository carries.
340 // Every argument is a literal — nothing from a URL reaches this call.
341 let output = run(
342 &repo,
343 [
344 OsStr::new("for-each-ref"),
345 OsStr::new("--sort=-creatordate"),
346 OsStr::new("--count=1"),
347 OsStr::new(TAG_FORMAT),
348 OsStr::new("refs/tags/"),
349 ],
350 )
351 .await?;
352
353 Ok(parse_latest_tag(&output.stdout))
354 }
355
356 async fn branches(
357 &self,
358 handle: &OrgName,
359 name: &RepoName,
360 ) -> Result<Vec<BranchRow>, GitQueryError> {
361 let repo = self.repo_path(handle, name);
362
363 // One process for the whole branches page. The sort is git's because it is
364 // free there and would otherwise be a second pass in Rust over the same rows,
365 // and `%(HEAD)` is what saves the page a `symbolic-ref` for the default branch.
366 // Every argument is a literal — nothing from a URL reaches this call.
367 let output = run(
368 &repo,
369 [
370 OsStr::new("for-each-ref"),
371 OsStr::new("--sort=-committerdate"),
372 OsStr::new(BRANCH_FORMAT),
373 OsStr::new("refs/heads/"),
374 ],
375 )
376 .await?;
377
378 parse_branches(&output.stdout)
379 }
380
381 async fn tags(&self, handle: &OrgName, name: &RepoName) -> Result<Vec<TagRow>, GitQueryError> {
382 let repo = self.repo_path(handle, name);
383
384 let output = run(
385 &repo,
386 [
387 OsStr::new("for-each-ref"),
388 OsStr::new("--sort=-creatordate"),
389 OsStr::new(TAG_ROW_FORMAT),
390 OsStr::new("refs/tags/"),
391 ],
392 )
393 .await?;
394
395 parse_tags(&output.stdout)
396 }
397
398 async fn grep(
399 &self,
400 handle: &OrgName,
401 name: &RepoName,
402 commit: &ObjectId,
403 query: &str,
404 limit: usize,
405 ) -> Result<Vec<GrepHit>, GitQueryError> {
406 let repo = self.repo_path(handle, name);
407
408 // **The one command here whose exit status is an answer**: `git grep` exits 1
409 // when it found nothing, which is not a failure. Everything else in this module
410 // keeps the rule that a non-zero exit is a fault; this is the exception, and it
411 // is spelled out in the call rather than hidden in the helper.
412 //
413 // `-F` fixed strings, `-I` skips binary files, `-n --column` locate the match,
414 // and `-z` makes the output parseable — see `parse_grep_output`. The query is
415 // passed after `-e`, so a query starting with `-` is a search rather than a
416 // flag, and the commit is one git resolved rather than anything from a URL.
417 let output = run_allowing(
418 &repo,
419 [
420 OsStr::new("grep"),
421 OsStr::new("-I"),
422 OsStr::new("-n"),
423 OsStr::new("-z"),
424 OsStr::new("-F"),
425 OsStr::new("--column"),
426 OsStr::new("--no-color"),
427 OsStr::new("-e"),
428 OsStr::new(query),
429 OsStr::new(commit.as_str()),
430 OsStr::new("--"),
431 ],
432 &[NO_MATCHES],
433 )
434 .await?;
435
436 Ok(parse_grep_output(&output.stdout, commit, limit))
437 }
438}
439
440/// What `cat-file --batch-check` said about one object.
441#[derive(Debug, Clone, PartialEq, Eq)]
442struct ObjectInfo {
443 id: ObjectId,
444 kind: ObjectKind,
445 size: u64,
446}
447
448/// A git object's type, as its header spells it.
449///
450/// Distinct from [`EntryKind`], which is about what a tree entry *means* — the object
451/// store cannot tell a symlink from a file, because both are blobs.
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453enum ObjectKind {
454 Blob,
455 Tree,
456 Commit,
457 Tag,
458}
459
460impl ObjectKind {
461 fn from_str(value: &str) -> Option<Self> {
462 match value {
463 "blob" => Some(Self::Blob),
464 "tree" => Some(Self::Tree),
465 "commit" => Some(Self::Commit),
466 "tag" => Some(Self::Tag),
467 _ => None,
468 }
469 }
470}
471
472/// How git addresses a path inside a revision: `{rev}:{path}`, and `{rev}:` for the root.
473fn tree_spec(rev: &RefName, path: &RepoPath) -> String {
474 format!("{}:{}", rev.as_str(), path.as_str())
475}
476
477/// Asks git what one revision-and-path resolves to, or `None` if it resolves to nothing.
478///
479/// The spec goes over stdin rather than in an argument, so no revision or path can ever
480/// be read as a flag regardless of what validation upstream does or stops doing.
481async fn object_info(repo: &Path, spec: &str) -> Result<Option<ObjectInfo>, GitQueryError> {
482 let mut command = git_command();
483 command
484 .arg("-C")
485 .arg(repo)
486 .arg("cat-file")
487 .arg("--batch-check")
488 .stdin(Stdio::piped())
489 .stdout(Stdio::piped())
490 .stderr(Stdio::piped());
491
492 let mut child = command
493 .spawn()
494 .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
495
496 let mut stdin = child.stdin.take().expect("stdin was piped");
497
498 // One short line, far below a pipe's buffer, so writing before waiting cannot
499 // deadlock. Dropping stdin is what ends the batch — git would otherwise wait for
500 // another spec forever.
501 stdin
502 .write_all(format!("{spec}\n").as_bytes())
503 .await
504 .map_err(|error| {
505 GitQueryError::new(format!("could not ask git about {spec:?}: {error}"))
506 })?;
507 drop(stdin);
508
509 let output = child
510 .wait_with_output()
511 .await
512 .map_err(|error| GitQueryError::new(format!("could not wait for git: {error}")))?;
513
514 // Per the module note: a non-zero exit here is never "not found".
515 if !output.status.success() {
516 return Err(GitQueryError::new(format!(
517 "git exited with {} looking up {spec:?}: {}",
518 output.status,
519 String::from_utf8_lossy(&output.stderr).trim()
520 )));
521 }
522
523 let line = String::from_utf8_lossy(&output.stdout);
524 let line = line.trim_end_matches('\n');
525
526 // The marker is checked before the field count, because a not-found line echoes the
527 // spec back — and a spec naming a file with spaces in it has no fixed field count.
528 if line
529 .rsplit(' ')
530 .next()
531 .is_some_and(|last| NOT_FOUND_MARKERS.contains(&last))
532 {
533 return Ok(None);
534 }
535
536 let fields: Vec<&str> = line.split_whitespace().collect();
537 let [id, kind, size] = fields[..] else {
538 return Err(GitQueryError::new(format!(
539 "git described {spec:?} in a shape we do not understand: {line:?}"
540 )));
541 };
542
543 Ok(Some(ObjectInfo {
544 // Validated rather than trusted. git's ids are trustworthy, but this is a parse
545 // of text whose layout we have assumed, and an id is about to appear in a URL —
546 // a misread field should stop here rather than surface as a broken link. The
547 // cost is a length and hex check next to a process spawn.
548 id: ObjectId::new(id)
549 .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))?,
550 kind: ObjectKind::from_str(kind).ok_or_else(|| {
551 GitQueryError::new(format!("git reported an unknown object type {kind:?}"))
552 })?,
553 size: size.parse().map_err(|_| {
554 GitQueryError::new(format!("git reported an unreadable object size {size:?}"))
555 })?,
556 }))
557}
558
559/// Parses `ls-tree -z --long` output.
560///
561/// Each record is `<mode> SP <type> SP <id> SP <size> TAB <name>`, NUL-terminated, where
562/// the size is space-padded and `-` for anything that is not a blob. The name is
563/// everything after the first tab and is *raw bytes* — which is why the split happens
564/// before any attempt to read it as text.
565fn parse_tree(stdout: &[u8]) -> Result<Vec<TreeEntry>, GitQueryError> {
566 let mut entries = Vec::new();
567
568 for record in stdout.split(|byte| *byte == 0) {
569 if record.is_empty() {
570 continue;
571 }
572
573 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
574 return Err(GitQueryError::new(
575 "git listed a tree entry with no name separator",
576 ));
577 };
578
579 let (meta, name) = record.split_at(tab);
580 let name = &name[1..];
581
582 let meta = std::str::from_utf8(meta).map_err(|_| {
583 GitQueryError::new("git listed a tree entry whose metadata is not text")
584 })?;
585
586 let fields: Vec<&str> = meta.split_whitespace().collect();
587 let [mode, _type, id, size] = fields[..] else {
588 return Err(GitQueryError::new(format!(
589 "git listed a tree entry in a shape we do not understand: {meta:?}"
590 )));
591 };
592
593 entries.push(TreeEntry {
594 // Lossy, because `TreeEntry::name` is a `String` and a filename is not
595 // required to be UTF-8. A replacement character renders; refusing to list
596 // the whole directory because one file has an odd name does not.
597 name: String::from_utf8_lossy(name).into_owned(),
598 kind: EntryKind::from_mode(mode)
599 .map_err(|error| GitQueryError::new(format!("git listed {name:?}: {error}")))?,
600 id: ObjectId::new(id).map_err(|error| {
601 GitQueryError::new(format!("git named a bad object id: {error}"))
602 })?,
603 // `-` for a tree or a submodule, which have no size a listing can show.
604 size: size.parse().ok(),
605 });
606 }
607
608 // Unsorted on purpose: ordering is `TreeEntry::ordering_key`'s decision, made once
609 // in the application rather than differently in each adapter.
610 Ok(entries)
611}
612
613/// Parses the NUL-separated `log` stream into four-field records.
614fn parse_log(stdout: &[u8]) -> Result<Vec<CommitSummary>, GitQueryError> {
615 // `-z` terminates the last record too, so the split leaves a trailing empty field
616 // that is not a commit.
617 let fields: Vec<&[u8]> = stdout
618 .split(|byte| *byte == 0)
619 .filter(|field| !field.is_empty())
620 .collect();
621
622 let mut commits = Vec::with_capacity(fields.len() / 4);
623
624 for record in fields.chunks(4) {
625 let [id, committed_at, author_name, summary] = record[..] else {
626 return Err(GitQueryError::new(
627 "git logged a commit with missing fields",
628 ));
629 };
630
631 let id = String::from_utf8_lossy(id);
632 let committed_at = String::from_utf8_lossy(committed_at);
633 let committed_at: i64 = committed_at.trim().parse().map_err(|_| {
634 GitQueryError::new(format!(
635 "git logged an unreadable commit time {committed_at:?}"
636 ))
637 })?;
638
639 commits.push(CommitSummary {
640 id: ObjectId::new(id.trim()).map_err(|error| {
641 GitQueryError::new(format!("git named a bad object id: {error}"))
642 })?,
643 // `%s` is git's subject: the first paragraph, joined into one line. Trimmed
644 // to the first line anyway, because that invariant is git's rather than
645 // something this parser should assume.
646 summary: String::from_utf8_lossy(summary)
647 .lines()
648 .next()
649 .unwrap_or_default()
650 .to_owned(),
651 author_name: String::from_utf8_lossy(author_name).into_owned(),
652 committed_at: unix_time(committed_at),
653 });
654 }
655
656 Ok(commits)
657}
658
659/// A unix timestamp as a `SystemTime`, including the negative ones.
660///
661/// A commit dated before 1970 is either a lie or an import from something older than
662/// git, and both exist in real repositories. `UNIX_EPOCH + Duration` would panic on the
663/// subtraction it cannot do.
664fn unix_time(seconds: i64) -> SystemTime {
665 match u64::try_from(seconds) {
666 Ok(seconds) => UNIX_EPOCH + Duration::from_secs(seconds),
667 Err(_) => UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs()),
668 }
669}
670
671/// What `for-each-ref` prints per ref: the full name and the object it names,
672/// NUL-separated and NUL-terminated.
673///
674/// The kind is *not* asked for. `%(objecttype)` says `commit` for both a branch and a
675/// lightweight tag, so the namespace in the name is the only thing that answers which
676/// one a visitor asked for.
677const REF_FORMAT: &str = "--format=%(refname)%00";
678
679/// Parses `for-each-ref`'s NUL-separated output into branches and tags.
680///
681/// Each record is `<full refname> NUL`, and git ends every record with a newline of its
682/// own that the format cannot suppress — so the newline arrives at the *front* of the
683/// next record's first field and is trimmed off. A ref name can contain neither a
684/// newline nor a space, so trimming cannot eat part of a name.
685fn parse_refs(stdout: &[u8]) -> Vec<GitRef> {
686 let mut refs = Vec::new();
687
688 for record in stdout.split(|byte| *byte == 0) {
689 let record = record.trim_ascii();
690
691 if record.is_empty() {
692 continue;
693 }
694
695 // Lossy would be wrong here: a name that is not UTF-8 cannot be put in a URL,
696 // and offering a link that cannot work is worse than leaving the ref out of the
697 // switcher. It is still browsable by object id.
698 let Ok(full) = std::str::from_utf8(record) else {
699 continue;
700 };
701
702 let (kind, short) = if let Some(short) = full.strip_prefix("refs/heads/") {
703 (RefKind::Branch, short)
704 } else if let Some(short) = full.strip_prefix("refs/tags/") {
705 (RefKind::Tag, short)
706 } else {
707 // Only the two namespaces were asked for, so this cannot happen — and if a
708 // future pattern is added and this is forgotten, skipping is the safe half
709 // of the mistake.
710 continue;
711 };
712
713 // Validated rather than trusted: this name is about to become a URL, and
714 // `RefName` is what decides a name is safe to hand back to git. A ref git
715 // accepts but Steid's rules do not is left out rather than linked to.
716 let Ok(name) = RefName::new(short) else {
717 continue;
718 };
719
720 refs.push(GitRef { name, kind });
721 }
722
723 refs
724}
725
726/// What the latest-tag query asks for: the full ref name and its creation time.
727///
728/// `creatordate` rather than `taggerdate`, which is empty for a lightweight tag, or
729/// `committerdate`, which is empty for an annotated one. `creatordate` is git's own
730/// "whichever of those this ref has".
731const TAG_FORMAT: &str = "--format=%(refname)%00%(creatordate:unix)%00";
732
733/// Parses the one record [`TAG_FORMAT`] produces, or `None` for a repository with no
734/// tags.
735///
736/// Anything unreadable is `None` rather than an error: this decorates a page with a
737/// fact, and a tag whose name is not UTF-8 or whose date git spelled unexpectedly is a
738/// reason to say nothing, not to fail the repository's landing page.
739fn parse_latest_tag(stdout: &[u8]) -> Option<TagSummary> {
740 let fields: Vec<&[u8]> = stdout
741 .split(|byte| *byte == 0)
742 .map(<[u8]>::trim_ascii)
743 .filter(|field| !field.is_empty())
744 .collect();
745
746 let [name, created_at] = fields[..] else {
747 return None;
748 };
749
750 let short = std::str::from_utf8(name).ok()?.strip_prefix("refs/tags/")?;
751 let created_at: i64 = std::str::from_utf8(created_at).ok()?.parse().ok()?;
752
753 Some(TagSummary {
754 // Validated rather than trusted, exactly as `parse_refs` does: the name is
755 // about to become a link.
756 name: RefName::new(short).ok()?,
757 created_at: unix_time(created_at),
758 })
759}
760
761/// What the branches page asks for, per branch.
762///
763/// `%(HEAD)` is git's own marker for the branch `HEAD` points at — `*` for it and a
764/// space for everything else. It is asked for here rather than resolved separately
765/// because a second process to learn one bit is the cost 0006 is about.
766///
767/// `%(objectname)` is the tip commit itself: a branch, unlike a tag, never points at
768/// anything else.
769const BRANCH_FORMAT: &str = "--format=%(refname)%00%(HEAD)%00%(objectname)%00%(committerdate:unix)%00%(contents:subject)%00";
770
771/// What the tags page asks for, per tag.
772///
773/// `%(objecttype)` is `tag` for an annotated tag and `commit` for a lightweight one,
774/// which is the only reliable way to tell them apart. `%(*objectname)` is the peeled
775/// object and is empty for a lightweight tag, so the commit is "the peeled one if there
776/// is one". `%(contents:subject)` is the *tag's* message for an annotated tag and the
777/// *commit's* for a lightweight one — so it is read only when the type says `tag`,
778/// otherwise a lightweight tag would appear to carry a message it does not have.
779const TAG_ROW_FORMAT: &str = "--format=%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%00";
780
781/// Splits `for-each-ref` output into its NUL-terminated fields.
782///
783/// Every field ends with a NUL and git adds a newline after each record that the format
784/// cannot suppress, so the split yields exactly one field per `%00` plus a trailing
785/// remainder holding that last newline — dropped here.
786///
787/// **Empty fields are kept.** A lightweight tag has no peeled object, and filtering
788/// empties the way [`parse_latest_tag`] can afford to would shift every later field of
789/// that record onto the wrong name.
790fn ref_fields(stdout: &[u8]) -> Vec<&[u8]> {
791 let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect();
792 fields.pop();
793 fields
794}
795
796/// The first line of git's subject, or `None` when there is nothing to show.
797///
798/// `%(contents:subject)` is already one line, but that is git's invariant rather than
799/// something this parser should assume — the same reason [`parse_log`] trims `%s`.
800fn subject(field: &[u8]) -> Option<String> {
801 let line = String::from_utf8_lossy(field)
802 .lines()
803 .next()
804 .unwrap_or_default()
805 .trim()
806 .to_owned();
807
808 (!line.is_empty()).then_some(line)
809}
810
811/// Parses [`BRANCH_FORMAT`] into rows, in the order git sorted them.
812///
813/// A record whose name or commit id Steid cannot use is skipped rather than failing the
814/// page, exactly as [`parse_refs`] skips one: a branch that cannot be linked to is a
815/// reason to leave a row out, not to refuse the whole list. A record with the wrong
816/// number of fields is different — that is git saying something this code does not
817/// understand, and it is an error.
818fn parse_branches(stdout: &[u8]) -> Result<Vec<BranchRow>, GitQueryError> {
819 let fields = ref_fields(stdout);
820 let mut rows = Vec::with_capacity(fields.len() / 5);
821
822 for record in fields.chunks(5) {
823 let [name, head, commit, committed_at, summary] = record[..] else {
824 return Err(GitQueryError::new(
825 "git listed a branch with missing fields",
826 ));
827 };
828
829 // git's trailing newline arrives in front of the next record's first field.
830 // A ref name can contain neither a newline nor a space, so trimming cannot eat
831 // part of one.
832 let Some(name) = short_ref(name.trim_ascii(), "refs/heads/") else {
833 continue;
834 };
835
836 let Ok(commit) = ObjectId::new(String::from_utf8_lossy(commit).trim()) else {
837 continue;
838 };
839
840 let committed_at = String::from_utf8_lossy(committed_at);
841 let Ok(committed_at) = committed_at.trim().parse::<i64>() else {
842 continue;
843 };
844
845 rows.push(BranchRow {
846 name,
847 // `*` for the branch HEAD names, a space for the rest.
848 is_default: head.trim_ascii() == b"*",
849 commit,
850 summary: subject(summary).unwrap_or_default(),
851 committed_at: unix_time(committed_at),
852 });
853 }
854
855 Ok(rows)
856}
857
858/// Parses [`TAG_ROW_FORMAT`] into rows, in the order git sorted them.
859///
860/// Skips and errors on the same terms as [`parse_branches`].
861fn parse_tags(stdout: &[u8]) -> Result<Vec<TagRow>, GitQueryError> {
862 let fields = ref_fields(stdout);
863 let mut rows = Vec::with_capacity(fields.len() / 6);
864
865 for record in fields.chunks(6) {
866 let [name, kind, object, peeled, created_at, message] = record[..] else {
867 return Err(GitQueryError::new("git listed a tag with missing fields"));
868 };
869
870 let Some(name) = short_ref(name.trim_ascii(), "refs/tags/") else {
871 continue;
872 };
873
874 // An annotated tag's `objectname` is the tag object, so the thing worth linking
875 // to is the peeled one. A lightweight tag has no peel and already names its
876 // commit.
877 let annotated = kind.trim_ascii() == b"tag";
878 let id = if peeled.trim_ascii().is_empty() {
879 object
880 } else {
881 peeled
882 };
883
884 let Ok(commit) = ObjectId::new(String::from_utf8_lossy(id).trim()) else {
885 continue;
886 };
887
888 let created_at = String::from_utf8_lossy(created_at);
889 let Ok(created_at) = created_at.trim().parse::<i64>() else {
890 continue;
891 };
892
893 rows.push(TagRow {
894 name,
895 commit,
896 // Only an annotated tag has a message of its own; for a lightweight one
897 // this field is the commit's subject, which belongs to the commit.
898 message: annotated.then(|| subject(message)).flatten(),
899 annotated,
900 created_at: unix_time(created_at),
901 });
902 }
903
904 Ok(rows)
905}
906
907/// A full ref name reduced to the short form Steid puts in a URL, or `None` when it is
908/// outside the namespace asked for or is not a name Steid will hand back to git.
909///
910/// Validated rather than trusted for the reason [`parse_refs`] gives: this name is
911/// about to become a link.
912fn short_ref(full: &[u8], namespace: &str) -> Option<RefName> {
913 let full = std::str::from_utf8(full).ok()?;
914 RefName::new(full.strip_prefix(namespace)?).ok()
915}
916
917/// Runs a git command inside a repository and fails on a non-zero exit.
918///
919/// Only ever used for commands whose subject has already been confirmed to exist, so a
920/// failure really is a failure. Built from [`git_command`] so the host isolation 0006
921/// insists on cannot drift out of this module.
922/// How long one read-side `git` process may run before it is killed.
923///
924/// Every read here is a subprocess on a request path, and nothing bounded it before
925/// blame and grep arrived — either can run for a long time on a large repository, and
926/// a request that never finishes holds a worker for as long as the client waits.
927/// Twenty seconds is far above any read a page should make and far below "hung".
928const GIT_TIMEOUT: Duration = Duration::from_secs(20);
929
930async fn run<I, S>(repo: &Path, args: I) -> Result<Output, GitQueryError>
931where
932 I: IntoIterator<Item = S>,
933 S: AsRef<OsStr>,
934{
935 run_within(repo, args, GIT_TIMEOUT).await
936}
937
938/// What `git grep` exits with when it matched nothing.
939///
940/// A value, not a failure — the one place in this module where git's exit status
941/// carries an answer. Named so the exception is legible at the call site.
942const NO_MATCHES: i32 = 1;
943
944/// [`run`] for a command whose exit status is partly an answer.
945///
946/// Exists for `git grep` alone. Every other command here is asked about something
947/// `cat-file --batch-check` has already confirmed exists, which is what makes the
948/// module's "a non-zero exit is always a fault" rule hold; grep is the one command
949/// whose whole job is to find nothing sometimes.
950async fn run_allowing<I, S>(repo: &Path, args: I, allowed: &[i32]) -> Result<Output, GitQueryError>
951where
952 I: IntoIterator<Item = S>,
953 S: AsRef<OsStr>,
954{
955 run_within_allowing(repo, args, GIT_TIMEOUT, allowed).await
956}
957
958/// [`run`] with an explicit limit, so the timeout path can be tested without waiting
959/// twenty seconds for it.
960async fn run_within<I, S>(repo: &Path, args: I, limit: Duration) -> Result<Output, GitQueryError>
961where
962 I: IntoIterator<Item = S>,
963 S: AsRef<OsStr>,
964{
965 run_within_allowing(repo, args, limit, &[]).await
966}
967
968/// The whole of running git: isolation, the timeout, and the status rule.
969async fn run_within_allowing<I, S>(
970 repo: &Path,
971 args: I,
972 limit: Duration,
973 allowed: &[i32],
974) -> Result<Output, GitQueryError>
975where
976 I: IntoIterator<Item = S>,
977 S: AsRef<OsStr>,
978{
979 let mut command = git_command();
980 command
981 .arg("-C")
982 .arg(repo)
983 .args(args)
984 .stdin(Stdio::null())
985 // Dropping the future on timeout must take the process with it, or a killed
986 // request leaves git running to completion for nobody.
987 .kill_on_drop(true);
988
989 let output = match tokio::time::timeout(limit, command.output()).await {
990 Ok(result) => {
991 result.map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?
992 }
993 Err(_elapsed) => return Err(GitQueryError::timed_out(limit)),
994 };
995
996 let expected = output
997 .status
998 .code()
999 .is_some_and(|code| allowed.contains(&code));
1000
1001 if !output.status.success() && !expected {
1002 return Err(GitQueryError::new(format!(
1003 "git exited with {}: {}",
1004 output.status,
1005 String::from_utf8_lossy(&output.stderr).trim()
1006 )));
1007 }
1008
1009 Ok(output)
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use std::collections::HashMap;
1015
1016 use tempfile::TempDir;
1017
1018 use super::*;
1019 use crate::domain::EntryKind;
1020
1021 /// Fixed so a timestamp assertion is exact rather than approximate.
1022 const FIRST_COMMIT: i64 = 1_700_000_000;
1023 const SECOND_COMMIT: i64 = 1_700_000_100;
1024 const THIRD_COMMIT: i64 = 1_700_000_200;
1025
1026 /// A subject with the punctuation a naive parser splits on, followed by a body — so
1027 /// a test can prove the body does not leak into the summary.
1028 const ODD_MESSAGE: &str =
1029 "third: 'quotes', \"doubles\" | pipes\n\nbody line one\nbody line two";
1030
1031 const BINARY: &[u8] = &[0x00, 0x01, 0xff, 0xfe, 0x80];
1032
1033 fn handle() -> OrgName {
1034 OrgName::new("jamesgill").expect("valid handle")
1035 }
1036
1037 fn repo_name() -> RepoName {
1038 RepoName::new("steid").expect("valid repository name")
1039 }
1040
1041 fn rev(value: &str) -> RefName {
1042 RefName::new(value).expect("valid revision")
1043 }
1044
1045 fn path(value: &str) -> RepoPath {
1046 RepoPath::new(value).expect("valid path")
1047 }
1048
1049 /// Runs git in a fixture, isolated from the host's configuration the same way the
1050 /// adapter is — otherwise a developer's `commit.gpgsign` or `init.defaultBranch`
1051 /// decides whether the suite passes.
1052 fn git(dir: &Path, when: i64, args: &[&str]) {
1053 let date = format!("@{when} +0000");
1054
1055 let output = std::process::Command::new("git")
1056 .arg("-C")
1057 .arg(dir)
1058 .args(args)
1059 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1060 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1061 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
1062 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
1063 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
1064 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
1065 .env("GIT_AUTHOR_DATE", &date)
1066 .env("GIT_COMMITTER_DATE", &date)
1067 .output()
1068 .expect("git should be on PATH");
1069
1070 assert!(
1071 output.status.success(),
1072 "git {args:?} failed: {}",
1073 String::from_utf8_lossy(&output.stderr)
1074 );
1075 }
1076
1077 /// A data directory holding one empty bare repository, exactly as Steid creates it.
1078 ///
1079 /// The `TempDir` is returned because dropping it deletes the fixture.
1080 fn empty() -> (TempDir, DiskGitQuery) {
1081 let dir = TempDir::new().expect("temp dir");
1082 let query = DiskGitQuery::new(dir.path());
1083 let repo = query.repo_path(&handle(), &repo_name());
1084
1085 std::fs::create_dir_all(repo.parent().expect("has a parent")).expect("create handle dir");
1086 git(
1087 dir.path(),
1088 FIRST_COMMIT,
1089 &[
1090 "init",
1091 "--bare",
1092 "--quiet",
1093 "--template=",
1094 "--initial-branch=main",
1095 "--",
1096 repo.to_str().expect("utf-8 fixture path"),
1097 ],
1098 );
1099
1100 (dir, query)
1101 }
1102
1103 /// The empty repository with three commits pushed into it, the way a real one fills
1104 /// up — a working copy and a push, rather than plumbing straight into the object
1105 /// store.
1106 fn populated() -> (TempDir, DiskGitQuery) {
1107 let (dir, query) = empty();
1108 let repo = query.repo_path(&handle(), &repo_name());
1109 let work = dir.path().join("work");
1110
1111 std::fs::create_dir_all(work.join("src/deep")).expect("create work tree");
1112 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
1113
1114 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
1115 std::fs::write(work.join("with space.txt"), b"spaced\n").expect("write");
1116 std::fs::write(work.join("bin.dat"), BINARY).expect("write");
1117 std::fs::write(work.join("big.txt"), vec![b'x'; 100]).expect("write");
1118 std::fs::write(work.join("src/deep/file.rs"), b"fn main() {}\n").expect("write");
1119 std::os::unix::fs::symlink("README.md", work.join("link")).expect("symlink");
1120
1121 git(&work, FIRST_COMMIT, &["add", "-A"]);
1122 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
1123
1124 std::fs::write(work.join("README.md"), b"hello again\n").expect("write");
1125 git(&work, SECOND_COMMIT, &["add", "-A"]);
1126 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
1127
1128 git(
1129 &work,
1130 THIRD_COMMIT,
1131 &["commit", "--quiet", "--allow-empty", "-m", ODD_MESSAGE],
1132 );
1133
1134 git(
1135 &work,
1136 THIRD_COMMIT,
1137 &[
1138 "push",
1139 "--quiet",
1140 repo.to_str().expect("utf-8 fixture path"),
1141 "main",
1142 ],
1143 );
1144
1145 (dir, query)
1146 }
1147
1148 /// A listing keyed by name, so an assertion does not depend on an order the port
1149 /// explicitly does not promise.
1150 fn by_name(entries: Vec<TreeEntry>) -> HashMap<String, TreeEntry> {
1151 entries
1152 .into_iter()
1153 .map(|entry| (entry.name.clone(), entry))
1154 .collect()
1155 }
1156
1157 // --- grep ------------------------------------------------------------------
1158
1159 async fn hits(query: &str, limit: usize) -> Vec<GrepHit> {
1160 let (_dir, query_port) = populated();
1161 let commit = query_port
1162 .resolve(&handle(), &repo_name(), &rev("main"))
1163 .await
1164 .expect("should read")
1165 .expect("main resolves");
1166
1167 query_port
1168 .grep(&handle(), &repo_name(), &commit, query, limit)
1169 .await
1170 .expect("should grep")
1171 }
1172
1173 #[tokio::test]
1174 async fn a_search_finds_the_line_it_matched() {
1175 let found = hits("fn main", 10).await;
1176
1177 assert_eq!(found.len(), 1);
1178 assert_eq!(found[0].path.as_str(), "src/deep/file.rs");
1179 assert_eq!(found[0].line, 1);
1180 assert_eq!(found[0].text, "fn main() {}");
1181 }
1182
1183 #[tokio::test]
1184 async fn a_search_that_matches_nothing_is_not_a_failure() {
1185 // `git grep` exits 1 here, which every other command in this module would treat
1186 // as a fault. This is the one exception, and this test is what pins it.
1187 assert_eq!(hits("nothing matches this", 10).await, Vec::new());
1188 }
1189
1190 #[tokio::test]
1191 async fn a_binary_file_is_never_reported() {
1192 // `bin.dat` contains 0x00 0x01 0xff 0xfe 0x80, so a byte-wise search would hit
1193 // it. `-I` is what keeps unreadable matches off the page.
1194 let found = hits("\u{fffd}", 10).await;
1195
1196 assert!(
1197 found.iter().all(|hit| hit.path.as_str() != "bin.dat"),
1198 "a binary file should never appear in results"
1199 );
1200 }
1201
1202 #[tokio::test]
1203 async fn a_search_is_a_fixed_string_not_a_pattern() {
1204 // `.` would match every line if this were a regular expression.
1205 assert_eq!(hits("hello.again", 10).await, Vec::new());
1206 }
1207
1208 #[tokio::test]
1209 async fn the_limit_bounds_what_comes_back() {
1210 // Every file in the fixture contains an `e` somewhere, so this is more than one
1211 // match without depending on how many.
1212 assert_eq!(hits("e", 2).await.len(), 2);
1213 }
1214
1215 // --- an empty repository ---------------------------------------------------
1216
1217 #[tokio::test]
1218 async fn an_empty_repository_has_no_default_branch() {
1219 // The distinction the port exists for: HEAD names `main`, but `main` has no
1220 // commits, so "nothing pushed yet" rather than a branch a page can browse.
1221 let (_dir, query) = empty();
1222
1223 assert_eq!(
1224 query
1225 .default_branch(&handle(), &repo_name())
1226 .await
1227 .expect("should read"),
1228 None
1229 );
1230 }
1231
1232 #[tokio::test]
1233 async fn nothing_resolves_in_an_empty_repository() {
1234 let (_dir, query) = empty();
1235
1236 for revision in ["main", "HEAD", "v1.0"] {
1237 assert_eq!(
1238 query
1239 .resolve(&handle(), &repo_name(), &rev(revision))
1240 .await
1241 .expect("should read"),
1242 None,
1243 "{revision} should not resolve"
1244 );
1245 }
1246 }
1247
1248 #[tokio::test]
1249 async fn an_empty_repository_lists_nothing_and_reads_nothing() {
1250 let (_dir, query) = empty();
1251
1252 assert_eq!(
1253 query
1254 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1255 .await
1256 .expect("should read"),
1257 None
1258 );
1259 assert_eq!(
1260 query
1261 .read_blob(
1262 &handle(),
1263 &repo_name(),
1264 &rev("main"),
1265 &path("README.md"),
1266 1024
1267 )
1268 .await
1269 .expect("should read"),
1270 None
1271 );
1272 }
1273
1274 #[tokio::test]
1275 async fn an_empty_repository_has_an_empty_log() {
1276 // `git log` is a fatal error here, and an empty list is what the port promises.
1277 let (_dir, query) = empty();
1278
1279 assert_eq!(
1280 query
1281 .log(&handle(), &repo_name(), &rev("main"), 10)
1282 .await
1283 .expect("should read"),
1284 Vec::new()
1285 );
1286 }
1287
1288 // --- a missing repository is a failure, not a 404 ---------------------------
1289
1290 #[tokio::test]
1291 async fn a_repository_that_is_not_on_disk_is_an_error() {
1292 // A record with no directory is a fault to investigate, not a "no such branch".
1293 // Answering `Ok(None)` here would hide it behind a plausible-looking 404.
1294 let (_dir, query) = empty();
1295 let missing = RepoName::new("never-created").expect("valid repository name");
1296
1297 assert!(query.default_branch(&handle(), &missing).await.is_err());
1298 assert!(
1299 query
1300 .resolve(&handle(), &missing, &rev("main"))
1301 .await
1302 .is_err()
1303 );
1304 assert!(
1305 query
1306 .list_tree(&handle(), &missing, &rev("main"), &RepoPath::root())
1307 .await
1308 .is_err()
1309 );
1310 assert!(
1311 query
1312 .log(&handle(), &missing, &rev("main"), 10)
1313 .await
1314 .is_err()
1315 );
1316 }
1317
1318 // --- default_branch and resolve --------------------------------------------
1319
1320 #[tokio::test]
1321 async fn a_repository_with_commits_reports_its_default_branch() {
1322 let (_dir, query) = populated();
1323
1324 assert_eq!(
1325 query
1326 .default_branch(&handle(), &repo_name())
1327 .await
1328 .expect("should read"),
1329 Some(RefName::from_trusted("main"))
1330 );
1331 }
1332
1333 #[tokio::test]
1334 async fn a_branch_and_head_resolve_to_the_same_commit() {
1335 let (_dir, query) = populated();
1336
1337 let main = query
1338 .resolve(&handle(), &repo_name(), &rev("main"))
1339 .await
1340 .expect("should read")
1341 .expect("main should resolve");
1342 let head = query
1343 .resolve(&handle(), &repo_name(), &rev("HEAD"))
1344 .await
1345 .expect("should read");
1346
1347 assert_eq!(head, Some(main));
1348 }
1349
1350 #[tokio::test]
1351 async fn a_commit_id_resolves_to_itself() {
1352 let (_dir, query) = populated();
1353
1354 let main = query
1355 .resolve(&handle(), &repo_name(), &rev("main"))
1356 .await
1357 .expect("should read")
1358 .expect("main should resolve");
1359
1360 assert_eq!(
1361 query
1362 .resolve(&handle(), &repo_name(), &rev(main.as_str()))
1363 .await
1364 .expect("should read"),
1365 Some(main)
1366 );
1367 }
1368
1369 #[tokio::test]
1370 async fn an_unknown_revision_resolves_to_nothing() {
1371 let (_dir, query) = populated();
1372
1373 assert_eq!(
1374 query
1375 .resolve(&handle(), &repo_name(), &rev("no-such-branch"))
1376 .await
1377 .expect("looking up a missing branch is not a failure"),
1378 None
1379 );
1380 }
1381
1382 // --- list_tree --------------------------------------------------------------
1383
1384 #[tokio::test]
1385 async fn the_root_lists_every_top_level_entry() {
1386 let (_dir, query) = populated();
1387
1388 let entries = by_name(
1389 query
1390 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1391 .await
1392 .expect("should read")
1393 .expect("the root is a directory"),
1394 );
1395
1396 let mut names: Vec<&str> = entries.keys().map(String::as_str).collect();
1397 names.sort_unstable();
1398 assert_eq!(
1399 names,
1400 vec![
1401 "README.md",
1402 "big.txt",
1403 "bin.dat",
1404 "link",
1405 "src",
1406 "with space.txt"
1407 ]
1408 );
1409 assert_eq!(entries["src"].kind, EntryKind::Tree);
1410 assert_eq!(entries["README.md"].kind, EntryKind::Blob);
1411 assert_eq!(
1412 entries["link"].kind,
1413 EntryKind::Symlink,
1414 "a symlink is its own kind, not a file"
1415 );
1416 }
1417
1418 #[tokio::test]
1419 async fn a_listing_carries_blob_sizes_but_not_tree_sizes() {
1420 let (_dir, query) = populated();
1421
1422 let entries = by_name(
1423 query
1424 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1425 .await
1426 .expect("should read")
1427 .expect("the root is a directory"),
1428 );
1429
1430 assert_eq!(entries["big.txt"].size, Some(100));
1431 assert_eq!(
1432 entries["src"].size, None,
1433 "a directory has no size a listing can show"
1434 );
1435 }
1436
1437 #[tokio::test]
1438 async fn a_filename_containing_a_space_survives_the_listing() {
1439 // The reason `-z` is not optional: split on whitespace and this name becomes two.
1440 let (_dir, query) = populated();
1441
1442 let entries = by_name(
1443 query
1444 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1445 .await
1446 .expect("should read")
1447 .expect("the root is a directory"),
1448 );
1449
1450 assert_eq!(entries["with space.txt"].kind, EntryKind::Blob);
1451 assert_eq!(entries["with space.txt"].size, Some(7));
1452 }
1453
1454 #[tokio::test]
1455 async fn a_nested_directory_lists_only_its_own_entries() {
1456 let (_dir, query) = populated();
1457
1458 let entries = query
1459 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src"))
1460 .await
1461 .expect("should read")
1462 .expect("src is a directory");
1463
1464 assert_eq!(entries.len(), 1);
1465 assert_eq!(entries[0].name, "deep", "names are entry names, not paths");
1466 assert_eq!(entries[0].kind, EntryKind::Tree);
1467
1468 let deeper = query
1469 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src/deep"))
1470 .await
1471 .expect("should read")
1472 .expect("src/deep is a directory");
1473
1474 assert_eq!(deeper.len(), 1);
1475 assert_eq!(deeper[0].name, "file.rs");
1476 }
1477
1478 #[tokio::test]
1479 async fn listing_a_file_as_a_directory_finds_nothing() {
1480 // git calls this a fatal error; to a visitor it is a wrong URL.
1481 let (_dir, query) = populated();
1482
1483 assert_eq!(
1484 query
1485 .list_tree(&handle(), &repo_name(), &rev("main"), &path("README.md"))
1486 .await
1487 .expect("a file is not a failure"),
1488 None
1489 );
1490 }
1491
1492 #[tokio::test]
1493 async fn listing_a_path_that_is_not_there_finds_nothing() {
1494 let (_dir, query) = populated();
1495
1496 for missing in ["nope", "src/nope", "README.md/nope"] {
1497 assert_eq!(
1498 query
1499 .list_tree(&handle(), &repo_name(), &rev("main"), &path(missing))
1500 .await
1501 .expect("should read"),
1502 None,
1503 "{missing} should not be found"
1504 );
1505 }
1506 }
1507
1508 #[tokio::test]
1509 async fn listing_at_an_unknown_revision_finds_nothing() {
1510 let (_dir, query) = populated();
1511
1512 assert_eq!(
1513 query
1514 .list_tree(
1515 &handle(),
1516 &repo_name(),
1517 &rev("no-such-branch"),
1518 &RepoPath::root()
1519 )
1520 .await
1521 .expect("should read"),
1522 None
1523 );
1524 }
1525
1526 #[tokio::test]
1527 async fn a_listing_reflects_the_revision_it_was_asked_for() {
1528 // Proves the revision is actually used rather than HEAD being read every time.
1529 let (_dir, query) = populated();
1530
1531 let first = query
1532 .log(&handle(), &repo_name(), &rev("main"), 10)
1533 .await
1534 .expect("should read")
1535 .last()
1536 .expect("three commits")
1537 .id
1538 .clone();
1539
1540 let old = query
1541 .read_blob(
1542 &handle(),
1543 &repo_name(),
1544 &rev(first.as_str()),
1545 &path("README.md"),
1546 1024,
1547 )
1548 .await
1549 .expect("should read")
1550 .expect("README existed in the first commit");
1551
1552 assert_eq!(old.content.as_deref(), Some(b"hello\n".as_slice()));
1553 }
1554
1555 // --- read_blob --------------------------------------------------------------
1556
1557 #[tokio::test]
1558 async fn a_file_is_read_with_its_size_and_content() {
1559 let (_dir, query) = populated();
1560
1561 let blob = query
1562 .read_blob(
1563 &handle(),
1564 &repo_name(),
1565 &rev("main"),
1566 &path("README.md"),
1567 1024,
1568 )
1569 .await
1570 .expect("should read")
1571 .expect("README.md is a file");
1572
1573 assert_eq!(blob.size, 12);
1574 assert_eq!(blob.content.as_deref(), Some(b"hello again\n".as_slice()));
1575 }
1576
1577 #[tokio::test]
1578 async fn a_binary_file_survives_intact() {
1579 // Nothing in this adapter may assume UTF-8: a lossy conversion here would swap
1580 // bytes for replacement characters and quietly corrupt every download.
1581 let (_dir, query) = populated();
1582
1583 let blob = query
1584 .read_blob(
1585 &handle(),
1586 &repo_name(),
1587 &rev("main"),
1588 &path("bin.dat"),
1589 1024,
1590 )
1591 .await
1592 .expect("should read")
1593 .expect("bin.dat is a file");
1594
1595 assert_eq!(blob.size, BINARY.len() as u64);
1596 assert_eq!(blob.content.as_deref(), Some(BINARY));
1597 }
1598
1599 #[tokio::test]
1600 async fn a_file_over_the_cap_reports_its_size_without_its_content() {
1601 let (_dir, query) = populated();
1602
1603 let blob = query
1604 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 10)
1605 .await
1606 .expect("should read")
1607 .expect("big.txt is a file");
1608
1609 assert_eq!(blob.size, 100, "the page still says how big it is");
1610 assert_eq!(blob.content, None);
1611 }
1612
1613 #[tokio::test]
1614 async fn a_file_exactly_at_the_cap_is_still_read() {
1615 let (_dir, query) = populated();
1616
1617 let blob = query
1618 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 100)
1619 .await
1620 .expect("should read")
1621 .expect("big.txt is a file");
1622
1623 assert_eq!(blob.content.map(|content| content.len()), Some(100));
1624 }
1625
1626 #[tokio::test]
1627 async fn a_file_with_a_space_in_its_name_can_be_read() {
1628 let (_dir, query) = populated();
1629
1630 let blob = query
1631 .read_blob(
1632 &handle(),
1633 &repo_name(),
1634 &rev("main"),
1635 &path("with space.txt"),
1636 1024,
1637 )
1638 .await
1639 .expect("should read")
1640 .expect("the file is there");
1641
1642 assert_eq!(blob.content.as_deref(), Some(b"spaced\n".as_slice()));
1643 }
1644
1645 #[tokio::test]
1646 async fn reading_a_directory_as_a_file_finds_nothing() {
1647 let (_dir, query) = populated();
1648
1649 for directory in ["src", "src/deep", ""] {
1650 assert_eq!(
1651 query
1652 .read_blob(
1653 &handle(),
1654 &repo_name(),
1655 &rev("main"),
1656 &path(directory),
1657 1024
1658 )
1659 .await
1660 .expect("a directory is not a failure"),
1661 None,
1662 "{directory:?} is a directory"
1663 );
1664 }
1665 }
1666
1667 #[tokio::test]
1668 async fn reading_a_path_that_is_not_there_finds_nothing() {
1669 let (_dir, query) = populated();
1670
1671 for missing in ["nope.txt", "src/nope.txt", "README.md/nope"] {
1672 assert_eq!(
1673 query
1674 .read_blob(&handle(), &repo_name(), &rev("main"), &path(missing), 1024)
1675 .await
1676 .expect("should read"),
1677 None,
1678 "{missing} should not be found"
1679 );
1680 }
1681 }
1682
1683 #[tokio::test]
1684 async fn a_blobs_id_matches_the_listing() {
1685 // Two commands, one object: if they disagree, one of the two parsers is wrong.
1686 let (_dir, query) = populated();
1687
1688 let entries = by_name(
1689 query
1690 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1691 .await
1692 .expect("should read")
1693 .expect("the root is a directory"),
1694 );
1695 let blob = query
1696 .read_blob(
1697 &handle(),
1698 &repo_name(),
1699 &rev("main"),
1700 &path("README.md"),
1701 1024,
1702 )
1703 .await
1704 .expect("should read")
1705 .expect("README.md is a file");
1706
1707 assert_eq!(blob.id, entries["README.md"].id);
1708 assert_eq!(Some(blob.size), entries["README.md"].size);
1709 }
1710
1711 #[tokio::test]
1712 async fn a_symlink_reads_as_its_target_path() {
1713 // The object store cannot tell a symlink from a file — both are blobs — and its
1714 // content is the path it points at. Showing that is more use than a blank page,
1715 // so this is a deliberate choice rather than an oversight.
1716 let (_dir, query) = populated();
1717
1718 let blob = query
1719 .read_blob(&handle(), &repo_name(), &rev("main"), &path("link"), 1024)
1720 .await
1721 .expect("should read")
1722 .expect("a symlink is readable");
1723
1724 assert_eq!(blob.content.as_deref(), Some(b"README.md".as_slice()));
1725 }
1726
1727 // --- log ---------------------------------------------------------------------
1728
1729 #[tokio::test]
1730 async fn the_log_is_newest_first() {
1731 let (_dir, query) = populated();
1732
1733 let commits = query
1734 .log(&handle(), &repo_name(), &rev("main"), 10)
1735 .await
1736 .expect("should read");
1737
1738 assert_eq!(commits.len(), 3);
1739 assert_eq!(
1740 commits
1741 .iter()
1742 .map(|commit| commit.summary.as_str())
1743 .collect::<Vec<_>>(),
1744 vec!["third: 'quotes', \"doubles\" | pipes", "second", "first"]
1745 );
1746 }
1747
1748 #[tokio::test]
1749 async fn the_log_stops_at_the_limit() {
1750 let (_dir, query) = populated();
1751
1752 let commits = query
1753 .log(&handle(), &repo_name(), &rev("main"), 2)
1754 .await
1755 .expect("should read");
1756
1757 assert_eq!(commits.len(), 2);
1758 assert_eq!(commits[0].summary, "third: 'quotes', \"doubles\" | pipes");
1759
1760 assert!(
1761 query
1762 .log(&handle(), &repo_name(), &rev("main"), 0)
1763 .await
1764 .expect("should read")
1765 .is_empty()
1766 );
1767 }
1768
1769 #[tokio::test]
1770 async fn a_commit_message_body_does_not_leak_into_the_summary() {
1771 // The message has a blank line and two body lines. A parser that split the
1772 // stream on newlines would report "body line one" as a separate commit.
1773 let (_dir, query) = populated();
1774
1775 let commits = query
1776 .log(&handle(), &repo_name(), &rev("main"), 10)
1777 .await
1778 .expect("should read");
1779
1780 assert_eq!(commits.len(), 3, "three commits, not five");
1781 assert!(
1782 !commits[0].summary.contains("body line"),
1783 "got: {:?}",
1784 commits[0].summary
1785 );
1786 }
1787
1788 #[tokio::test]
1789 async fn a_log_entry_carries_its_author_and_time() {
1790 let (_dir, query) = populated();
1791
1792 let commits = query
1793 .log(&handle(), &repo_name(), &rev("main"), 10)
1794 .await
1795 .expect("should read");
1796
1797 assert_eq!(commits[0].author_name, "Ada Lovelace");
1798 assert_eq!(
1799 commits[0].committed_at,
1800 UNIX_EPOCH + Duration::from_secs(THIRD_COMMIT as u64)
1801 );
1802 assert_eq!(
1803 commits[2].committed_at,
1804 UNIX_EPOCH + Duration::from_secs(FIRST_COMMIT as u64)
1805 );
1806 }
1807
1808 #[tokio::test]
1809 async fn a_log_entry_id_is_the_commit_the_revision_resolves_to() {
1810 let (_dir, query) = populated();
1811
1812 let head = query
1813 .resolve(&handle(), &repo_name(), &rev("main"))
1814 .await
1815 .expect("should read")
1816 .expect("main resolves");
1817 let commits = query
1818 .log(&handle(), &repo_name(), &rev("main"), 1)
1819 .await
1820 .expect("should read");
1821
1822 assert_eq!(commits[0].id, head);
1823 }
1824
1825 #[tokio::test]
1826 async fn the_log_of_an_unknown_revision_is_empty_rather_than_a_failure() {
1827 let (_dir, query) = populated();
1828
1829 assert_eq!(
1830 query
1831 .log(&handle(), &repo_name(), &rev("no-such-branch"), 10)
1832 .await
1833 .expect("an unknown branch is not a failure"),
1834 Vec::new()
1835 );
1836 }
1837
1838 #[tokio::test]
1839 async fn a_log_can_start_from_an_older_commit() {
1840 let (_dir, query) = populated();
1841
1842 let all = query
1843 .log(&handle(), &repo_name(), &rev("main"), 10)
1844 .await
1845 .expect("should read");
1846 let from_second = query
1847 .log(&handle(), &repo_name(), &rev(all[1].id.as_str()), 10)
1848 .await
1849 .expect("should read");
1850
1851 assert_eq!(from_second.len(), 2, "history behind the second commit");
1852 assert_eq!(from_second[0].id, all[1].id);
1853 }
1854
1855 // --- list_refs ---------------------------------------------------------------
1856
1857 /// The populated repository with a second branch and two tags pushed into it — one
1858 /// lightweight, one annotated, because they are different objects and the switcher
1859 /// must not care.
1860 fn with_refs() -> (TempDir, DiskGitQuery) {
1861 let (dir, query) = populated();
1862 let repo = query.repo_path(&handle(), &repo_name());
1863 let work = dir.path().join("work");
1864 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1865
1866 // A slash in the name, because that is what makes a ref name interesting: it is
1867 // the case the `/-/` separator in the URL exists for.
1868 git(&work, THIRD_COMMIT, &["branch", "feature/login"]);
1869 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1870 git(
1871 &work,
1872 THIRD_COMMIT,
1873 &["tag", "-a", "v2.0", "-m", "second release"],
1874 );
1875 git(
1876 &work,
1877 THIRD_COMMIT,
1878 &["push", "--quiet", &target, "feature/login"],
1879 );
1880 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1881
1882 (dir, query)
1883 }
1884
1885 fn named(refs: &[GitRef], kind: RefKind) -> Vec<String> {
1886 let mut names: Vec<String> = refs
1887 .iter()
1888 .filter(|git_ref| git_ref.kind == kind)
1889 .map(|git_ref| git_ref.name.to_string())
1890 .collect();
1891
1892 // The port promises no order, so a test that asserted one would be asserting
1893 // something the adapter is free to change.
1894 names.sort();
1895 names
1896 }
1897
1898 #[tokio::test]
1899 async fn branches_and_tags_are_listed_and_told_apart() {
1900 let (_dir, query) = with_refs();
1901
1902 let refs = query
1903 .list_refs(&handle(), &repo_name())
1904 .await
1905 .expect("should read");
1906
1907 assert_eq!(
1908 named(&refs, RefKind::Branch),
1909 vec!["feature/login".to_owned(), "main".to_owned()]
1910 );
1911 // An annotated tag points at a tag object rather than a commit, and a
1912 // lightweight one points straight at the commit. Both are tags.
1913 assert_eq!(
1914 named(&refs, RefKind::Tag),
1915 vec!["v1.0".to_owned(), "v2.0".to_owned()]
1916 );
1917 }
1918
1919 #[tokio::test]
1920 async fn a_repository_with_one_branch_lists_just_it() {
1921 let (_dir, query) = populated();
1922
1923 let refs = query
1924 .list_refs(&handle(), &repo_name())
1925 .await
1926 .expect("should read");
1927
1928 assert_eq!(refs.len(), 1);
1929 assert_eq!(refs[0].name.as_str(), "main");
1930 assert_eq!(refs[0].kind, RefKind::Branch);
1931 }
1932
1933 #[tokio::test]
1934 async fn an_empty_repository_lists_no_refs() {
1935 // HEAD names `main`, but no ref exists, so there is nothing to switch to. An
1936 // empty list rather than an error: nothing pushed yet is not a failure.
1937 let (_dir, query) = empty();
1938
1939 assert_eq!(
1940 query
1941 .list_refs(&handle(), &repo_name())
1942 .await
1943 .expect("should read"),
1944 Vec::new()
1945 );
1946 }
1947
1948 #[tokio::test]
1949 async fn listing_refs_of_a_repository_that_is_not_on_disk_is_an_error() {
1950 let (_dir, query) = empty();
1951 let missing = RepoName::new("never-created").expect("valid repository name");
1952
1953 assert!(query.list_refs(&handle(), &missing).await.is_err());
1954 }
1955
1956 #[test]
1957 fn refs_are_parsed_from_nul_terminated_records() {
1958 // git ends each record with a newline the format cannot suppress, so it arrives
1959 // in front of the next record's name. Anything outside the two namespaces is
1960 // dropped rather than guessed at.
1961 let stdout = b"refs/heads/main\0\nrefs/tags/v1.0\0\nrefs/pull/7/head\0\n";
1962 let refs = parse_refs(stdout);
1963
1964 assert_eq!(refs.len(), 2);
1965 assert_eq!(refs[0].name.as_str(), "main");
1966 assert_eq!(refs[0].kind, RefKind::Branch);
1967 assert_eq!(refs[1].name.as_str(), "v1.0");
1968 assert_eq!(refs[1].kind, RefKind::Tag);
1969 }
1970
1971 #[test]
1972 fn nothing_is_parsed_from_an_empty_listing() {
1973 assert!(parse_refs(b"").is_empty());
1974 }
1975
1976 // --- count_commits ------------------------------------------------------------
1977
1978 #[tokio::test]
1979 async fn commits_are_counted_from_the_revision_asked_about() {
1980 let (_dir, query) = populated();
1981
1982 assert_eq!(
1983 query
1984 .count_commits(&handle(), &repo_name(), &rev("main"))
1985 .await
1986 .expect("should count"),
1987 3
1988 );
1989 }
1990
1991 #[tokio::test]
1992 async fn a_revision_with_no_commits_counts_zero_rather_than_failing() {
1993 // Both spellings of "nothing here": an empty repository, and a branch that is
1994 // not there. `rev-list` is fatal for each, and a page asking how big a
1995 // repository is wants a number.
1996 let (_dir, empty_query) = empty();
1997 assert_eq!(
1998 empty_query
1999 .count_commits(&handle(), &repo_name(), &rev("main"))
2000 .await
2001 .expect("should count"),
2002 0
2003 );
2004
2005 let (_dir, query) = populated();
2006 assert_eq!(
2007 query
2008 .count_commits(&handle(), &repo_name(), &rev("no-such-branch"))
2009 .await
2010 .expect("should count"),
2011 0
2012 );
2013 }
2014
2015 #[tokio::test]
2016 async fn counting_a_repository_that_is_not_on_disk_is_an_error() {
2017 // Same rule as everywhere else here: absent from disk is a fault, not a zero.
2018 let (_dir, query) = empty();
2019 let missing = RepoName::new("gone").expect("valid repository name");
2020
2021 assert!(
2022 query
2023 .count_commits(&handle(), &missing, &rev("main"))
2024 .await
2025 .is_err()
2026 );
2027 }
2028
2029 // --- latest_tag ---------------------------------------------------------------
2030
2031 /// The populated repository with two annotated tags whose dates disagree with their
2032 /// names, so a test can tell "newest" from "last alphabetically".
2033 fn with_dated_tags() -> (TempDir, DiskGitQuery) {
2034 let (dir, query) = populated();
2035 let repo = query.repo_path(&handle(), &repo_name());
2036 let work = dir.path().join("work");
2037 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2038
2039 git(&work, FIRST_COMMIT, &["tag", "-a", "v1.0", "-m", "first"]);
2040 // Made later but named lower: sorting by name would pick `v1.0`.
2041 git(
2042 &work,
2043 THIRD_COMMIT,
2044 &["tag", "-a", "v0.9", "-m", "backport"],
2045 );
2046 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
2047
2048 (dir, query)
2049 }
2050
2051 #[tokio::test]
2052 async fn the_latest_tag_is_the_newest_one_not_the_last_one_alphabetically() {
2053 let (_dir, query) = with_dated_tags();
2054
2055 let tag = query
2056 .latest_tag(&handle(), &repo_name())
2057 .await
2058 .expect("should read")
2059 .expect("a tag");
2060
2061 assert_eq!(tag.name.as_str(), "v0.9");
2062 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
2063 }
2064
2065 #[tokio::test]
2066 async fn a_lightweight_tag_is_dated_by_the_commit_it_points_at() {
2067 // It has no date of its own, and `creatordate` is what fills that in.
2068 let (dir, query) = populated();
2069 let repo = query.repo_path(&handle(), &repo_name());
2070 let work = dir.path().join("work");
2071 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2072
2073 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
2074 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
2075
2076 let tag = query
2077 .latest_tag(&handle(), &repo_name())
2078 .await
2079 .expect("should read")
2080 .expect("a tag");
2081
2082 assert_eq!(tag.name.as_str(), "v1.0");
2083 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
2084 }
2085
2086 #[tokio::test]
2087 async fn a_repository_with_no_tags_has_no_latest_tag() {
2088 let (_dir, query) = populated();
2089 assert_eq!(
2090 query
2091 .latest_tag(&handle(), &repo_name())
2092 .await
2093 .expect("should read"),
2094 None
2095 );
2096
2097 let (_dir, empty_query) = empty();
2098 assert_eq!(
2099 empty_query
2100 .latest_tag(&handle(), &repo_name())
2101 .await
2102 .expect("should read"),
2103 None
2104 );
2105 }
2106
2107 #[test]
2108 fn a_tag_record_is_parsed_past_the_trailing_newline() {
2109 // `for-each-ref` ends every record with a newline the format cannot suppress,
2110 // exactly as it does for `parse_refs`.
2111 let tag = parse_latest_tag(b"refs/tags/v1.0\x001700000000\x00\n").expect("a tag");
2112
2113 assert_eq!(tag.name.as_str(), "v1.0");
2114 assert_eq!(tag.created_at, unix_time(1_700_000_000));
2115 }
2116
2117 #[test]
2118 fn nothing_is_parsed_from_an_empty_tag_listing() {
2119 assert_eq!(parse_latest_tag(b""), None);
2120 // A ref outside the tags namespace is not a tag, whatever asked for it.
2121 assert_eq!(
2122 parse_latest_tag(b"refs/heads/main\x001700000000\x00\n"),
2123 None
2124 );
2125 }
2126
2127 // --- branches and tags ---------------------------------------------------------
2128
2129 /// The populated repository with two more branches, each left at an older commit so
2130 /// the three tips carry three different dates — otherwise "newest first" is not
2131 /// something a test can see.
2132 fn with_branches() -> (TempDir, DiskGitQuery) {
2133 let (dir, query) = populated();
2134 let repo = query.repo_path(&handle(), &repo_name());
2135 let work = dir.path().join("work");
2136 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2137
2138 git(&work, THIRD_COMMIT, &["branch", "stale", "main~2"]);
2139 // A slash in the name, because that is the case the `/-/` separator exists for.
2140 git(&work, THIRD_COMMIT, &["branch", "feature/login", "main~1"]);
2141 git(
2142 &work,
2143 THIRD_COMMIT,
2144 &["push", "--quiet", &target, "stale", "feature/login"],
2145 );
2146
2147 (dir, query)
2148 }
2149
2150 /// The populated repository with one lightweight tag and two annotated ones, made
2151 /// on three different dates so ordering and the annotated/lightweight split can be
2152 /// asserted together.
2153 fn with_mixed_tags() -> (TempDir, DiskGitQuery) {
2154 let (dir, query) = populated();
2155 let repo = query.repo_path(&handle(), &repo_name());
2156 let work = dir.path().join("work");
2157 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2158
2159 // Lightweight: no object of its own, so its date is the commit's.
2160 git(&work, FIRST_COMMIT, &["tag", "v0.5", "main~2"]);
2161 git(
2162 &work,
2163 SECOND_COMMIT,
2164 &["tag", "-a", "v1.0", "-m", "first release"],
2165 );
2166 git(
2167 &work,
2168 THIRD_COMMIT,
2169 &["tag", "-a", "v2.0", "-m", "second release\n\nnotes below"],
2170 );
2171 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
2172
2173 (dir, query)
2174 }
2175
2176 #[tokio::test]
2177 async fn branches_are_newest_first_with_the_default_marked() {
2178 let (_dir, query) = with_branches();
2179
2180 let rows = query
2181 .branches(&handle(), &repo_name())
2182 .await
2183 .expect("should read");
2184
2185 assert_eq!(
2186 rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
2187 vec!["main", "feature/login", "stale"]
2188 );
2189 // `%(HEAD)` marks exactly one branch, and it is the one a bare repository's
2190 // HEAD names — which is what the page pins to the top.
2191 assert_eq!(
2192 rows.iter()
2193 .filter(|row| row.is_default)
2194 .map(|row| row.name.as_str())
2195 .collect::<Vec<_>>(),
2196 vec!["main"]
2197 );
2198 }
2199
2200 #[tokio::test]
2201 async fn a_branch_row_carries_its_tip_commit() {
2202 let (_dir, query) = with_branches();
2203
2204 let rows = query
2205 .branches(&handle(), &repo_name())
2206 .await
2207 .expect("should read");
2208
2209 let main = rows.first().expect("main is first");
2210
2211 // The subject only, from a message whose body would leak into it if the format
2212 // were read line-wise.
2213 assert_eq!(main.summary, "third: 'quotes', \"doubles\" | pipes");
2214 assert_eq!(main.committed_at, unix_time(THIRD_COMMIT));
2215 assert_eq!(main.commit.as_str().len(), 40);
2216
2217 let stale = rows.last().expect("stale is last");
2218 assert_eq!(stale.summary, "first");
2219 assert_eq!(stale.committed_at, unix_time(FIRST_COMMIT));
2220 }
2221
2222 #[tokio::test]
2223 async fn an_empty_repository_has_no_branches() {
2224 // The same answer `list_refs` gives, and for the same reason: nothing pushed
2225 // yet is not a failure. It is also how the page knows to show the push snippet.
2226 let (_dir, query) = empty();
2227
2228 assert_eq!(
2229 query
2230 .branches(&handle(), &repo_name())
2231 .await
2232 .expect("should read"),
2233 Vec::new()
2234 );
2235 }
2236
2237 #[tokio::test]
2238 async fn tags_are_newest_first_and_only_annotated_ones_carry_a_message() {
2239 let (_dir, query) = with_mixed_tags();
2240
2241 let rows = query
2242 .tags(&handle(), &repo_name())
2243 .await
2244 .expect("should read");
2245
2246 assert_eq!(
2247 rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
2248 vec!["v2.0", "v1.0", "v0.5"]
2249 );
2250
2251 let newest = &rows[0];
2252 assert!(newest.annotated);
2253 // The subject of the tag's own message, not its body.
2254 assert_eq!(newest.message.as_deref(), Some("second release"));
2255 assert_eq!(newest.created_at, unix_time(THIRD_COMMIT));
2256
2257 let lightweight = &rows[2];
2258 assert!(!lightweight.annotated);
2259 // A lightweight tag has no message of its own; the commit's subject is the
2260 // commit's, and reporting it would invent one.
2261 assert_eq!(lightweight.message, None);
2262 assert_eq!(lightweight.created_at, unix_time(FIRST_COMMIT));
2263 }
2264
2265 #[tokio::test]
2266 async fn an_annotated_tag_reports_the_commit_it_peels_to() {
2267 // Its `objectname` is the tag object, which is not what a visitor browses.
2268 let (_dir, query) = with_mixed_tags();
2269
2270 let tip = query
2271 .branches(&handle(), &repo_name())
2272 .await
2273 .expect("should read")
2274 .into_iter()
2275 .find(|row| row.name.as_str() == "main")
2276 .expect("main");
2277
2278 let annotated = query
2279 .tags(&handle(), &repo_name())
2280 .await
2281 .expect("should read")
2282 .into_iter()
2283 .find(|row| row.name.as_str() == "v1.0")
2284 .expect("v1.0");
2285
2286 assert_eq!(annotated.commit, tip.commit);
2287 }
2288
2289 #[tokio::test]
2290 async fn a_repository_with_no_tags_lists_none() {
2291 let (_dir, query) = populated();
2292 assert_eq!(
2293 query
2294 .tags(&handle(), &repo_name())
2295 .await
2296 .expect("should read"),
2297 Vec::new()
2298 );
2299
2300 let (_dir, empty_query) = empty();
2301 assert_eq!(
2302 empty_query
2303 .tags(&handle(), &repo_name())
2304 .await
2305 .expect("should read"),
2306 Vec::new()
2307 );
2308 }
2309
2310 #[tokio::test]
2311 async fn listing_rows_of_a_repository_that_is_not_on_disk_is_an_error() {
2312 let (_dir, query) = empty();
2313 let missing = RepoName::new("never-created").expect("valid repository name");
2314
2315 assert!(query.branches(&handle(), &missing).await.is_err());
2316 assert!(query.tags(&handle(), &missing).await.is_err());
2317 }
2318
2319 #[test]
2320 fn branch_records_survive_the_newline_git_puts_between_them() {
2321 let rows = parse_branches(
2322 b"refs/heads/main\x00*\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x001700000000\x00first\x00\nrefs/heads/side\x00 \x00bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x001700000100\x00second\x00\n",
2323 )
2324 .expect("should parse");
2325
2326 assert_eq!(rows.len(), 2);
2327 assert!(rows[0].is_default);
2328 assert_eq!(rows[0].summary, "first");
2329 // The newline in front of `refs/heads/side` is git's record separator, not part
2330 // of the name.
2331 assert_eq!(rows[1].name.as_str(), "side");
2332 assert!(!rows[1].is_default);
2333 assert_eq!(rows[1].committed_at, unix_time(1_700_000_100));
2334 }
2335
2336 #[test]
2337 fn a_lightweight_tags_empty_peel_does_not_shift_the_fields_after_it() {
2338 // The reason `ref_fields` keeps empty fields: filtering them would read this
2339 // record's date as its commit id.
2340 let rows = parse_tags(
2341 b"refs/tags/v1.0\x00commit\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x00\x001700000000\x00a commit subject\x00\n",
2342 )
2343 .expect("should parse");
2344
2345 assert_eq!(rows.len(), 1);
2346 assert_eq!(rows[0].name.as_str(), "v1.0");
2347 assert_eq!(rows[0].commit.as_str(), "a".repeat(40));
2348 assert!(!rows[0].annotated);
2349 assert_eq!(rows[0].message, None);
2350 assert_eq!(rows[0].created_at, unix_time(1_700_000_000));
2351 }
2352
2353 #[test]
2354 fn nothing_is_parsed_from_an_empty_row_listing() {
2355 assert_eq!(parse_branches(b"").expect("should parse"), Vec::new());
2356 assert_eq!(parse_tags(b"").expect("should parse"), Vec::new());
2357 }
2358
2359 #[test]
2360 fn a_record_with_the_wrong_number_of_fields_is_a_fault() {
2361 // Skipping a ref Steid cannot link to is right; misreading git's output is not.
2362 assert!(parse_branches(b"refs/heads/main\x00*\x00\n").is_err());
2363 }
2364
2365 // --- helpers ------------------------------------------------------------------
2366
2367 #[tokio::test]
2368 async fn repo_path_lands_under_the_data_directory() {
2369 let query = DiskGitQuery::new("/data");
2370
2371 assert_eq!(
2372 query.repo_path(&handle(), &repo_name()),
2373 PathBuf::from("/data/jamesgill/steid.git")
2374 );
2375 }
2376
2377 #[test]
2378 fn a_pre_epoch_commit_time_does_not_panic() {
2379 // git will hand back a negative `%ct` for an imported history, and
2380 // `UNIX_EPOCH + Duration` cannot represent it.
2381 assert!(unix_time(-1) < UNIX_EPOCH);
2382 assert_eq!(unix_time(0), UNIX_EPOCH);
2383 }
2384
2385 #[tokio::test]
2386 async fn a_read_that_exceeds_its_limit_is_a_timeout_not_a_fault() {
2387 let (_dir, repo) = fixture_repo_for_timeout().await;
2388 let error = run_within(&repo, ["rev-parse", "HEAD"], Duration::ZERO)
2389 .await
2390 .expect_err("a zero limit cannot be met");
2391 assert!(error.is_timeout(), "{error}");
2392 }
2393
2394 /// A bare repository with nothing in it: `rev-parse` failing is not the point, the
2395 /// process being cut off before it can answer is.
2396 async fn fixture_repo_for_timeout() -> (TempDir, std::path::PathBuf) {
2397 let dir = TempDir::new().unwrap();
2398 let repo = dir.path().join("t.git");
2399 let status = git_command()
2400 .args(["init", "--bare", "-q"])
2401 .arg(&repo)
2402 .status()
2403 .await
2404 .unwrap();
2405 assert!(status.success());
2406 (dir, repo)
2407 }
2408}