@jpgilldev / steid

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