steid

@jamesgill /

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