steid

@jamesgill /

steid/src/infrastructure/git_query.rs
43.5 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::{CommitSummary, EntryKind, ObjectId, OrgName, RefName, RepoName, RepoPath, TreeEntry},
43 infrastructure::git::git_command,
44};
45
46/// The words `cat-file --batch-check` ends a line with when it did not resolve a spec.
47///
48/// `missing` covers the common cases; `ambiguous` is an abbreviated id matching more
49/// than one object, and `dangling` and `notdir` appear when following a `^{}` or a path
50/// through something that cannot hold one. None of them is a failure — they are the
51/// answer "no such thing here".
52const NOT_FOUND_MARKERS: [&str; 4] = ["missing", "ambiguous", "dangling", "notdir"];
53
54/// Repository contents, read from bare repositories under a data directory.
55#[derive(Debug, Clone)]
56pub struct DiskGitQuery {
57 data_dir: PathBuf,
58}
59
60impl DiskGitQuery {
61 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
62 Self {
63 data_dir: data_dir.into(),
64 }
65 }
66
67 /// Where a repository lives, matching `DiskGitStorage`'s layout.
68 pub(crate) fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
69 self.data_dir
70 .join(handle.as_str())
71 .join(format!("{name}.git"))
72 }
73}
74
75impl GitQuery for DiskGitQuery {
76 async fn default_branch(
77 &self,
78 handle: &OrgName,
79 name: &RepoName,
80 ) -> Result<Option<RefName>, GitQueryError> {
81 let repo = self.repo_path(handle, name);
82
83 // An empty repository's HEAD names a branch that does not exist yet, so
84 // `symbolic-ref` happily answers `main` for a repository with nothing in it.
85 // Whether HEAD *resolves* is the actual question, and it is asked first.
86 let Some(head) = object_info(&repo, "HEAD").await? else {
87 return Ok(None);
88 };
89
90 let branch = run(&repo, [OsStr::new("symbolic-ref"), OsStr::new("HEAD")]).await;
91
92 match branch {
93 Ok(output) => {
94 let full = String::from_utf8_lossy(&output.stdout).trim().to_owned();
95 // `refs/heads/main` rather than `--short`, because `--short` shortens
96 // only as far as is unambiguous and would hand back `heads/main` for a
97 // repository that also has a tag called `main`.
98 let short = full.strip_prefix("refs/heads/").unwrap_or(&full);
99
100 Ok(Some(RefName::from_trusted(short)))
101 }
102 // A detached HEAD is not a state Steid creates, but a repository pushed into
103 // from elsewhere can be in it. The commit is still browsable, so name it
104 // rather than claiming the repository is empty — which is what `Ok(None)`
105 // would mean to a page.
106 Err(_) => Ok(Some(RefName::from_trusted(head.id.as_str()))),
107 }
108 }
109
110 async fn resolve(
111 &self,
112 handle: &OrgName,
113 name: &RepoName,
114 rev: &RefName,
115 ) -> Result<Option<ObjectId>, GitQueryError> {
116 let repo = self.repo_path(handle, name);
117
118 // `^{commit}` peels an annotated tag to what it points at, and refuses a
119 // revision that names a tree or a blob — a browse page wants a commit, and
120 // returning a tree id here would fail confusingly two calls later.
121 let spec = format!("{}^{{commit}}", rev.as_str());
122
123 Ok(object_info(&repo, &spec).await?.map(|info| info.id))
124 }
125
126 async fn list_tree(
127 &self,
128 handle: &OrgName,
129 name: &RepoName,
130 rev: &RefName,
131 path: &RepoPath,
132 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
133 let repo = self.repo_path(handle, name);
134
135 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
136 return Ok(None);
137 };
138
139 // A file is not a directory. Asking `ls-tree` anyway is a fatal error, which is
140 // exactly the confusion this check exists to avoid.
141 if info.kind != ObjectKind::Tree {
142 return Ok(None);
143 }
144
145 // `-z` because a filename may contain a newline, and `--long` for blob sizes.
146 // The already-resolved tree id is passed rather than the user's revision, so
147 // nothing here has to think about what git's revision parser might make of it.
148 let output = run(
149 &repo,
150 [
151 OsStr::new("ls-tree"),
152 OsStr::new("-z"),
153 OsStr::new("--long"),
154 OsStr::new(info.id.as_str()),
155 ],
156 )
157 .await?;
158
159 parse_tree(&output.stdout).map(Some)
160 }
161
162 async fn read_blob(
163 &self,
164 handle: &OrgName,
165 name: &RepoName,
166 rev: &RefName,
167 path: &RepoPath,
168 max_bytes: u64,
169 ) -> Result<Option<Blob>, GitQueryError> {
170 let repo = self.repo_path(handle, name);
171
172 // The root is a tree, and `{rev}:` is how git spells it — but a caller asking to
173 // read the root is asking for a file that is not there.
174 if path.is_root() {
175 return Ok(None);
176 }
177
178 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
179 return Ok(None);
180 };
181
182 // Symlinks are blobs whose content is the target path, and are read as such:
183 // showing where a link points is more use than a blank page. Trees and
184 // submodules are not files.
185 if info.kind != ObjectKind::Blob {
186 return Ok(None);
187 }
188
189 // The size comes from the object header, so an oversized file is never read.
190 // Doing this the other way round — read, then measure — is how one URL becomes
191 // an out-of-memory kill.
192 let content = if info.size > max_bytes {
193 None
194 } else {
195 let output = run(
196 &repo,
197 [
198 OsStr::new("cat-file"),
199 OsStr::new("blob"),
200 OsStr::new(info.id.as_str()),
201 ],
202 )
203 .await?;
204
205 Some(output.stdout)
206 };
207
208 Ok(Some(Blob {
209 id: info.id,
210 size: info.size,
211 content,
212 }))
213 }
214
215 async fn log(
216 &self,
217 handle: &OrgName,
218 name: &RepoName,
219 rev: &RefName,
220 limit: usize,
221 ) -> Result<Vec<CommitSummary>, GitQueryError> {
222 let repo = self.repo_path(handle, name);
223
224 // `git log` on a repository with no commits is a fatal error, and so is a log of
225 // a branch that does not exist. Resolving first turns both into the empty list
226 // the port's signature promises, without having to read meaning into a stderr
227 // string that is localised and free to change between git versions.
228 let Some(commit) = self.resolve(handle, name, rev).await? else {
229 return Ok(Vec::new());
230 };
231
232 if limit == 0 {
233 return Ok(Vec::new());
234 }
235
236 // Every separator is a NUL: `-z` between commits, `%x00` between fields. A
237 // commit message contains newlines as a matter of course, and a name can contain
238 // almost anything, so splitting on lines or whitespace would misread real
239 // history rather than exotic history.
240 let format = "--format=%H%x00%ct%x00%an%x00%s";
241 let count = format!("--max-count={limit}");
242
243 let output = run(
244 &repo,
245 [
246 OsStr::new("log"),
247 OsStr::new("-z"),
248 OsStr::new(&count),
249 OsStr::new(format),
250 OsStr::new(commit.as_str()),
251 ],
252 )
253 .await?;
254
255 parse_log(&output.stdout)
256 }
257}
258
259/// What `cat-file --batch-check` said about one object.
260#[derive(Debug, Clone, PartialEq, Eq)]
261struct ObjectInfo {
262 id: ObjectId,
263 kind: ObjectKind,
264 size: u64,
265}
266
267/// A git object's type, as its header spells it.
268///
269/// Distinct from [`EntryKind`], which is about what a tree entry *means* — the object
270/// store cannot tell a symlink from a file, because both are blobs.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272enum ObjectKind {
273 Blob,
274 Tree,
275 Commit,
276 Tag,
277}
278
279impl ObjectKind {
280 fn from_str(value: &str) -> Option<Self> {
281 match value {
282 "blob" => Some(Self::Blob),
283 "tree" => Some(Self::Tree),
284 "commit" => Some(Self::Commit),
285 "tag" => Some(Self::Tag),
286 _ => None,
287 }
288 }
289}
290
291/// How git addresses a path inside a revision: `{rev}:{path}`, and `{rev}:` for the root.
292fn tree_spec(rev: &RefName, path: &RepoPath) -> String {
293 format!("{}:{}", rev.as_str(), path.as_str())
294}
295
296/// Asks git what one revision-and-path resolves to, or `None` if it resolves to nothing.
297///
298/// The spec goes over stdin rather than in an argument, so no revision or path can ever
299/// be read as a flag regardless of what validation upstream does or stops doing.
300async fn object_info(repo: &Path, spec: &str) -> Result<Option<ObjectInfo>, GitQueryError> {
301 let mut command = git_command();
302 command
303 .arg("-C")
304 .arg(repo)
305 .arg("cat-file")
306 .arg("--batch-check")
307 .stdin(Stdio::piped())
308 .stdout(Stdio::piped())
309 .stderr(Stdio::piped());
310
311 let mut child = command
312 .spawn()
313 .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
314
315 let mut stdin = child.stdin.take().expect("stdin was piped");
316
317 // One short line, far below a pipe's buffer, so writing before waiting cannot
318 // deadlock. Dropping stdin is what ends the batch — git would otherwise wait for
319 // another spec forever.
320 stdin
321 .write_all(format!("{spec}\n").as_bytes())
322 .await
323 .map_err(|error| {
324 GitQueryError::new(format!("could not ask git about {spec:?}: {error}"))
325 })?;
326 drop(stdin);
327
328 let output = child
329 .wait_with_output()
330 .await
331 .map_err(|error| GitQueryError::new(format!("could not wait for git: {error}")))?;
332
333 // Per the module note: a non-zero exit here is never "not found".
334 if !output.status.success() {
335 return Err(GitQueryError::new(format!(
336 "git exited with {} looking up {spec:?}: {}",
337 output.status,
338 String::from_utf8_lossy(&output.stderr).trim()
339 )));
340 }
341
342 let line = String::from_utf8_lossy(&output.stdout);
343 let line = line.trim_end_matches('\n');
344
345 // The marker is checked before the field count, because a not-found line echoes the
346 // spec back — and a spec naming a file with spaces in it has no fixed field count.
347 if line
348 .rsplit(' ')
349 .next()
350 .is_some_and(|last| NOT_FOUND_MARKERS.contains(&last))
351 {
352 return Ok(None);
353 }
354
355 let fields: Vec<&str> = line.split_whitespace().collect();
356 let [id, kind, size] = fields[..] else {
357 return Err(GitQueryError::new(format!(
358 "git described {spec:?} in a shape we do not understand: {line:?}"
359 )));
360 };
361
362 Ok(Some(ObjectInfo {
363 // Validated rather than trusted. git's ids are trustworthy, but this is a parse
364 // of text whose layout we have assumed, and an id is about to appear in a URL —
365 // a misread field should stop here rather than surface as a broken link. The
366 // cost is a length and hex check next to a process spawn.
367 id: ObjectId::new(id)
368 .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))?,
369 kind: ObjectKind::from_str(kind).ok_or_else(|| {
370 GitQueryError::new(format!("git reported an unknown object type {kind:?}"))
371 })?,
372 size: size.parse().map_err(|_| {
373 GitQueryError::new(format!("git reported an unreadable object size {size:?}"))
374 })?,
375 }))
376}
377
378/// Parses `ls-tree -z --long` output.
379///
380/// Each record is `<mode> SP <type> SP <id> SP <size> TAB <name>`, NUL-terminated, where
381/// the size is space-padded and `-` for anything that is not a blob. The name is
382/// everything after the first tab and is *raw bytes* — which is why the split happens
383/// before any attempt to read it as text.
384fn parse_tree(stdout: &[u8]) -> Result<Vec<TreeEntry>, GitQueryError> {
385 let mut entries = Vec::new();
386
387 for record in stdout.split(|byte| *byte == 0) {
388 if record.is_empty() {
389 continue;
390 }
391
392 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
393 return Err(GitQueryError::new(
394 "git listed a tree entry with no name separator",
395 ));
396 };
397
398 let (meta, name) = record.split_at(tab);
399 let name = &name[1..];
400
401 let meta = std::str::from_utf8(meta).map_err(|_| {
402 GitQueryError::new("git listed a tree entry whose metadata is not text")
403 })?;
404
405 let fields: Vec<&str> = meta.split_whitespace().collect();
406 let [mode, _type, id, size] = fields[..] else {
407 return Err(GitQueryError::new(format!(
408 "git listed a tree entry in a shape we do not understand: {meta:?}"
409 )));
410 };
411
412 entries.push(TreeEntry {
413 // Lossy, because `TreeEntry::name` is a `String` and a filename is not
414 // required to be UTF-8. A replacement character renders; refusing to list
415 // the whole directory because one file has an odd name does not.
416 name: String::from_utf8_lossy(name).into_owned(),
417 kind: EntryKind::from_mode(mode)
418 .map_err(|error| GitQueryError::new(format!("git listed {name:?}: {error}")))?,
419 id: ObjectId::new(id).map_err(|error| {
420 GitQueryError::new(format!("git named a bad object id: {error}"))
421 })?,
422 // `-` for a tree or a submodule, which have no size a listing can show.
423 size: size.parse().ok(),
424 });
425 }
426
427 // Unsorted on purpose: ordering is `TreeEntry::ordering_key`'s decision, made once
428 // in the application rather than differently in each adapter.
429 Ok(entries)
430}
431
432/// Parses the NUL-separated `log` stream into four-field records.
433fn parse_log(stdout: &[u8]) -> Result<Vec<CommitSummary>, GitQueryError> {
434 // `-z` terminates the last record too, so the split leaves a trailing empty field
435 // that is not a commit.
436 let fields: Vec<&[u8]> = stdout
437 .split(|byte| *byte == 0)
438 .filter(|field| !field.is_empty())
439 .collect();
440
441 let mut commits = Vec::with_capacity(fields.len() / 4);
442
443 for record in fields.chunks(4) {
444 let [id, committed_at, author_name, summary] = record[..] else {
445 return Err(GitQueryError::new(
446 "git logged a commit with missing fields",
447 ));
448 };
449
450 let id = String::from_utf8_lossy(id);
451 let committed_at = String::from_utf8_lossy(committed_at);
452 let committed_at: i64 = committed_at.trim().parse().map_err(|_| {
453 GitQueryError::new(format!(
454 "git logged an unreadable commit time {committed_at:?}"
455 ))
456 })?;
457
458 commits.push(CommitSummary {
459 id: ObjectId::new(id.trim()).map_err(|error| {
460 GitQueryError::new(format!("git named a bad object id: {error}"))
461 })?,
462 // `%s` is git's subject: the first paragraph, joined into one line. Trimmed
463 // to the first line anyway, because that invariant is git's rather than
464 // something this parser should assume.
465 summary: String::from_utf8_lossy(summary)
466 .lines()
467 .next()
468 .unwrap_or_default()
469 .to_owned(),
470 author_name: String::from_utf8_lossy(author_name).into_owned(),
471 committed_at: unix_time(committed_at),
472 });
473 }
474
475 Ok(commits)
476}
477
478/// A unix timestamp as a `SystemTime`, including the negative ones.
479///
480/// A commit dated before 1970 is either a lie or an import from something older than
481/// git, and both exist in real repositories. `UNIX_EPOCH + Duration` would panic on the
482/// subtraction it cannot do.
483fn unix_time(seconds: i64) -> SystemTime {
484 match u64::try_from(seconds) {
485 Ok(seconds) => UNIX_EPOCH + Duration::from_secs(seconds),
486 Err(_) => UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs()),
487 }
488}
489
490/// Runs a git command inside a repository and fails on a non-zero exit.
491///
492/// Only ever used for commands whose subject has already been confirmed to exist, so a
493/// failure really is a failure. Built from [`git_command`] so the host isolation 0006
494/// insists on cannot drift out of this module.
495async fn run<I, S>(repo: &Path, args: I) -> Result<Output, GitQueryError>
496where
497 I: IntoIterator<Item = S>,
498 S: AsRef<OsStr>,
499{
500 let mut command = git_command();
501 command.arg("-C").arg(repo).args(args).stdin(Stdio::null());
502
503 let output = command
504 .output()
505 .await
506 .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
507
508 if !output.status.success() {
509 return Err(GitQueryError::new(format!(
510 "git exited with {}: {}",
511 output.status,
512 String::from_utf8_lossy(&output.stderr).trim()
513 )));
514 }
515
516 Ok(output)
517}
518
519#[cfg(test)]
520mod tests {
521 use std::collections::HashMap;
522
523 use tempfile::TempDir;
524
525 use super::*;
526 use crate::domain::EntryKind;
527
528 /// Fixed so a timestamp assertion is exact rather than approximate.
529 const FIRST_COMMIT: i64 = 1_700_000_000;
530 const SECOND_COMMIT: i64 = 1_700_000_100;
531 const THIRD_COMMIT: i64 = 1_700_000_200;
532
533 /// A subject with the punctuation a naive parser splits on, followed by a body — so
534 /// a test can prove the body does not leak into the summary.
535 const ODD_MESSAGE: &str =
536 "third: 'quotes', \"doubles\" | pipes\n\nbody line one\nbody line two";
537
538 const BINARY: &[u8] = &[0x00, 0x01, 0xff, 0xfe, 0x80];
539
540 fn handle() -> OrgName {
541 OrgName::new("jamesgill").expect("valid handle")
542 }
543
544 fn repo_name() -> RepoName {
545 RepoName::new("steid").expect("valid repository name")
546 }
547
548 fn rev(value: &str) -> RefName {
549 RefName::new(value).expect("valid revision")
550 }
551
552 fn path(value: &str) -> RepoPath {
553 RepoPath::new(value).expect("valid path")
554 }
555
556 /// Runs git in a fixture, isolated from the host's configuration the same way the
557 /// adapter is — otherwise a developer's `commit.gpgsign` or `init.defaultBranch`
558 /// decides whether the suite passes.
559 fn git(dir: &Path, when: i64, args: &[&str]) {
560 let date = format!("@{when} +0000");
561
562 let output = std::process::Command::new("git")
563 .arg("-C")
564 .arg(dir)
565 .args(args)
566 .env("GIT_CONFIG_GLOBAL", "/dev/null")
567 .env("GIT_CONFIG_SYSTEM", "/dev/null")
568 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
569 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
570 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
571 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
572 .env("GIT_AUTHOR_DATE", &date)
573 .env("GIT_COMMITTER_DATE", &date)
574 .output()
575 .expect("git should be on PATH");
576
577 assert!(
578 output.status.success(),
579 "git {args:?} failed: {}",
580 String::from_utf8_lossy(&output.stderr)
581 );
582 }
583
584 /// A data directory holding one empty bare repository, exactly as Steid creates it.
585 ///
586 /// The `TempDir` is returned because dropping it deletes the fixture.
587 fn empty() -> (TempDir, DiskGitQuery) {
588 let dir = TempDir::new().expect("temp dir");
589 let query = DiskGitQuery::new(dir.path());
590 let repo = query.repo_path(&handle(), &repo_name());
591
592 std::fs::create_dir_all(repo.parent().expect("has a parent")).expect("create handle dir");
593 git(
594 dir.path(),
595 FIRST_COMMIT,
596 &[
597 "init",
598 "--bare",
599 "--quiet",
600 "--template=",
601 "--initial-branch=main",
602 "--",
603 repo.to_str().expect("utf-8 fixture path"),
604 ],
605 );
606
607 (dir, query)
608 }
609
610 /// The empty repository with three commits pushed into it, the way a real one fills
611 /// up — a working copy and a push, rather than plumbing straight into the object
612 /// store.
613 fn populated() -> (TempDir, DiskGitQuery) {
614 let (dir, query) = empty();
615 let repo = query.repo_path(&handle(), &repo_name());
616 let work = dir.path().join("work");
617
618 std::fs::create_dir_all(work.join("src/deep")).expect("create work tree");
619 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
620
621 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
622 std::fs::write(work.join("with space.txt"), b"spaced\n").expect("write");
623 std::fs::write(work.join("bin.dat"), BINARY).expect("write");
624 std::fs::write(work.join("big.txt"), vec![b'x'; 100]).expect("write");
625 std::fs::write(work.join("src/deep/file.rs"), b"fn main() {}\n").expect("write");
626 std::os::unix::fs::symlink("README.md", work.join("link")).expect("symlink");
627
628 git(&work, FIRST_COMMIT, &["add", "-A"]);
629 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
630
631 std::fs::write(work.join("README.md"), b"hello again\n").expect("write");
632 git(&work, SECOND_COMMIT, &["add", "-A"]);
633 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
634
635 git(
636 &work,
637 THIRD_COMMIT,
638 &["commit", "--quiet", "--allow-empty", "-m", ODD_MESSAGE],
639 );
640
641 git(
642 &work,
643 THIRD_COMMIT,
644 &[
645 "push",
646 "--quiet",
647 repo.to_str().expect("utf-8 fixture path"),
648 "main",
649 ],
650 );
651
652 (dir, query)
653 }
654
655 /// A listing keyed by name, so an assertion does not depend on an order the port
656 /// explicitly does not promise.
657 fn by_name(entries: Vec<TreeEntry>) -> HashMap<String, TreeEntry> {
658 entries
659 .into_iter()
660 .map(|entry| (entry.name.clone(), entry))
661 .collect()
662 }
663
664 // --- an empty repository ---------------------------------------------------
665
666 #[tokio::test]
667 async fn an_empty_repository_has_no_default_branch() {
668 // The distinction the port exists for: HEAD names `main`, but `main` has no
669 // commits, so "nothing pushed yet" rather than a branch a page can browse.
670 let (_dir, query) = empty();
671
672 assert_eq!(
673 query
674 .default_branch(&handle(), &repo_name())
675 .await
676 .expect("should read"),
677 None
678 );
679 }
680
681 #[tokio::test]
682 async fn nothing_resolves_in_an_empty_repository() {
683 let (_dir, query) = empty();
684
685 for revision in ["main", "HEAD", "v1.0"] {
686 assert_eq!(
687 query
688 .resolve(&handle(), &repo_name(), &rev(revision))
689 .await
690 .expect("should read"),
691 None,
692 "{revision} should not resolve"
693 );
694 }
695 }
696
697 #[tokio::test]
698 async fn an_empty_repository_lists_nothing_and_reads_nothing() {
699 let (_dir, query) = empty();
700
701 assert_eq!(
702 query
703 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
704 .await
705 .expect("should read"),
706 None
707 );
708 assert_eq!(
709 query
710 .read_blob(
711 &handle(),
712 &repo_name(),
713 &rev("main"),
714 &path("README.md"),
715 1024
716 )
717 .await
718 .expect("should read"),
719 None
720 );
721 }
722
723 #[tokio::test]
724 async fn an_empty_repository_has_an_empty_log() {
725 // `git log` is a fatal error here, and an empty list is what the port promises.
726 let (_dir, query) = empty();
727
728 assert_eq!(
729 query
730 .log(&handle(), &repo_name(), &rev("main"), 10)
731 .await
732 .expect("should read"),
733 Vec::new()
734 );
735 }
736
737 // --- a missing repository is a failure, not a 404 ---------------------------
738
739 #[tokio::test]
740 async fn a_repository_that_is_not_on_disk_is_an_error() {
741 // A record with no directory is a fault to investigate, not a "no such branch".
742 // Answering `Ok(None)` here would hide it behind a plausible-looking 404.
743 let (_dir, query) = empty();
744 let missing = RepoName::new("never-created").expect("valid repository name");
745
746 assert!(query.default_branch(&handle(), &missing).await.is_err());
747 assert!(
748 query
749 .resolve(&handle(), &missing, &rev("main"))
750 .await
751 .is_err()
752 );
753 assert!(
754 query
755 .list_tree(&handle(), &missing, &rev("main"), &RepoPath::root())
756 .await
757 .is_err()
758 );
759 assert!(
760 query
761 .log(&handle(), &missing, &rev("main"), 10)
762 .await
763 .is_err()
764 );
765 }
766
767 // --- default_branch and resolve --------------------------------------------
768
769 #[tokio::test]
770 async fn a_repository_with_commits_reports_its_default_branch() {
771 let (_dir, query) = populated();
772
773 assert_eq!(
774 query
775 .default_branch(&handle(), &repo_name())
776 .await
777 .expect("should read"),
778 Some(RefName::from_trusted("main"))
779 );
780 }
781
782 #[tokio::test]
783 async fn a_branch_and_head_resolve_to_the_same_commit() {
784 let (_dir, query) = populated();
785
786 let main = query
787 .resolve(&handle(), &repo_name(), &rev("main"))
788 .await
789 .expect("should read")
790 .expect("main should resolve");
791 let head = query
792 .resolve(&handle(), &repo_name(), &rev("HEAD"))
793 .await
794 .expect("should read");
795
796 assert_eq!(head, Some(main));
797 }
798
799 #[tokio::test]
800 async fn a_commit_id_resolves_to_itself() {
801 let (_dir, query) = populated();
802
803 let main = query
804 .resolve(&handle(), &repo_name(), &rev("main"))
805 .await
806 .expect("should read")
807 .expect("main should resolve");
808
809 assert_eq!(
810 query
811 .resolve(&handle(), &repo_name(), &rev(main.as_str()))
812 .await
813 .expect("should read"),
814 Some(main)
815 );
816 }
817
818 #[tokio::test]
819 async fn an_unknown_revision_resolves_to_nothing() {
820 let (_dir, query) = populated();
821
822 assert_eq!(
823 query
824 .resolve(&handle(), &repo_name(), &rev("no-such-branch"))
825 .await
826 .expect("looking up a missing branch is not a failure"),
827 None
828 );
829 }
830
831 // --- list_tree --------------------------------------------------------------
832
833 #[tokio::test]
834 async fn the_root_lists_every_top_level_entry() {
835 let (_dir, query) = populated();
836
837 let entries = by_name(
838 query
839 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
840 .await
841 .expect("should read")
842 .expect("the root is a directory"),
843 );
844
845 let mut names: Vec<&str> = entries.keys().map(String::as_str).collect();
846 names.sort_unstable();
847 assert_eq!(
848 names,
849 vec![
850 "README.md",
851 "big.txt",
852 "bin.dat",
853 "link",
854 "src",
855 "with space.txt"
856 ]
857 );
858 assert_eq!(entries["src"].kind, EntryKind::Tree);
859 assert_eq!(entries["README.md"].kind, EntryKind::Blob);
860 assert_eq!(
861 entries["link"].kind,
862 EntryKind::Symlink,
863 "a symlink is its own kind, not a file"
864 );
865 }
866
867 #[tokio::test]
868 async fn a_listing_carries_blob_sizes_but_not_tree_sizes() {
869 let (_dir, query) = populated();
870
871 let entries = by_name(
872 query
873 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
874 .await
875 .expect("should read")
876 .expect("the root is a directory"),
877 );
878
879 assert_eq!(entries["big.txt"].size, Some(100));
880 assert_eq!(
881 entries["src"].size, None,
882 "a directory has no size a listing can show"
883 );
884 }
885
886 #[tokio::test]
887 async fn a_filename_containing_a_space_survives_the_listing() {
888 // The reason `-z` is not optional: split on whitespace and this name becomes two.
889 let (_dir, query) = populated();
890
891 let entries = by_name(
892 query
893 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
894 .await
895 .expect("should read")
896 .expect("the root is a directory"),
897 );
898
899 assert_eq!(entries["with space.txt"].kind, EntryKind::Blob);
900 assert_eq!(entries["with space.txt"].size, Some(7));
901 }
902
903 #[tokio::test]
904 async fn a_nested_directory_lists_only_its_own_entries() {
905 let (_dir, query) = populated();
906
907 let entries = query
908 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src"))
909 .await
910 .expect("should read")
911 .expect("src is a directory");
912
913 assert_eq!(entries.len(), 1);
914 assert_eq!(entries[0].name, "deep", "names are entry names, not paths");
915 assert_eq!(entries[0].kind, EntryKind::Tree);
916
917 let deeper = query
918 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src/deep"))
919 .await
920 .expect("should read")
921 .expect("src/deep is a directory");
922
923 assert_eq!(deeper.len(), 1);
924 assert_eq!(deeper[0].name, "file.rs");
925 }
926
927 #[tokio::test]
928 async fn listing_a_file_as_a_directory_finds_nothing() {
929 // git calls this a fatal error; to a visitor it is a wrong URL.
930 let (_dir, query) = populated();
931
932 assert_eq!(
933 query
934 .list_tree(&handle(), &repo_name(), &rev("main"), &path("README.md"))
935 .await
936 .expect("a file is not a failure"),
937 None
938 );
939 }
940
941 #[tokio::test]
942 async fn listing_a_path_that_is_not_there_finds_nothing() {
943 let (_dir, query) = populated();
944
945 for missing in ["nope", "src/nope", "README.md/nope"] {
946 assert_eq!(
947 query
948 .list_tree(&handle(), &repo_name(), &rev("main"), &path(missing))
949 .await
950 .expect("should read"),
951 None,
952 "{missing} should not be found"
953 );
954 }
955 }
956
957 #[tokio::test]
958 async fn listing_at_an_unknown_revision_finds_nothing() {
959 let (_dir, query) = populated();
960
961 assert_eq!(
962 query
963 .list_tree(
964 &handle(),
965 &repo_name(),
966 &rev("no-such-branch"),
967 &RepoPath::root()
968 )
969 .await
970 .expect("should read"),
971 None
972 );
973 }
974
975 #[tokio::test]
976 async fn a_listing_reflects_the_revision_it_was_asked_for() {
977 // Proves the revision is actually used rather than HEAD being read every time.
978 let (_dir, query) = populated();
979
980 let first = query
981 .log(&handle(), &repo_name(), &rev("main"), 10)
982 .await
983 .expect("should read")
984 .last()
985 .expect("three commits")
986 .id
987 .clone();
988
989 let old = query
990 .read_blob(
991 &handle(),
992 &repo_name(),
993 &rev(first.as_str()),
994 &path("README.md"),
995 1024,
996 )
997 .await
998 .expect("should read")
999 .expect("README existed in the first commit");
1000
1001 assert_eq!(old.content.as_deref(), Some(b"hello\n".as_slice()));
1002 }
1003
1004 // --- read_blob --------------------------------------------------------------
1005
1006 #[tokio::test]
1007 async fn a_file_is_read_with_its_size_and_content() {
1008 let (_dir, query) = populated();
1009
1010 let blob = query
1011 .read_blob(
1012 &handle(),
1013 &repo_name(),
1014 &rev("main"),
1015 &path("README.md"),
1016 1024,
1017 )
1018 .await
1019 .expect("should read")
1020 .expect("README.md is a file");
1021
1022 assert_eq!(blob.size, 12);
1023 assert_eq!(blob.content.as_deref(), Some(b"hello again\n".as_slice()));
1024 }
1025
1026 #[tokio::test]
1027 async fn a_binary_file_survives_intact() {
1028 // Nothing in this adapter may assume UTF-8: a lossy conversion here would swap
1029 // bytes for replacement characters and quietly corrupt every download.
1030 let (_dir, query) = populated();
1031
1032 let blob = query
1033 .read_blob(
1034 &handle(),
1035 &repo_name(),
1036 &rev("main"),
1037 &path("bin.dat"),
1038 1024,
1039 )
1040 .await
1041 .expect("should read")
1042 .expect("bin.dat is a file");
1043
1044 assert_eq!(blob.size, BINARY.len() as u64);
1045 assert_eq!(blob.content.as_deref(), Some(BINARY));
1046 }
1047
1048 #[tokio::test]
1049 async fn a_file_over_the_cap_reports_its_size_without_its_content() {
1050 let (_dir, query) = populated();
1051
1052 let blob = query
1053 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 10)
1054 .await
1055 .expect("should read")
1056 .expect("big.txt is a file");
1057
1058 assert_eq!(blob.size, 100, "the page still says how big it is");
1059 assert_eq!(blob.content, None);
1060 }
1061
1062 #[tokio::test]
1063 async fn a_file_exactly_at_the_cap_is_still_read() {
1064 let (_dir, query) = populated();
1065
1066 let blob = query
1067 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 100)
1068 .await
1069 .expect("should read")
1070 .expect("big.txt is a file");
1071
1072 assert_eq!(blob.content.map(|content| content.len()), Some(100));
1073 }
1074
1075 #[tokio::test]
1076 async fn a_file_with_a_space_in_its_name_can_be_read() {
1077 let (_dir, query) = populated();
1078
1079 let blob = query
1080 .read_blob(
1081 &handle(),
1082 &repo_name(),
1083 &rev("main"),
1084 &path("with space.txt"),
1085 1024,
1086 )
1087 .await
1088 .expect("should read")
1089 .expect("the file is there");
1090
1091 assert_eq!(blob.content.as_deref(), Some(b"spaced\n".as_slice()));
1092 }
1093
1094 #[tokio::test]
1095 async fn reading_a_directory_as_a_file_finds_nothing() {
1096 let (_dir, query) = populated();
1097
1098 for directory in ["src", "src/deep", ""] {
1099 assert_eq!(
1100 query
1101 .read_blob(
1102 &handle(),
1103 &repo_name(),
1104 &rev("main"),
1105 &path(directory),
1106 1024
1107 )
1108 .await
1109 .expect("a directory is not a failure"),
1110 None,
1111 "{directory:?} is a directory"
1112 );
1113 }
1114 }
1115
1116 #[tokio::test]
1117 async fn reading_a_path_that_is_not_there_finds_nothing() {
1118 let (_dir, query) = populated();
1119
1120 for missing in ["nope.txt", "src/nope.txt", "README.md/nope"] {
1121 assert_eq!(
1122 query
1123 .read_blob(&handle(), &repo_name(), &rev("main"), &path(missing), 1024)
1124 .await
1125 .expect("should read"),
1126 None,
1127 "{missing} should not be found"
1128 );
1129 }
1130 }
1131
1132 #[tokio::test]
1133 async fn a_blobs_id_matches_the_listing() {
1134 // Two commands, one object: if they disagree, one of the two parsers is wrong.
1135 let (_dir, query) = populated();
1136
1137 let entries = by_name(
1138 query
1139 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1140 .await
1141 .expect("should read")
1142 .expect("the root is a directory"),
1143 );
1144 let blob = query
1145 .read_blob(
1146 &handle(),
1147 &repo_name(),
1148 &rev("main"),
1149 &path("README.md"),
1150 1024,
1151 )
1152 .await
1153 .expect("should read")
1154 .expect("README.md is a file");
1155
1156 assert_eq!(blob.id, entries["README.md"].id);
1157 assert_eq!(Some(blob.size), entries["README.md"].size);
1158 }
1159
1160 #[tokio::test]
1161 async fn a_symlink_reads_as_its_target_path() {
1162 // The object store cannot tell a symlink from a file — both are blobs — and its
1163 // content is the path it points at. Showing that is more use than a blank page,
1164 // so this is a deliberate choice rather than an oversight.
1165 let (_dir, query) = populated();
1166
1167 let blob = query
1168 .read_blob(&handle(), &repo_name(), &rev("main"), &path("link"), 1024)
1169 .await
1170 .expect("should read")
1171 .expect("a symlink is readable");
1172
1173 assert_eq!(blob.content.as_deref(), Some(b"README.md".as_slice()));
1174 }
1175
1176 // --- log ---------------------------------------------------------------------
1177
1178 #[tokio::test]
1179 async fn the_log_is_newest_first() {
1180 let (_dir, query) = populated();
1181
1182 let commits = query
1183 .log(&handle(), &repo_name(), &rev("main"), 10)
1184 .await
1185 .expect("should read");
1186
1187 assert_eq!(commits.len(), 3);
1188 assert_eq!(
1189 commits
1190 .iter()
1191 .map(|commit| commit.summary.as_str())
1192 .collect::<Vec<_>>(),
1193 vec!["third: 'quotes', \"doubles\" | pipes", "second", "first"]
1194 );
1195 }
1196
1197 #[tokio::test]
1198 async fn the_log_stops_at_the_limit() {
1199 let (_dir, query) = populated();
1200
1201 let commits = query
1202 .log(&handle(), &repo_name(), &rev("main"), 2)
1203 .await
1204 .expect("should read");
1205
1206 assert_eq!(commits.len(), 2);
1207 assert_eq!(commits[0].summary, "third: 'quotes', \"doubles\" | pipes");
1208
1209 assert!(
1210 query
1211 .log(&handle(), &repo_name(), &rev("main"), 0)
1212 .await
1213 .expect("should read")
1214 .is_empty()
1215 );
1216 }
1217
1218 #[tokio::test]
1219 async fn a_commit_message_body_does_not_leak_into_the_summary() {
1220 // The message has a blank line and two body lines. A parser that split the
1221 // stream on newlines would report "body line one" as a separate commit.
1222 let (_dir, query) = populated();
1223
1224 let commits = query
1225 .log(&handle(), &repo_name(), &rev("main"), 10)
1226 .await
1227 .expect("should read");
1228
1229 assert_eq!(commits.len(), 3, "three commits, not five");
1230 assert!(
1231 !commits[0].summary.contains("body line"),
1232 "got: {:?}",
1233 commits[0].summary
1234 );
1235 }
1236
1237 #[tokio::test]
1238 async fn a_log_entry_carries_its_author_and_time() {
1239 let (_dir, query) = populated();
1240
1241 let commits = query
1242 .log(&handle(), &repo_name(), &rev("main"), 10)
1243 .await
1244 .expect("should read");
1245
1246 assert_eq!(commits[0].author_name, "Ada Lovelace");
1247 assert_eq!(
1248 commits[0].committed_at,
1249 UNIX_EPOCH + Duration::from_secs(THIRD_COMMIT as u64)
1250 );
1251 assert_eq!(
1252 commits[2].committed_at,
1253 UNIX_EPOCH + Duration::from_secs(FIRST_COMMIT as u64)
1254 );
1255 }
1256
1257 #[tokio::test]
1258 async fn a_log_entry_id_is_the_commit_the_revision_resolves_to() {
1259 let (_dir, query) = populated();
1260
1261 let head = query
1262 .resolve(&handle(), &repo_name(), &rev("main"))
1263 .await
1264 .expect("should read")
1265 .expect("main resolves");
1266 let commits = query
1267 .log(&handle(), &repo_name(), &rev("main"), 1)
1268 .await
1269 .expect("should read");
1270
1271 assert_eq!(commits[0].id, head);
1272 }
1273
1274 #[tokio::test]
1275 async fn the_log_of_an_unknown_revision_is_empty_rather_than_a_failure() {
1276 let (_dir, query) = populated();
1277
1278 assert_eq!(
1279 query
1280 .log(&handle(), &repo_name(), &rev("no-such-branch"), 10)
1281 .await
1282 .expect("an unknown branch is not a failure"),
1283 Vec::new()
1284 );
1285 }
1286
1287 #[tokio::test]
1288 async fn a_log_can_start_from_an_older_commit() {
1289 let (_dir, query) = populated();
1290
1291 let all = query
1292 .log(&handle(), &repo_name(), &rev("main"), 10)
1293 .await
1294 .expect("should read");
1295 let from_second = query
1296 .log(&handle(), &repo_name(), &rev(all[1].id.as_str()), 10)
1297 .await
1298 .expect("should read");
1299
1300 assert_eq!(from_second.len(), 2, "history behind the second commit");
1301 assert_eq!(from_second[0].id, all[1].id);
1302 }
1303
1304 // --- helpers ------------------------------------------------------------------
1305
1306 #[tokio::test]
1307 async fn repo_path_lands_under_the_data_directory() {
1308 let query = DiskGitQuery::new("/data");
1309
1310 assert_eq!(
1311 query.repo_path(&handle(), &repo_name()),
1312 PathBuf::from("/data/jamesgill/steid.git")
1313 );
1314 }
1315
1316 #[test]
1317 fn a_pre_epoch_commit_time_does_not_panic() {
1318 // git will hand back a negative `%ct` for an imported history, and
1319 // `UNIX_EPOCH + Duration` cannot represent it.
1320 assert!(unix_time(-1) < UNIX_EPOCH);
1321 assert_eq!(unix_time(0), UNIX_EPOCH);
1322 }
1323}