steid

@jamesgill /

steid/src/infrastructure/git_query.rs
61.2 KBCode·Blame·Raw
1//! Reading repository contents through the `git` binary.
2//!
3//! One process per question, per the Milestone 5 amendment to
4//! [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md): ~11–12ms of
5//! that is `execve`, and the upgrade to a kept-alive `cat-file --batch` is an adapter
6//! change behind this unchanged port.
7//!
8//! # Telling "not there" from "broken"
9//!
10//! git reports both with a non-zero exit, and at different call sites with *different*
11//! non-zero exits: `rev-parse --verify --quiet` says 1 for an unknown ref but 128 for a
12//! missing repository, while `ls-tree` pointed at a blob says 128 for what is, to a
13//! visitor, a 404. Keying off exit codes therefore either turns a typo'd URL into a 500
14//! or buries a corrupt repository behind a "not found".
15//!
16//! So every existence question here goes through one command that does not use its exit
17//! status to answer: `git cat-file --batch-check` writes `<spec> missing` on stdout and
18//! **exits 0** for anything it cannot resolve — an unknown ref, an absent path, a path
19//! traversing through a blob, a submodule's commit that lives in another repository.
20//! That gives a single rule for this whole module:
21//!
22//! **A non-zero exit from git is always an error.** "Not found" is a value read off
23//! stdout, never an exit code. Everything else — git missing from `PATH`, a repository
24//! directory that is gone, an unreadable object store — surfaces as [`GitQueryError`]
25//! carrying git's own words.
26//!
27//! The listing and content commands are only ever reached *after* `--batch-check` has
28//! confirmed the object and its type, and are handed the resolved object id rather than
29//! the user's revision, so their failure modes are genuinely faults.
30
31use std::{
32 ffi::OsStr,
33 path::{Path, PathBuf},
34 process::{Output, Stdio},
35 time::{Duration, SystemTime, UNIX_EPOCH},
36};
37
38use tokio::io::AsyncWriteExt;
39
40use crate::{
41 application::port::{Blob, GitQuery, GitQueryError},
42 domain::{
43 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.
680/// How long one read-side `git` process may run before it is killed.
681///
682/// Every read here is a subprocess on a request path, and nothing bounded it before
683/// blame and grep arrived — either can run for a long time on a large repository, and
684/// a request that never finishes holds a worker for as long as the client waits.
685/// Twenty seconds is far above any read a page should make and far below "hung".
686const GIT_TIMEOUT: Duration = Duration::from_secs(20);
687
688async fn run<I, S>(repo: &Path, args: I) -> Result<Output, GitQueryError>
689where
690 I: IntoIterator<Item = S>,
691 S: AsRef<OsStr>,
692{
693 run_within(repo, args, GIT_TIMEOUT).await
694}
695
696/// [`run`] with an explicit limit, so the timeout path can be tested without waiting
697/// twenty seconds for it.
698async fn run_within<I, S>(repo: &Path, args: I, limit: Duration) -> Result<Output, GitQueryError>
699where
700 I: IntoIterator<Item = S>,
701 S: AsRef<OsStr>,
702{
703 let mut command = git_command();
704 command
705 .arg("-C")
706 .arg(repo)
707 .args(args)
708 .stdin(Stdio::null())
709 // Dropping the future on timeout must take the process with it, or a killed
710 // request leaves git running to completion for nobody.
711 .kill_on_drop(true);
712
713 let output = match tokio::time::timeout(limit, command.output()).await {
714 Ok(result) => {
715 result.map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?
716 }
717 Err(_elapsed) => return Err(GitQueryError::timed_out(limit)),
718 };
719
720 if !output.status.success() {
721 return Err(GitQueryError::new(format!(
722 "git exited with {}: {}",
723 output.status,
724 String::from_utf8_lossy(&output.stderr).trim()
725 )));
726 }
727
728 Ok(output)
729}
730
731#[cfg(test)]
732mod tests {
733 use std::collections::HashMap;
734
735 use tempfile::TempDir;
736
737 use super::*;
738 use crate::domain::EntryKind;
739
740 /// Fixed so a timestamp assertion is exact rather than approximate.
741 const FIRST_COMMIT: i64 = 1_700_000_000;
742 const SECOND_COMMIT: i64 = 1_700_000_100;
743 const THIRD_COMMIT: i64 = 1_700_000_200;
744
745 /// A subject with the punctuation a naive parser splits on, followed by a body — so
746 /// a test can prove the body does not leak into the summary.
747 const ODD_MESSAGE: &str =
748 "third: 'quotes', \"doubles\" | pipes\n\nbody line one\nbody line two";
749
750 const BINARY: &[u8] = &[0x00, 0x01, 0xff, 0xfe, 0x80];
751
752 fn handle() -> OrgName {
753 OrgName::new("jamesgill").expect("valid handle")
754 }
755
756 fn repo_name() -> RepoName {
757 RepoName::new("steid").expect("valid repository name")
758 }
759
760 fn rev(value: &str) -> RefName {
761 RefName::new(value).expect("valid revision")
762 }
763
764 fn path(value: &str) -> RepoPath {
765 RepoPath::new(value).expect("valid path")
766 }
767
768 /// Runs git in a fixture, isolated from the host's configuration the same way the
769 /// adapter is — otherwise a developer's `commit.gpgsign` or `init.defaultBranch`
770 /// decides whether the suite passes.
771 fn git(dir: &Path, when: i64, args: &[&str]) {
772 let date = format!("@{when} +0000");
773
774 let output = std::process::Command::new("git")
775 .arg("-C")
776 .arg(dir)
777 .args(args)
778 .env("GIT_CONFIG_GLOBAL", "/dev/null")
779 .env("GIT_CONFIG_SYSTEM", "/dev/null")
780 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
781 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
782 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
783 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
784 .env("GIT_AUTHOR_DATE", &date)
785 .env("GIT_COMMITTER_DATE", &date)
786 .output()
787 .expect("git should be on PATH");
788
789 assert!(
790 output.status.success(),
791 "git {args:?} failed: {}",
792 String::from_utf8_lossy(&output.stderr)
793 );
794 }
795
796 /// A data directory holding one empty bare repository, exactly as Steid creates it.
797 ///
798 /// The `TempDir` is returned because dropping it deletes the fixture.
799 fn empty() -> (TempDir, DiskGitQuery) {
800 let dir = TempDir::new().expect("temp dir");
801 let query = DiskGitQuery::new(dir.path());
802 let repo = query.repo_path(&handle(), &repo_name());
803
804 std::fs::create_dir_all(repo.parent().expect("has a parent")).expect("create handle dir");
805 git(
806 dir.path(),
807 FIRST_COMMIT,
808 &[
809 "init",
810 "--bare",
811 "--quiet",
812 "--template=",
813 "--initial-branch=main",
814 "--",
815 repo.to_str().expect("utf-8 fixture path"),
816 ],
817 );
818
819 (dir, query)
820 }
821
822 /// The empty repository with three commits pushed into it, the way a real one fills
823 /// up — a working copy and a push, rather than plumbing straight into the object
824 /// store.
825 fn populated() -> (TempDir, DiskGitQuery) {
826 let (dir, query) = empty();
827 let repo = query.repo_path(&handle(), &repo_name());
828 let work = dir.path().join("work");
829
830 std::fs::create_dir_all(work.join("src/deep")).expect("create work tree");
831 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
832
833 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
834 std::fs::write(work.join("with space.txt"), b"spaced\n").expect("write");
835 std::fs::write(work.join("bin.dat"), BINARY).expect("write");
836 std::fs::write(work.join("big.txt"), vec![b'x'; 100]).expect("write");
837 std::fs::write(work.join("src/deep/file.rs"), b"fn main() {}\n").expect("write");
838 std::os::unix::fs::symlink("README.md", work.join("link")).expect("symlink");
839
840 git(&work, FIRST_COMMIT, &["add", "-A"]);
841 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
842
843 std::fs::write(work.join("README.md"), b"hello again\n").expect("write");
844 git(&work, SECOND_COMMIT, &["add", "-A"]);
845 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
846
847 git(
848 &work,
849 THIRD_COMMIT,
850 &["commit", "--quiet", "--allow-empty", "-m", ODD_MESSAGE],
851 );
852
853 git(
854 &work,
855 THIRD_COMMIT,
856 &[
857 "push",
858 "--quiet",
859 repo.to_str().expect("utf-8 fixture path"),
860 "main",
861 ],
862 );
863
864 (dir, query)
865 }
866
867 /// A listing keyed by name, so an assertion does not depend on an order the port
868 /// explicitly does not promise.
869 fn by_name(entries: Vec<TreeEntry>) -> HashMap<String, TreeEntry> {
870 entries
871 .into_iter()
872 .map(|entry| (entry.name.clone(), entry))
873 .collect()
874 }
875
876 // --- an empty repository ---------------------------------------------------
877
878 #[tokio::test]
879 async fn an_empty_repository_has_no_default_branch() {
880 // The distinction the port exists for: HEAD names `main`, but `main` has no
881 // commits, so "nothing pushed yet" rather than a branch a page can browse.
882 let (_dir, query) = empty();
883
884 assert_eq!(
885 query
886 .default_branch(&handle(), &repo_name())
887 .await
888 .expect("should read"),
889 None
890 );
891 }
892
893 #[tokio::test]
894 async fn nothing_resolves_in_an_empty_repository() {
895 let (_dir, query) = empty();
896
897 for revision in ["main", "HEAD", "v1.0"] {
898 assert_eq!(
899 query
900 .resolve(&handle(), &repo_name(), &rev(revision))
901 .await
902 .expect("should read"),
903 None,
904 "{revision} should not resolve"
905 );
906 }
907 }
908
909 #[tokio::test]
910 async fn an_empty_repository_lists_nothing_and_reads_nothing() {
911 let (_dir, query) = empty();
912
913 assert_eq!(
914 query
915 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
916 .await
917 .expect("should read"),
918 None
919 );
920 assert_eq!(
921 query
922 .read_blob(
923 &handle(),
924 &repo_name(),
925 &rev("main"),
926 &path("README.md"),
927 1024
928 )
929 .await
930 .expect("should read"),
931 None
932 );
933 }
934
935 #[tokio::test]
936 async fn an_empty_repository_has_an_empty_log() {
937 // `git log` is a fatal error here, and an empty list is what the port promises.
938 let (_dir, query) = empty();
939
940 assert_eq!(
941 query
942 .log(&handle(), &repo_name(), &rev("main"), 10)
943 .await
944 .expect("should read"),
945 Vec::new()
946 );
947 }
948
949 // --- a missing repository is a failure, not a 404 ---------------------------
950
951 #[tokio::test]
952 async fn a_repository_that_is_not_on_disk_is_an_error() {
953 // A record with no directory is a fault to investigate, not a "no such branch".
954 // Answering `Ok(None)` here would hide it behind a plausible-looking 404.
955 let (_dir, query) = empty();
956 let missing = RepoName::new("never-created").expect("valid repository name");
957
958 assert!(query.default_branch(&handle(), &missing).await.is_err());
959 assert!(
960 query
961 .resolve(&handle(), &missing, &rev("main"))
962 .await
963 .is_err()
964 );
965 assert!(
966 query
967 .list_tree(&handle(), &missing, &rev("main"), &RepoPath::root())
968 .await
969 .is_err()
970 );
971 assert!(
972 query
973 .log(&handle(), &missing, &rev("main"), 10)
974 .await
975 .is_err()
976 );
977 }
978
979 // --- default_branch and resolve --------------------------------------------
980
981 #[tokio::test]
982 async fn a_repository_with_commits_reports_its_default_branch() {
983 let (_dir, query) = populated();
984
985 assert_eq!(
986 query
987 .default_branch(&handle(), &repo_name())
988 .await
989 .expect("should read"),
990 Some(RefName::from_trusted("main"))
991 );
992 }
993
994 #[tokio::test]
995 async fn a_branch_and_head_resolve_to_the_same_commit() {
996 let (_dir, query) = populated();
997
998 let main = query
999 .resolve(&handle(), &repo_name(), &rev("main"))
1000 .await
1001 .expect("should read")
1002 .expect("main should resolve");
1003 let head = query
1004 .resolve(&handle(), &repo_name(), &rev("HEAD"))
1005 .await
1006 .expect("should read");
1007
1008 assert_eq!(head, Some(main));
1009 }
1010
1011 #[tokio::test]
1012 async fn a_commit_id_resolves_to_itself() {
1013 let (_dir, query) = populated();
1014
1015 let main = query
1016 .resolve(&handle(), &repo_name(), &rev("main"))
1017 .await
1018 .expect("should read")
1019 .expect("main should resolve");
1020
1021 assert_eq!(
1022 query
1023 .resolve(&handle(), &repo_name(), &rev(main.as_str()))
1024 .await
1025 .expect("should read"),
1026 Some(main)
1027 );
1028 }
1029
1030 #[tokio::test]
1031 async fn an_unknown_revision_resolves_to_nothing() {
1032 let (_dir, query) = populated();
1033
1034 assert_eq!(
1035 query
1036 .resolve(&handle(), &repo_name(), &rev("no-such-branch"))
1037 .await
1038 .expect("looking up a missing branch is not a failure"),
1039 None
1040 );
1041 }
1042
1043 // --- list_tree --------------------------------------------------------------
1044
1045 #[tokio::test]
1046 async fn the_root_lists_every_top_level_entry() {
1047 let (_dir, query) = populated();
1048
1049 let entries = by_name(
1050 query
1051 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1052 .await
1053 .expect("should read")
1054 .expect("the root is a directory"),
1055 );
1056
1057 let mut names: Vec<&str> = entries.keys().map(String::as_str).collect();
1058 names.sort_unstable();
1059 assert_eq!(
1060 names,
1061 vec![
1062 "README.md",
1063 "big.txt",
1064 "bin.dat",
1065 "link",
1066 "src",
1067 "with space.txt"
1068 ]
1069 );
1070 assert_eq!(entries["src"].kind, EntryKind::Tree);
1071 assert_eq!(entries["README.md"].kind, EntryKind::Blob);
1072 assert_eq!(
1073 entries["link"].kind,
1074 EntryKind::Symlink,
1075 "a symlink is its own kind, not a file"
1076 );
1077 }
1078
1079 #[tokio::test]
1080 async fn a_listing_carries_blob_sizes_but_not_tree_sizes() {
1081 let (_dir, query) = populated();
1082
1083 let entries = by_name(
1084 query
1085 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1086 .await
1087 .expect("should read")
1088 .expect("the root is a directory"),
1089 );
1090
1091 assert_eq!(entries["big.txt"].size, Some(100));
1092 assert_eq!(
1093 entries["src"].size, None,
1094 "a directory has no size a listing can show"
1095 );
1096 }
1097
1098 #[tokio::test]
1099 async fn a_filename_containing_a_space_survives_the_listing() {
1100 // The reason `-z` is not optional: split on whitespace and this name becomes two.
1101 let (_dir, query) = populated();
1102
1103 let entries = by_name(
1104 query
1105 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1106 .await
1107 .expect("should read")
1108 .expect("the root is a directory"),
1109 );
1110
1111 assert_eq!(entries["with space.txt"].kind, EntryKind::Blob);
1112 assert_eq!(entries["with space.txt"].size, Some(7));
1113 }
1114
1115 #[tokio::test]
1116 async fn a_nested_directory_lists_only_its_own_entries() {
1117 let (_dir, query) = populated();
1118
1119 let entries = query
1120 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src"))
1121 .await
1122 .expect("should read")
1123 .expect("src is a directory");
1124
1125 assert_eq!(entries.len(), 1);
1126 assert_eq!(entries[0].name, "deep", "names are entry names, not paths");
1127 assert_eq!(entries[0].kind, EntryKind::Tree);
1128
1129 let deeper = query
1130 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src/deep"))
1131 .await
1132 .expect("should read")
1133 .expect("src/deep is a directory");
1134
1135 assert_eq!(deeper.len(), 1);
1136 assert_eq!(deeper[0].name, "file.rs");
1137 }
1138
1139 #[tokio::test]
1140 async fn listing_a_file_as_a_directory_finds_nothing() {
1141 // git calls this a fatal error; to a visitor it is a wrong URL.
1142 let (_dir, query) = populated();
1143
1144 assert_eq!(
1145 query
1146 .list_tree(&handle(), &repo_name(), &rev("main"), &path("README.md"))
1147 .await
1148 .expect("a file is not a failure"),
1149 None
1150 );
1151 }
1152
1153 #[tokio::test]
1154 async fn listing_a_path_that_is_not_there_finds_nothing() {
1155 let (_dir, query) = populated();
1156
1157 for missing in ["nope", "src/nope", "README.md/nope"] {
1158 assert_eq!(
1159 query
1160 .list_tree(&handle(), &repo_name(), &rev("main"), &path(missing))
1161 .await
1162 .expect("should read"),
1163 None,
1164 "{missing} should not be found"
1165 );
1166 }
1167 }
1168
1169 #[tokio::test]
1170 async fn listing_at_an_unknown_revision_finds_nothing() {
1171 let (_dir, query) = populated();
1172
1173 assert_eq!(
1174 query
1175 .list_tree(
1176 &handle(),
1177 &repo_name(),
1178 &rev("no-such-branch"),
1179 &RepoPath::root()
1180 )
1181 .await
1182 .expect("should read"),
1183 None
1184 );
1185 }
1186
1187 #[tokio::test]
1188 async fn a_listing_reflects_the_revision_it_was_asked_for() {
1189 // Proves the revision is actually used rather than HEAD being read every time.
1190 let (_dir, query) = populated();
1191
1192 let first = query
1193 .log(&handle(), &repo_name(), &rev("main"), 10)
1194 .await
1195 .expect("should read")
1196 .last()
1197 .expect("three commits")
1198 .id
1199 .clone();
1200
1201 let old = query
1202 .read_blob(
1203 &handle(),
1204 &repo_name(),
1205 &rev(first.as_str()),
1206 &path("README.md"),
1207 1024,
1208 )
1209 .await
1210 .expect("should read")
1211 .expect("README existed in the first commit");
1212
1213 assert_eq!(old.content.as_deref(), Some(b"hello\n".as_slice()));
1214 }
1215
1216 // --- read_blob --------------------------------------------------------------
1217
1218 #[tokio::test]
1219 async fn a_file_is_read_with_its_size_and_content() {
1220 let (_dir, query) = populated();
1221
1222 let blob = query
1223 .read_blob(
1224 &handle(),
1225 &repo_name(),
1226 &rev("main"),
1227 &path("README.md"),
1228 1024,
1229 )
1230 .await
1231 .expect("should read")
1232 .expect("README.md is a file");
1233
1234 assert_eq!(blob.size, 12);
1235 assert_eq!(blob.content.as_deref(), Some(b"hello again\n".as_slice()));
1236 }
1237
1238 #[tokio::test]
1239 async fn a_binary_file_survives_intact() {
1240 // Nothing in this adapter may assume UTF-8: a lossy conversion here would swap
1241 // bytes for replacement characters and quietly corrupt every download.
1242 let (_dir, query) = populated();
1243
1244 let blob = query
1245 .read_blob(
1246 &handle(),
1247 &repo_name(),
1248 &rev("main"),
1249 &path("bin.dat"),
1250 1024,
1251 )
1252 .await
1253 .expect("should read")
1254 .expect("bin.dat is a file");
1255
1256 assert_eq!(blob.size, BINARY.len() as u64);
1257 assert_eq!(blob.content.as_deref(), Some(BINARY));
1258 }
1259
1260 #[tokio::test]
1261 async fn a_file_over_the_cap_reports_its_size_without_its_content() {
1262 let (_dir, query) = populated();
1263
1264 let blob = query
1265 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 10)
1266 .await
1267 .expect("should read")
1268 .expect("big.txt is a file");
1269
1270 assert_eq!(blob.size, 100, "the page still says how big it is");
1271 assert_eq!(blob.content, None);
1272 }
1273
1274 #[tokio::test]
1275 async fn a_file_exactly_at_the_cap_is_still_read() {
1276 let (_dir, query) = populated();
1277
1278 let blob = query
1279 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 100)
1280 .await
1281 .expect("should read")
1282 .expect("big.txt is a file");
1283
1284 assert_eq!(blob.content.map(|content| content.len()), Some(100));
1285 }
1286
1287 #[tokio::test]
1288 async fn a_file_with_a_space_in_its_name_can_be_read() {
1289 let (_dir, query) = populated();
1290
1291 let blob = query
1292 .read_blob(
1293 &handle(),
1294 &repo_name(),
1295 &rev("main"),
1296 &path("with space.txt"),
1297 1024,
1298 )
1299 .await
1300 .expect("should read")
1301 .expect("the file is there");
1302
1303 assert_eq!(blob.content.as_deref(), Some(b"spaced\n".as_slice()));
1304 }
1305
1306 #[tokio::test]
1307 async fn reading_a_directory_as_a_file_finds_nothing() {
1308 let (_dir, query) = populated();
1309
1310 for directory in ["src", "src/deep", ""] {
1311 assert_eq!(
1312 query
1313 .read_blob(
1314 &handle(),
1315 &repo_name(),
1316 &rev("main"),
1317 &path(directory),
1318 1024
1319 )
1320 .await
1321 .expect("a directory is not a failure"),
1322 None,
1323 "{directory:?} is a directory"
1324 );
1325 }
1326 }
1327
1328 #[tokio::test]
1329 async fn reading_a_path_that_is_not_there_finds_nothing() {
1330 let (_dir, query) = populated();
1331
1332 for missing in ["nope.txt", "src/nope.txt", "README.md/nope"] {
1333 assert_eq!(
1334 query
1335 .read_blob(&handle(), &repo_name(), &rev("main"), &path(missing), 1024)
1336 .await
1337 .expect("should read"),
1338 None,
1339 "{missing} should not be found"
1340 );
1341 }
1342 }
1343
1344 #[tokio::test]
1345 async fn a_blobs_id_matches_the_listing() {
1346 // Two commands, one object: if they disagree, one of the two parsers is wrong.
1347 let (_dir, query) = populated();
1348
1349 let entries = by_name(
1350 query
1351 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1352 .await
1353 .expect("should read")
1354 .expect("the root is a directory"),
1355 );
1356 let blob = query
1357 .read_blob(
1358 &handle(),
1359 &repo_name(),
1360 &rev("main"),
1361 &path("README.md"),
1362 1024,
1363 )
1364 .await
1365 .expect("should read")
1366 .expect("README.md is a file");
1367
1368 assert_eq!(blob.id, entries["README.md"].id);
1369 assert_eq!(Some(blob.size), entries["README.md"].size);
1370 }
1371
1372 #[tokio::test]
1373 async fn a_symlink_reads_as_its_target_path() {
1374 // The object store cannot tell a symlink from a file — both are blobs — and its
1375 // content is the path it points at. Showing that is more use than a blank page,
1376 // so this is a deliberate choice rather than an oversight.
1377 let (_dir, query) = populated();
1378
1379 let blob = query
1380 .read_blob(&handle(), &repo_name(), &rev("main"), &path("link"), 1024)
1381 .await
1382 .expect("should read")
1383 .expect("a symlink is readable");
1384
1385 assert_eq!(blob.content.as_deref(), Some(b"README.md".as_slice()));
1386 }
1387
1388 // --- log ---------------------------------------------------------------------
1389
1390 #[tokio::test]
1391 async fn the_log_is_newest_first() {
1392 let (_dir, query) = populated();
1393
1394 let commits = query
1395 .log(&handle(), &repo_name(), &rev("main"), 10)
1396 .await
1397 .expect("should read");
1398
1399 assert_eq!(commits.len(), 3);
1400 assert_eq!(
1401 commits
1402 .iter()
1403 .map(|commit| commit.summary.as_str())
1404 .collect::<Vec<_>>(),
1405 vec!["third: 'quotes', \"doubles\" | pipes", "second", "first"]
1406 );
1407 }
1408
1409 #[tokio::test]
1410 async fn the_log_stops_at_the_limit() {
1411 let (_dir, query) = populated();
1412
1413 let commits = query
1414 .log(&handle(), &repo_name(), &rev("main"), 2)
1415 .await
1416 .expect("should read");
1417
1418 assert_eq!(commits.len(), 2);
1419 assert_eq!(commits[0].summary, "third: 'quotes', \"doubles\" | pipes");
1420
1421 assert!(
1422 query
1423 .log(&handle(), &repo_name(), &rev("main"), 0)
1424 .await
1425 .expect("should read")
1426 .is_empty()
1427 );
1428 }
1429
1430 #[tokio::test]
1431 async fn a_commit_message_body_does_not_leak_into_the_summary() {
1432 // The message has a blank line and two body lines. A parser that split the
1433 // stream on newlines would report "body line one" as a separate commit.
1434 let (_dir, query) = populated();
1435
1436 let commits = query
1437 .log(&handle(), &repo_name(), &rev("main"), 10)
1438 .await
1439 .expect("should read");
1440
1441 assert_eq!(commits.len(), 3, "three commits, not five");
1442 assert!(
1443 !commits[0].summary.contains("body line"),
1444 "got: {:?}",
1445 commits[0].summary
1446 );
1447 }
1448
1449 #[tokio::test]
1450 async fn a_log_entry_carries_its_author_and_time() {
1451 let (_dir, query) = populated();
1452
1453 let commits = query
1454 .log(&handle(), &repo_name(), &rev("main"), 10)
1455 .await
1456 .expect("should read");
1457
1458 assert_eq!(commits[0].author_name, "Ada Lovelace");
1459 assert_eq!(
1460 commits[0].committed_at,
1461 UNIX_EPOCH + Duration::from_secs(THIRD_COMMIT as u64)
1462 );
1463 assert_eq!(
1464 commits[2].committed_at,
1465 UNIX_EPOCH + Duration::from_secs(FIRST_COMMIT as u64)
1466 );
1467 }
1468
1469 #[tokio::test]
1470 async fn a_log_entry_id_is_the_commit_the_revision_resolves_to() {
1471 let (_dir, query) = populated();
1472
1473 let head = query
1474 .resolve(&handle(), &repo_name(), &rev("main"))
1475 .await
1476 .expect("should read")
1477 .expect("main resolves");
1478 let commits = query
1479 .log(&handle(), &repo_name(), &rev("main"), 1)
1480 .await
1481 .expect("should read");
1482
1483 assert_eq!(commits[0].id, head);
1484 }
1485
1486 #[tokio::test]
1487 async fn the_log_of_an_unknown_revision_is_empty_rather_than_a_failure() {
1488 let (_dir, query) = populated();
1489
1490 assert_eq!(
1491 query
1492 .log(&handle(), &repo_name(), &rev("no-such-branch"), 10)
1493 .await
1494 .expect("an unknown branch is not a failure"),
1495 Vec::new()
1496 );
1497 }
1498
1499 #[tokio::test]
1500 async fn a_log_can_start_from_an_older_commit() {
1501 let (_dir, query) = populated();
1502
1503 let all = query
1504 .log(&handle(), &repo_name(), &rev("main"), 10)
1505 .await
1506 .expect("should read");
1507 let from_second = query
1508 .log(&handle(), &repo_name(), &rev(all[1].id.as_str()), 10)
1509 .await
1510 .expect("should read");
1511
1512 assert_eq!(from_second.len(), 2, "history behind the second commit");
1513 assert_eq!(from_second[0].id, all[1].id);
1514 }
1515
1516 // --- list_refs ---------------------------------------------------------------
1517
1518 /// The populated repository with a second branch and two tags pushed into it — one
1519 /// lightweight, one annotated, because they are different objects and the switcher
1520 /// must not care.
1521 fn with_refs() -> (TempDir, DiskGitQuery) {
1522 let (dir, query) = populated();
1523 let repo = query.repo_path(&handle(), &repo_name());
1524 let work = dir.path().join("work");
1525 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1526
1527 // A slash in the name, because that is what makes a ref name interesting: it is
1528 // the case the `/-/` separator in the URL exists for.
1529 git(&work, THIRD_COMMIT, &["branch", "feature/login"]);
1530 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1531 git(
1532 &work,
1533 THIRD_COMMIT,
1534 &["tag", "-a", "v2.0", "-m", "second release"],
1535 );
1536 git(
1537 &work,
1538 THIRD_COMMIT,
1539 &["push", "--quiet", &target, "feature/login"],
1540 );
1541 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1542
1543 (dir, query)
1544 }
1545
1546 fn named(refs: &[GitRef], kind: RefKind) -> Vec<String> {
1547 let mut names: Vec<String> = refs
1548 .iter()
1549 .filter(|git_ref| git_ref.kind == kind)
1550 .map(|git_ref| git_ref.name.to_string())
1551 .collect();
1552
1553 // The port promises no order, so a test that asserted one would be asserting
1554 // something the adapter is free to change.
1555 names.sort();
1556 names
1557 }
1558
1559 #[tokio::test]
1560 async fn branches_and_tags_are_listed_and_told_apart() {
1561 let (_dir, query) = with_refs();
1562
1563 let refs = query
1564 .list_refs(&handle(), &repo_name())
1565 .await
1566 .expect("should read");
1567
1568 assert_eq!(
1569 named(&refs, RefKind::Branch),
1570 vec!["feature/login".to_owned(), "main".to_owned()]
1571 );
1572 // An annotated tag points at a tag object rather than a commit, and a
1573 // lightweight one points straight at the commit. Both are tags.
1574 assert_eq!(
1575 named(&refs, RefKind::Tag),
1576 vec!["v1.0".to_owned(), "v2.0".to_owned()]
1577 );
1578 }
1579
1580 #[tokio::test]
1581 async fn a_repository_with_one_branch_lists_just_it() {
1582 let (_dir, query) = populated();
1583
1584 let refs = query
1585 .list_refs(&handle(), &repo_name())
1586 .await
1587 .expect("should read");
1588
1589 assert_eq!(refs.len(), 1);
1590 assert_eq!(refs[0].name.as_str(), "main");
1591 assert_eq!(refs[0].kind, RefKind::Branch);
1592 }
1593
1594 #[tokio::test]
1595 async fn an_empty_repository_lists_no_refs() {
1596 // HEAD names `main`, but no ref exists, so there is nothing to switch to. An
1597 // empty list rather than an error: nothing pushed yet is not a failure.
1598 let (_dir, query) = empty();
1599
1600 assert_eq!(
1601 query
1602 .list_refs(&handle(), &repo_name())
1603 .await
1604 .expect("should read"),
1605 Vec::new()
1606 );
1607 }
1608
1609 #[tokio::test]
1610 async fn listing_refs_of_a_repository_that_is_not_on_disk_is_an_error() {
1611 let (_dir, query) = empty();
1612 let missing = RepoName::new("never-created").expect("valid repository name");
1613
1614 assert!(query.list_refs(&handle(), &missing).await.is_err());
1615 }
1616
1617 #[test]
1618 fn refs_are_parsed_from_nul_terminated_records() {
1619 // git ends each record with a newline the format cannot suppress, so it arrives
1620 // in front of the next record's name. Anything outside the two namespaces is
1621 // dropped rather than guessed at.
1622 let stdout = b"refs/heads/main\0\nrefs/tags/v1.0\0\nrefs/pull/7/head\0\n";
1623 let refs = parse_refs(stdout);
1624
1625 assert_eq!(refs.len(), 2);
1626 assert_eq!(refs[0].name.as_str(), "main");
1627 assert_eq!(refs[0].kind, RefKind::Branch);
1628 assert_eq!(refs[1].name.as_str(), "v1.0");
1629 assert_eq!(refs[1].kind, RefKind::Tag);
1630 }
1631
1632 #[test]
1633 fn nothing_is_parsed_from_an_empty_listing() {
1634 assert!(parse_refs(b"").is_empty());
1635 }
1636
1637 // --- count_commits ------------------------------------------------------------
1638
1639 #[tokio::test]
1640 async fn commits_are_counted_from_the_revision_asked_about() {
1641 let (_dir, query) = populated();
1642
1643 assert_eq!(
1644 query
1645 .count_commits(&handle(), &repo_name(), &rev("main"))
1646 .await
1647 .expect("should count"),
1648 3
1649 );
1650 }
1651
1652 #[tokio::test]
1653 async fn a_revision_with_no_commits_counts_zero_rather_than_failing() {
1654 // Both spellings of "nothing here": an empty repository, and a branch that is
1655 // not there. `rev-list` is fatal for each, and a page asking how big a
1656 // repository is wants a number.
1657 let (_dir, empty_query) = empty();
1658 assert_eq!(
1659 empty_query
1660 .count_commits(&handle(), &repo_name(), &rev("main"))
1661 .await
1662 .expect("should count"),
1663 0
1664 );
1665
1666 let (_dir, query) = populated();
1667 assert_eq!(
1668 query
1669 .count_commits(&handle(), &repo_name(), &rev("no-such-branch"))
1670 .await
1671 .expect("should count"),
1672 0
1673 );
1674 }
1675
1676 #[tokio::test]
1677 async fn counting_a_repository_that_is_not_on_disk_is_an_error() {
1678 // Same rule as everywhere else here: absent from disk is a fault, not a zero.
1679 let (_dir, query) = empty();
1680 let missing = RepoName::new("gone").expect("valid repository name");
1681
1682 assert!(
1683 query
1684 .count_commits(&handle(), &missing, &rev("main"))
1685 .await
1686 .is_err()
1687 );
1688 }
1689
1690 // --- latest_tag ---------------------------------------------------------------
1691
1692 /// The populated repository with two annotated tags whose dates disagree with their
1693 /// names, so a test can tell "newest" from "last alphabetically".
1694 fn with_dated_tags() -> (TempDir, DiskGitQuery) {
1695 let (dir, query) = populated();
1696 let repo = query.repo_path(&handle(), &repo_name());
1697 let work = dir.path().join("work");
1698 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1699
1700 git(&work, FIRST_COMMIT, &["tag", "-a", "v1.0", "-m", "first"]);
1701 // Made later but named lower: sorting by name would pick `v1.0`.
1702 git(
1703 &work,
1704 THIRD_COMMIT,
1705 &["tag", "-a", "v0.9", "-m", "backport"],
1706 );
1707 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1708
1709 (dir, query)
1710 }
1711
1712 #[tokio::test]
1713 async fn the_latest_tag_is_the_newest_one_not_the_last_one_alphabetically() {
1714 let (_dir, query) = with_dated_tags();
1715
1716 let tag = query
1717 .latest_tag(&handle(), &repo_name())
1718 .await
1719 .expect("should read")
1720 .expect("a tag");
1721
1722 assert_eq!(tag.name.as_str(), "v0.9");
1723 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
1724 }
1725
1726 #[tokio::test]
1727 async fn a_lightweight_tag_is_dated_by_the_commit_it_points_at() {
1728 // It has no date of its own, and `creatordate` is what fills that in.
1729 let (dir, query) = populated();
1730 let repo = query.repo_path(&handle(), &repo_name());
1731 let work = dir.path().join("work");
1732 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1733
1734 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1735 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1736
1737 let tag = query
1738 .latest_tag(&handle(), &repo_name())
1739 .await
1740 .expect("should read")
1741 .expect("a tag");
1742
1743 assert_eq!(tag.name.as_str(), "v1.0");
1744 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
1745 }
1746
1747 #[tokio::test]
1748 async fn a_repository_with_no_tags_has_no_latest_tag() {
1749 let (_dir, query) = populated();
1750 assert_eq!(
1751 query
1752 .latest_tag(&handle(), &repo_name())
1753 .await
1754 .expect("should read"),
1755 None
1756 );
1757
1758 let (_dir, empty_query) = empty();
1759 assert_eq!(
1760 empty_query
1761 .latest_tag(&handle(), &repo_name())
1762 .await
1763 .expect("should read"),
1764 None
1765 );
1766 }
1767
1768 #[test]
1769 fn a_tag_record_is_parsed_past_the_trailing_newline() {
1770 // `for-each-ref` ends every record with a newline the format cannot suppress,
1771 // exactly as it does for `parse_refs`.
1772 let tag = parse_latest_tag(b"refs/tags/v1.0\x001700000000\x00\n").expect("a tag");
1773
1774 assert_eq!(tag.name.as_str(), "v1.0");
1775 assert_eq!(tag.created_at, unix_time(1_700_000_000));
1776 }
1777
1778 #[test]
1779 fn nothing_is_parsed_from_an_empty_tag_listing() {
1780 assert_eq!(parse_latest_tag(b""), None);
1781 // A ref outside the tags namespace is not a tag, whatever asked for it.
1782 assert_eq!(
1783 parse_latest_tag(b"refs/heads/main\x001700000000\x00\n"),
1784 None
1785 );
1786 }
1787
1788 // --- helpers ------------------------------------------------------------------
1789
1790 #[tokio::test]
1791 async fn repo_path_lands_under_the_data_directory() {
1792 let query = DiskGitQuery::new("/data");
1793
1794 assert_eq!(
1795 query.repo_path(&handle(), &repo_name()),
1796 PathBuf::from("/data/jamesgill/steid.git")
1797 );
1798 }
1799
1800 #[test]
1801 fn a_pre_epoch_commit_time_does_not_panic() {
1802 // git will hand back a negative `%ct` for an imported history, and
1803 // `UNIX_EPOCH + Duration` cannot represent it.
1804 assert!(unix_time(-1) < UNIX_EPOCH);
1805 assert_eq!(unix_time(0), UNIX_EPOCH);
1806 }
1807
1808 #[tokio::test]
1809 async fn a_read_that_exceeds_its_limit_is_a_timeout_not_a_fault() {
1810 let (_dir, repo) = fixture_repo_for_timeout().await;
1811 let error = run_within(&repo, ["rev-parse", "HEAD"], Duration::ZERO)
1812 .await
1813 .expect_err("a zero limit cannot be met");
1814 assert!(error.is_timeout(), "{error}");
1815 }
1816
1817 /// A bare repository with nothing in it: `rev-parse` failing is not the point, the
1818 /// process being cut off before it can answer is.
1819 async fn fixture_repo_for_timeout() -> (TempDir, std::path::PathBuf) {
1820 let dir = TempDir::new().unwrap();
1821 let repo = dir.path().join("t.git");
1822 let status = git_command()
1823 .args(["init", "--bare", "-q"])
1824 .arg(&repo)
1825 .status()
1826 .await
1827 .unwrap();
1828 assert!(status.success());
1829 (dir, repo)
1830 }
1831}