steid

@jamesgill /

steid/src/infrastructure/git_query.rs
113.8 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//!
31//! **Two commands are exempt**, and only those two: `git grep` and `git merge-base`,
32//! whose exit 1 git's own manual promises as "matched nothing" and "no common ancestor".
33//! Both go through [`run_allowing`], which is the only place a non-zero exit is ever
34//! accepted, and each names the status it accepts at the call site.
35
36use std::{
37 ffi::OsStr,
38 path::{Path, PathBuf},
39 process::{Output, Stdio},
40 time::{Duration, SystemTime, UNIX_EPOCH},
41};
42
43use tokio::io::{AsyncReadExt, AsyncWriteExt};
44
45use crate::{
46 application::{
47 blame::{Blame, parse_blame},
48 port::{Blob, GitQuery, GitQueryError, RawDiff},
49 search::parse_grep_output,
50 },
51 domain::{
52 BranchRow, CommitDetail, CommitSummary, EntryKind, GitRef, GrepHit, ObjectId, OrgName,
53 RefKind, RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
54 },
55 infrastructure::git::git_command,
56};
57
58/// The words `cat-file --batch-check` ends a line with when it did not resolve a spec.
59///
60/// `missing` covers the common cases; `ambiguous` is an abbreviated id matching more
61/// than one object, and `dangling` and `notdir` appear when following a `^{}` or a path
62/// through something that cannot hold one. None of them is a failure — they are the
63/// answer "no such thing here".
64const NOT_FOUND_MARKERS: [&str; 4] = ["missing", "ambiguous", "dangling", "notdir"];
65
66/// Repository contents, read from bare repositories under a data directory.
67#[derive(Debug, Clone)]
68pub struct DiskGitQuery {
69 data_dir: PathBuf,
70}
71
72impl DiskGitQuery {
73 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
74 Self {
75 data_dir: data_dir.into(),
76 }
77 }
78
79 /// Where a repository lives, matching `DiskGitStorage`'s layout.
80 pub(crate) fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
81 self.data_dir
82 .join(handle.as_str())
83 .join(format!("{name}.git"))
84 }
85}
86
87impl GitQuery for DiskGitQuery {
88 async fn default_branch(
89 &self,
90 handle: &OrgName,
91 name: &RepoName,
92 ) -> Result<Option<RefName>, GitQueryError> {
93 let repo = self.repo_path(handle, name);
94
95 // An empty repository's HEAD names a branch that does not exist yet, so
96 // `symbolic-ref` happily answers `main` for a repository with nothing in it.
97 // Whether HEAD *resolves* is the actual question, and it is asked first.
98 let Some(head) = object_info(&repo, "HEAD").await? else {
99 return Ok(None);
100 };
101
102 let branch = run(&repo, [OsStr::new("symbolic-ref"), OsStr::new("HEAD")]).await;
103
104 match branch {
105 Ok(output) => {
106 let full = String::from_utf8_lossy(&output.stdout).trim().to_owned();
107 // `refs/heads/main` rather than `--short`, because `--short` shortens
108 // only as far as is unambiguous and would hand back `heads/main` for a
109 // repository that also has a tag called `main`.
110 let short = full.strip_prefix("refs/heads/").unwrap_or(&full);
111
112 Ok(Some(RefName::from_trusted(short)))
113 }
114 // A detached HEAD is not a state Steid creates, but a repository pushed into
115 // from elsewhere can be in it. The commit is still browsable, so name it
116 // rather than claiming the repository is empty — which is what `Ok(None)`
117 // would mean to a page.
118 Err(_) => Ok(Some(RefName::from_trusted(head.id.as_str()))),
119 }
120 }
121
122 async fn resolve(
123 &self,
124 handle: &OrgName,
125 name: &RepoName,
126 rev: &RefName,
127 ) -> Result<Option<ObjectId>, GitQueryError> {
128 let repo = self.repo_path(handle, name);
129
130 // `^{commit}` peels an annotated tag to what it points at, and refuses a
131 // revision that names a tree or a blob — a browse page wants a commit, and
132 // returning a tree id here would fail confusingly two calls later.
133 let spec = format!("{}^{{commit}}", rev.as_str());
134
135 Ok(object_info(&repo, &spec).await?.map(|info| info.id))
136 }
137
138 async fn list_tree(
139 &self,
140 handle: &OrgName,
141 name: &RepoName,
142 rev: &RefName,
143 path: &RepoPath,
144 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
145 let repo = self.repo_path(handle, name);
146
147 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
148 return Ok(None);
149 };
150
151 // A file is not a directory. Asking `ls-tree` anyway is a fatal error, which is
152 // exactly the confusion this check exists to avoid.
153 if info.kind != ObjectKind::Tree {
154 return Ok(None);
155 }
156
157 // `-z` because a filename may contain a newline, and `--long` for blob sizes.
158 // The already-resolved tree id is passed rather than the user's revision, so
159 // nothing here has to think about what git's revision parser might make of it.
160 let output = run(
161 &repo,
162 [
163 OsStr::new("ls-tree"),
164 OsStr::new("-z"),
165 OsStr::new("--long"),
166 OsStr::new(info.id.as_str()),
167 ],
168 )
169 .await?;
170
171 parse_tree(&output.stdout).map(Some)
172 }
173
174 async fn read_blob(
175 &self,
176 handle: &OrgName,
177 name: &RepoName,
178 rev: &RefName,
179 path: &RepoPath,
180 max_bytes: u64,
181 ) -> Result<Option<Blob>, GitQueryError> {
182 let repo = self.repo_path(handle, name);
183
184 // The root is a tree, and `{rev}:` is how git spells it — but a caller asking to
185 // read the root is asking for a file that is not there.
186 if path.is_root() {
187 return Ok(None);
188 }
189
190 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
191 return Ok(None);
192 };
193
194 // Symlinks are blobs whose content is the target path, and are read as such:
195 // showing where a link points is more use than a blank page. Trees and
196 // submodules are not files.
197 if info.kind != ObjectKind::Blob {
198 return Ok(None);
199 }
200
201 // The size comes from the object header, so an oversized file is never read.
202 // Doing this the other way round — read, then measure — is how one URL becomes
203 // an out-of-memory kill.
204 let content = if info.size > max_bytes {
205 None
206 } else {
207 let output = run(
208 &repo,
209 [
210 OsStr::new("cat-file"),
211 OsStr::new("blob"),
212 OsStr::new(info.id.as_str()),
213 ],
214 )
215 .await?;
216
217 Some(output.stdout)
218 };
219
220 Ok(Some(Blob {
221 id: info.id,
222 size: info.size,
223 content,
224 }))
225 }
226
227 async fn log(
228 &self,
229 handle: &OrgName,
230 name: &RepoName,
231 rev: &RefName,
232 limit: usize,
233 ) -> Result<Vec<CommitSummary>, GitQueryError> {
234 let repo = self.repo_path(handle, name);
235
236 // `git log` on a repository with no commits is a fatal error, and so is a log of
237 // a branch that does not exist. Resolving first turns both into the empty list
238 // the port's signature promises, without having to read meaning into a stderr
239 // string that is localised and free to change between git versions.
240 let Some(commit) = self.resolve(handle, name, rev).await? else {
241 return Ok(Vec::new());
242 };
243
244 if limit == 0 {
245 return Ok(Vec::new());
246 }
247
248 // Every separator is a NUL: `-z` between commits, `%x00` between fields. A
249 // commit message contains newlines as a matter of course, and a name can contain
250 // almost anything, so splitting on lines or whitespace would misread real
251 // history rather than exotic history.
252 let count = format!("--max-count={limit}");
253
254 let output = run(
255 &repo,
256 [
257 OsStr::new("log"),
258 OsStr::new("-z"),
259 OsStr::new(&count),
260 OsStr::new(LOG_FORMAT),
261 OsStr::new(commit.as_str()),
262 ],
263 )
264 .await?;
265
266 parse_log(&output.stdout)
267 }
268
269 async fn list_refs(
270 &self,
271 handle: &OrgName,
272 name: &RepoName,
273 ) -> Result<Vec<GitRef>, GitQueryError> {
274 let repo = self.repo_path(handle, name);
275
276 // One fork, ~14ms — see the port's note. `for-each-ref` is asked for both
277 // namespaces at once rather than once each, because the cost here is the
278 // process, not the question.
279 //
280 // Every field separator is a NUL, for the same reason `log` uses one: a tag name
281 // is close to arbitrary text once git's own restrictions are met, and splitting
282 // on whitespace would misread a real name. The patterns are literals, so unlike
283 // a revision from a URL there is nothing here that could be read as a flag.
284 let output = run(
285 &repo,
286 [
287 OsStr::new("for-each-ref"),
288 OsStr::new(REF_FORMAT),
289 OsStr::new("refs/heads/"),
290 OsStr::new("refs/tags/"),
291 ],
292 )
293 .await?;
294
295 Ok(parse_refs(&output.stdout))
296 }
297
298 async fn count_commits(
299 &self,
300 handle: &OrgName,
301 name: &RepoName,
302 rev: &RefName,
303 ) -> Result<u64, GitQueryError> {
304 let repo = self.repo_path(handle, name);
305
306 // Resolved first, for the same reason `log` resolves first: `rev-list` on an
307 // empty repository or an unknown branch is a fatal error, and the port promises
308 // a count rather than a failure. **That makes this two processes, not one** —
309 // the price of keeping the module's rule that a non-zero exit is always a real
310 // fault. Handing the resolved id to `rev-list` also means nothing from a URL
311 // reaches git's revision parser here.
312 let Some(commit) = self.resolve(handle, name, rev).await? else {
313 return Ok(0);
314 };
315
316 let output = run(
317 &repo,
318 [
319 OsStr::new("rev-list"),
320 OsStr::new("--count"),
321 OsStr::new(commit.as_str()),
322 ],
323 )
324 .await?;
325
326 let count = String::from_utf8_lossy(&output.stdout);
327 let count = count.trim();
328
329 count.parse().map_err(|_| {
330 GitQueryError::new(format!(
331 "git counted commits as {count:?}, which is not a number"
332 ))
333 })
334 }
335
336 async fn latest_tag(
337 &self,
338 handle: &OrgName,
339 name: &RepoName,
340 ) -> Result<Option<TagSummary>, GitQueryError> {
341 let repo = self.repo_path(handle, name);
342
343 // `--count=1` after `--sort` is the whole of the work: git does the ordering,
344 // so this is one process regardless of how many tags a repository carries.
345 // Every argument is a literal — nothing from a URL reaches this call.
346 let output = run(
347 &repo,
348 [
349 OsStr::new("for-each-ref"),
350 OsStr::new("--sort=-creatordate"),
351 OsStr::new("--count=1"),
352 OsStr::new(TAG_FORMAT),
353 OsStr::new("refs/tags/"),
354 ],
355 )
356 .await?;
357
358 Ok(parse_latest_tag(&output.stdout))
359 }
360
361 async fn branches(
362 &self,
363 handle: &OrgName,
364 name: &RepoName,
365 ) -> Result<Vec<BranchRow>, GitQueryError> {
366 let repo = self.repo_path(handle, name);
367
368 // One process for the whole branches page. The sort is git's because it is
369 // free there and would otherwise be a second pass in Rust over the same rows,
370 // and `%(HEAD)` is what saves the page a `symbolic-ref` for the default branch.
371 // Every argument is a literal — nothing from a URL reaches this call.
372 let output = run(
373 &repo,
374 [
375 OsStr::new("for-each-ref"),
376 OsStr::new("--sort=-committerdate"),
377 OsStr::new(BRANCH_FORMAT),
378 OsStr::new("refs/heads/"),
379 ],
380 )
381 .await?;
382
383 parse_branches(&output.stdout)
384 }
385
386 async fn tags(&self, handle: &OrgName, name: &RepoName) -> Result<Vec<TagRow>, GitQueryError> {
387 let repo = self.repo_path(handle, name);
388
389 let output = run(
390 &repo,
391 [
392 OsStr::new("for-each-ref"),
393 OsStr::new("--sort=-creatordate"),
394 OsStr::new(TAG_ROW_FORMAT),
395 OsStr::new("refs/tags/"),
396 ],
397 )
398 .await?;
399
400 parse_tags(&output.stdout)
401 }
402
403 async fn grep(
404 &self,
405 handle: &OrgName,
406 name: &RepoName,
407 commit: &ObjectId,
408 query: &str,
409 limit: usize,
410 ) -> Result<Vec<GrepHit>, GitQueryError> {
411 let repo = self.repo_path(handle, name);
412
413 // **One of the two commands here whose exit status is an answer** — the other
414 // is `merge_base`. `git grep` exits 1 when it found nothing, which is not a
415 // failure. Everything else in this module keeps the rule that a non-zero exit
416 // is a fault; the exception is spelled out in the call rather than hidden in
417 // the helper.
418 //
419 // `-F` fixed strings, `-I` skips binary files, `-n --column` locate the match,
420 // and `-z` makes the output parseable — see `parse_grep_output`. The query is
421 // passed after `-e`, so a query starting with `-` is a search rather than a
422 // flag, and the commit is one git resolved rather than anything from a URL.
423 let output = run_allowing(
424 &repo,
425 [
426 OsStr::new("grep"),
427 OsStr::new("-I"),
428 OsStr::new("-n"),
429 OsStr::new("-z"),
430 OsStr::new("-F"),
431 OsStr::new("--column"),
432 OsStr::new("--no-color"),
433 OsStr::new("-e"),
434 OsStr::new(query),
435 OsStr::new(commit.as_str()),
436 OsStr::new("--"),
437 ],
438 &[NO_MATCHES],
439 )
440 .await?;
441
442 Ok(parse_grep_output(&output.stdout, commit, limit))
443 }
444
445 async fn commit(
446 &self,
447 handle: &OrgName,
448 name: &RepoName,
449 rev: &RefName,
450 ) -> Result<Option<CommitDetail>, GitQueryError> {
451 let repo = self.repo_path(handle, name);
452
453 // Resolved first, as everywhere else in this module: `git log` is fatal on a
454 // revision that names nothing, and `resolve` already peels an annotated tag and
455 // refuses a tree or a blob — so what reaches `log` is an object id known to be
456 // a commit, and a non-zero exit below really is a fault.
457 let Some(id) = self.resolve(handle, name, rev).await? else {
458 return Ok(None);
459 };
460
461 let output = run(
462 &repo,
463 [
464 OsStr::new("log"),
465 OsStr::new("--max-count=1"),
466 OsStr::new(COMMIT_FORMAT),
467 OsStr::new(id.as_str()),
468 ],
469 )
470 .await?;
471
472 parse_commit(&output.stdout).map(Some)
473 }
474
475 async fn diff(
476 &self,
477 handle: &OrgName,
478 name: &RepoName,
479 base: Option<&ObjectId>,
480 head: &ObjectId,
481 max_bytes: u64,
482 ) -> Result<RawDiff, GitQueryError> {
483 let repo = self.repo_path(handle, name);
484
485 // `diff-tree` rather than `diff`, for both shapes: it is the plumbing command,
486 // it takes tree-ish arguments rather than revision expressions, and it does not
487 // consult a working tree that a bare repository does not have.
488 //
489 // The flags, each load-bearing:
490 // --no-commit-id `-p` would otherwise print the commit's id as a first line
491 // -p the patch itself
492 // -M rename detection, so a moved file is one entry, not two
493 // --numstat the per-file counts, written *before* the patch — which is
494 // what makes them survive the byte cap below
495 // --no-color the caller renders; git must not send escape sequences
496 // --root a first commit is diffed against nothing rather than
497 // skipped, which is the only way to see what it introduced
498 let mut args: Vec<&OsStr> = vec![
499 OsStr::new("diff-tree"),
500 OsStr::new("--no-commit-id"),
501 OsStr::new("-p"),
502 OsStr::new("-M"),
503 OsStr::new("--numstat"),
504 OsStr::new("--no-color"),
505 ];
506
507 match base {
508 Some(base) => {
509 args.push(OsStr::new(base.as_str()));
510 }
511 None => args.push(OsStr::new("--root")),
512 }
513
514 args.push(OsStr::new(head.as_str()));
515
516 let (stdout, truncated) = run_capped(&repo, args, max_bytes).await?;
517 let (numstat, patch) = split_numstat(&stdout);
518
519 Ok(RawDiff {
520 numstat: numstat.to_vec(),
521 patch: patch.to_vec(),
522 truncated,
523 })
524 }
525
526 async fn merge_base(
527 &self,
528 handle: &OrgName,
529 name: &RepoName,
530 base: &ObjectId,
531 head: &ObjectId,
532 ) -> Result<Option<ObjectId>, GitQueryError> {
533 let repo = self.repo_path(handle, name);
534
535 // **The other exception to this module's rule that a non-zero exit is a
536 // fault**, `grep` being the first. `git merge-base` documents exit 1 as "no
537 // merge base found" and reserves 128 for real errors, so the two are
538 // distinguishable here in a way they are not for `rev-parse` — which is why the
539 // rule exists at all. Two histories with no common ancestor is a thing a compare
540 // page must be able to say, and the arguments are resolved object ids, so there
541 // is nothing else exit 1 can mean.
542 let output = run_allowing(
543 &repo,
544 [
545 OsStr::new("merge-base"),
546 OsStr::new(base.as_str()),
547 OsStr::new(head.as_str()),
548 ],
549 &[NO_COMMON_ANCESTOR],
550 )
551 .await?;
552
553 let id = String::from_utf8_lossy(&output.stdout);
554 let id = id.trim();
555
556 if id.is_empty() {
557 return Ok(None);
558 }
559
560 Ok(Some(ObjectId::new(id).map_err(|error| {
561 GitQueryError::new(format!("git named a bad merge base: {error}"))
562 })?))
563 }
564
565 async fn log_between(
566 &self,
567 handle: &OrgName,
568 name: &RepoName,
569 base: Option<&ObjectId>,
570 head: &ObjectId,
571 limit: usize,
572 ) -> Result<Vec<CommitSummary>, GitQueryError> {
573 let repo = self.repo_path(handle, name);
574
575 if limit == 0 {
576 return Ok(Vec::new());
577 }
578
579 // Both ends are already object ids, so the range is built here rather than
580 // taken from a URL — `..` is the one piece of revision syntax this module
581 // writes itself, and `RefName` refuses it precisely so that nothing else can.
582 let range = match base {
583 Some(base) => format!("{}..{}", base.as_str(), head.as_str()),
584 None => head.as_str().to_owned(),
585 };
586 let count = format!("--max-count={limit}");
587
588 let output = run(
589 &repo,
590 [
591 OsStr::new("log"),
592 OsStr::new("-z"),
593 OsStr::new(&count),
594 OsStr::new(LOG_FORMAT),
595 OsStr::new(&range),
596 ],
597 )
598 .await?;
599
600 parse_log(&output.stdout)
601 }
602
603 async fn blame(
604 &self,
605 handle: &OrgName,
606 name: &RepoName,
607 rev: &RefName,
608 path: &RepoPath,
609 ) -> Result<Option<Blame>, GitQueryError> {
610 let repo = self.repo_path(handle, name);
611
612 // `git blame` is fatal on an unknown revision *and* on a path that is not in it,
613 // so one `--batch-check` on `{rev}:{path}` settles both before the expensive
614 // command runs — the module's rule that a non-zero exit is a real fault only
615 // holds because of checks like this one.
616 let Some(info) = object_info(&repo, &tree_spec(rev, path)).await? else {
617 return Ok(None);
618 };
619
620 // A directory has no lines. Blaming one is another fatal error.
621 if info.kind != ObjectKind::Blob {
622 return Ok(None);
623 }
624
625 // The revision is named rather than the blob id: blame walks *history*, and a
626 // blob knows nothing about which commits reached it. `--` separates the revision
627 // from the path so neither can be read as the other, and `RefName` and
628 // `RepoPath` have already refused a leading hyphen.
629 let output = run(
630 &repo,
631 [
632 OsStr::new("blame"),
633 OsStr::new("--porcelain"),
634 OsStr::new(rev.as_str()),
635 OsStr::new("--"),
636 OsStr::new(path.as_str()),
637 ],
638 )
639 .await?;
640
641 Ok(Some(parse_blame(&output.stdout)))
642 }
643}
644
645/// What `cat-file --batch-check` said about one object.
646#[derive(Debug, Clone, PartialEq, Eq)]
647struct ObjectInfo {
648 id: ObjectId,
649 kind: ObjectKind,
650 size: u64,
651}
652
653/// A git object's type, as its header spells it.
654///
655/// Distinct from [`EntryKind`], which is about what a tree entry *means* — the object
656/// store cannot tell a symlink from a file, because both are blobs.
657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658enum ObjectKind {
659 Blob,
660 Tree,
661 Commit,
662 Tag,
663}
664
665impl ObjectKind {
666 fn from_str(value: &str) -> Option<Self> {
667 match value {
668 "blob" => Some(Self::Blob),
669 "tree" => Some(Self::Tree),
670 "commit" => Some(Self::Commit),
671 "tag" => Some(Self::Tag),
672 _ => None,
673 }
674 }
675}
676
677/// How git addresses a path inside a revision: `{rev}:{path}`, and `{rev}:` for the root.
678fn tree_spec(rev: &RefName, path: &RepoPath) -> String {
679 format!("{}:{}", rev.as_str(), path.as_str())
680}
681
682/// Asks git what one revision-and-path resolves to, or `None` if it resolves to nothing.
683///
684/// The spec goes over stdin rather than in an argument, so no revision or path can ever
685/// be read as a flag regardless of what validation upstream does or stops doing.
686async fn object_info(repo: &Path, spec: &str) -> Result<Option<ObjectInfo>, GitQueryError> {
687 let mut command = git_command();
688 command
689 .arg("-C")
690 .arg(repo)
691 .arg("cat-file")
692 .arg("--batch-check")
693 .stdin(Stdio::piped())
694 .stdout(Stdio::piped())
695 .stderr(Stdio::piped());
696
697 let mut child = command
698 .spawn()
699 .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
700
701 let mut stdin = child.stdin.take().expect("stdin was piped");
702
703 // One short line, far below a pipe's buffer, so writing before waiting cannot
704 // deadlock. Dropping stdin is what ends the batch — git would otherwise wait for
705 // another spec forever.
706 stdin
707 .write_all(format!("{spec}\n").as_bytes())
708 .await
709 .map_err(|error| {
710 GitQueryError::new(format!("could not ask git about {spec:?}: {error}"))
711 })?;
712 drop(stdin);
713
714 let output = child
715 .wait_with_output()
716 .await
717 .map_err(|error| GitQueryError::new(format!("could not wait for git: {error}")))?;
718
719 // Per the module note: a non-zero exit here is never "not found".
720 if !output.status.success() {
721 return Err(GitQueryError::new(format!(
722 "git exited with {} looking up {spec:?}: {}",
723 output.status,
724 String::from_utf8_lossy(&output.stderr).trim()
725 )));
726 }
727
728 let line = String::from_utf8_lossy(&output.stdout);
729 let line = line.trim_end_matches('\n');
730
731 // The marker is checked before the field count, because a not-found line echoes the
732 // spec back — and a spec naming a file with spaces in it has no fixed field count.
733 if line
734 .rsplit(' ')
735 .next()
736 .is_some_and(|last| NOT_FOUND_MARKERS.contains(&last))
737 {
738 return Ok(None);
739 }
740
741 let fields: Vec<&str> = line.split_whitespace().collect();
742 let [id, kind, size] = fields[..] else {
743 return Err(GitQueryError::new(format!(
744 "git described {spec:?} in a shape we do not understand: {line:?}"
745 )));
746 };
747
748 Ok(Some(ObjectInfo {
749 // Validated rather than trusted. git's ids are trustworthy, but this is a parse
750 // of text whose layout we have assumed, and an id is about to appear in a URL —
751 // a misread field should stop here rather than surface as a broken link. The
752 // cost is a length and hex check next to a process spawn.
753 id: ObjectId::new(id)
754 .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))?,
755 kind: ObjectKind::from_str(kind).ok_or_else(|| {
756 GitQueryError::new(format!("git reported an unknown object type {kind:?}"))
757 })?,
758 size: size.parse().map_err(|_| {
759 GitQueryError::new(format!("git reported an unreadable object size {size:?}"))
760 })?,
761 }))
762}
763
764/// Parses `ls-tree -z --long` output.
765///
766/// Each record is `<mode> SP <type> SP <id> SP <size> TAB <name>`, NUL-terminated, where
767/// the size is space-padded and `-` for anything that is not a blob. The name is
768/// everything after the first tab and is *raw bytes* — which is why the split happens
769/// before any attempt to read it as text.
770fn parse_tree(stdout: &[u8]) -> Result<Vec<TreeEntry>, GitQueryError> {
771 let mut entries = Vec::new();
772
773 for record in stdout.split(|byte| *byte == 0) {
774 if record.is_empty() {
775 continue;
776 }
777
778 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
779 return Err(GitQueryError::new(
780 "git listed a tree entry with no name separator",
781 ));
782 };
783
784 let (meta, name) = record.split_at(tab);
785 let name = &name[1..];
786
787 let meta = std::str::from_utf8(meta).map_err(|_| {
788 GitQueryError::new("git listed a tree entry whose metadata is not text")
789 })?;
790
791 let fields: Vec<&str> = meta.split_whitespace().collect();
792 let [mode, _type, id, size] = fields[..] else {
793 return Err(GitQueryError::new(format!(
794 "git listed a tree entry in a shape we do not understand: {meta:?}"
795 )));
796 };
797
798 entries.push(TreeEntry {
799 // Lossy, because `TreeEntry::name` is a `String` and a filename is not
800 // required to be UTF-8. A replacement character renders; refusing to list
801 // the whole directory because one file has an odd name does not.
802 name: String::from_utf8_lossy(name).into_owned(),
803 kind: EntryKind::from_mode(mode)
804 .map_err(|error| GitQueryError::new(format!("git listed {name:?}: {error}")))?,
805 id: ObjectId::new(id).map_err(|error| {
806 GitQueryError::new(format!("git named a bad object id: {error}"))
807 })?,
808 // `-` for a tree or a submodule, which have no size a listing can show.
809 size: size.parse().ok(),
810 });
811 }
812
813 // Unsorted on purpose: ordering is `TreeEntry::ordering_key`'s decision, made once
814 // in the application rather than differently in each adapter.
815 Ok(entries)
816}
817
818/// Parses the NUL-separated `log` stream into four-field records.
819fn parse_log(stdout: &[u8]) -> Result<Vec<CommitSummary>, GitQueryError> {
820 // `-z` terminates the last record too, so the split leaves a trailing empty field
821 // that is not a commit.
822 let fields: Vec<&[u8]> = stdout
823 .split(|byte| *byte == 0)
824 .filter(|field| !field.is_empty())
825 .collect();
826
827 let mut commits = Vec::with_capacity(fields.len() / 4);
828
829 for record in fields.chunks(4) {
830 let [id, committed_at, author_name, summary] = record[..] else {
831 return Err(GitQueryError::new(
832 "git logged a commit with missing fields",
833 ));
834 };
835
836 let id = String::from_utf8_lossy(id);
837 let committed_at = String::from_utf8_lossy(committed_at);
838 let committed_at: i64 = committed_at.trim().parse().map_err(|_| {
839 GitQueryError::new(format!(
840 "git logged an unreadable commit time {committed_at:?}"
841 ))
842 })?;
843
844 commits.push(CommitSummary {
845 id: ObjectId::new(id.trim()).map_err(|error| {
846 GitQueryError::new(format!("git named a bad object id: {error}"))
847 })?,
848 // `%s` is git's subject: the first paragraph, joined into one line. Trimmed
849 // to the first line anyway, because that invariant is git's rather than
850 // something this parser should assume.
851 summary: String::from_utf8_lossy(summary)
852 .lines()
853 .next()
854 .unwrap_or_default()
855 .to_owned(),
856 author_name: String::from_utf8_lossy(author_name).into_owned(),
857 committed_at: unix_time(committed_at),
858 });
859 }
860
861 Ok(commits)
862}
863
864/// A unix timestamp as a `SystemTime`, including the negative ones.
865///
866/// A commit dated before 1970 is either a lie or an import from something older than
867/// git, and both exist in real repositories. `UNIX_EPOCH + Duration` would panic on the
868/// subtraction it cannot do.
869fn unix_time(seconds: i64) -> SystemTime {
870 match u64::try_from(seconds) {
871 Ok(seconds) => UNIX_EPOCH + Duration::from_secs(seconds),
872 Err(_) => UNIX_EPOCH - Duration::from_secs(seconds.unsigned_abs()),
873 }
874}
875
876/// What `for-each-ref` prints per ref: the full name and the object it names,
877/// NUL-separated and NUL-terminated.
878///
879/// The kind is *not* asked for. `%(objecttype)` says `commit` for both a branch and a
880/// lightweight tag, so the namespace in the name is the only thing that answers which
881/// one a visitor asked for.
882const REF_FORMAT: &str = "--format=%(refname)%00";
883
884/// Parses `for-each-ref`'s NUL-separated output into branches and tags.
885///
886/// Each record is `<full refname> NUL`, and git ends every record with a newline of its
887/// own that the format cannot suppress — so the newline arrives at the *front* of the
888/// next record's first field and is trimmed off. A ref name can contain neither a
889/// newline nor a space, so trimming cannot eat part of a name.
890fn parse_refs(stdout: &[u8]) -> Vec<GitRef> {
891 let mut refs = Vec::new();
892
893 for record in stdout.split(|byte| *byte == 0) {
894 let record = record.trim_ascii();
895
896 if record.is_empty() {
897 continue;
898 }
899
900 // Lossy would be wrong here: a name that is not UTF-8 cannot be put in a URL,
901 // and offering a link that cannot work is worse than leaving the ref out of the
902 // switcher. It is still browsable by object id.
903 let Ok(full) = std::str::from_utf8(record) else {
904 continue;
905 };
906
907 let (kind, short) = if let Some(short) = full.strip_prefix("refs/heads/") {
908 (RefKind::Branch, short)
909 } else if let Some(short) = full.strip_prefix("refs/tags/") {
910 (RefKind::Tag, short)
911 } else {
912 // Only the two namespaces were asked for, so this cannot happen — and if a
913 // future pattern is added and this is forgotten, skipping is the safe half
914 // of the mistake.
915 continue;
916 };
917
918 // Validated rather than trusted: this name is about to become a URL, and
919 // `RefName` is what decides a name is safe to hand back to git. A ref git
920 // accepts but Steid's rules do not is left out rather than linked to.
921 let Ok(name) = RefName::new(short) else {
922 continue;
923 };
924
925 refs.push(GitRef { name, kind });
926 }
927
928 refs
929}
930
931/// What the latest-tag query asks for: the full ref name and its creation time.
932///
933/// `creatordate` rather than `taggerdate`, which is empty for a lightweight tag, or
934/// `committerdate`, which is empty for an annotated one. `creatordate` is git's own
935/// "whichever of those this ref has".
936const TAG_FORMAT: &str = "--format=%(refname)%00%(creatordate:unix)%00";
937
938/// Parses the one record [`TAG_FORMAT`] produces, or `None` for a repository with no
939/// tags.
940///
941/// Anything unreadable is `None` rather than an error: this decorates a page with a
942/// fact, and a tag whose name is not UTF-8 or whose date git spelled unexpectedly is a
943/// reason to say nothing, not to fail the repository's landing page.
944fn parse_latest_tag(stdout: &[u8]) -> Option<TagSummary> {
945 let fields: Vec<&[u8]> = stdout
946 .split(|byte| *byte == 0)
947 .map(<[u8]>::trim_ascii)
948 .filter(|field| !field.is_empty())
949 .collect();
950
951 let [name, created_at] = fields[..] else {
952 return None;
953 };
954
955 let short = std::str::from_utf8(name).ok()?.strip_prefix("refs/tags/")?;
956 let created_at: i64 = std::str::from_utf8(created_at).ok()?.parse().ok()?;
957
958 Some(TagSummary {
959 // Validated rather than trusted, exactly as `parse_refs` does: the name is
960 // about to become a link.
961 name: RefName::new(short).ok()?,
962 created_at: unix_time(created_at),
963 })
964}
965
966/// What the branches page asks for, per branch.
967///
968/// `%(HEAD)` is git's own marker for the branch `HEAD` points at — `*` for it and a
969/// space for everything else. It is asked for here rather than resolved separately
970/// because a second process to learn one bit is the cost 0006 is about.
971///
972/// `%(objectname)` is the tip commit itself: a branch, unlike a tag, never points at
973/// anything else.
974const BRANCH_FORMAT: &str = "--format=%(refname)%00%(HEAD)%00%(objectname)%00%(committerdate:unix)%00%(contents:subject)%00";
975
976/// What the tags page asks for, per tag.
977///
978/// `%(objecttype)` is `tag` for an annotated tag and `commit` for a lightweight one,
979/// which is the only reliable way to tell them apart. `%(*objectname)` is the peeled
980/// object and is empty for a lightweight tag, so the commit is "the peeled one if there
981/// is one". `%(contents:subject)` is the *tag's* message for an annotated tag and the
982/// *commit's* for a lightweight one — so it is read only when the type says `tag`,
983/// otherwise a lightweight tag would appear to carry a message it does not have.
984const TAG_ROW_FORMAT: &str = "--format=%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%00";
985
986/// Splits `for-each-ref` output into its NUL-terminated fields.
987///
988/// Every field ends with a NUL and git adds a newline after each record that the format
989/// cannot suppress, so the split yields exactly one field per `%00` plus a trailing
990/// remainder holding that last newline — dropped here.
991///
992/// **Empty fields are kept.** A lightweight tag has no peeled object, and filtering
993/// empties the way [`parse_latest_tag`] can afford to would shift every later field of
994/// that record onto the wrong name.
995fn ref_fields(stdout: &[u8]) -> Vec<&[u8]> {
996 let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect();
997 fields.pop();
998 fields
999}
1000
1001/// The first line of git's subject, or `None` when there is nothing to show.
1002///
1003/// `%(contents:subject)` is already one line, but that is git's invariant rather than
1004/// something this parser should assume — the same reason [`parse_log`] trims `%s`.
1005fn subject(field: &[u8]) -> Option<String> {
1006 let line = String::from_utf8_lossy(field)
1007 .lines()
1008 .next()
1009 .unwrap_or_default()
1010 .trim()
1011 .to_owned();
1012
1013 (!line.is_empty()).then_some(line)
1014}
1015
1016/// Parses [`BRANCH_FORMAT`] into rows, in the order git sorted them.
1017///
1018/// A record whose name or commit id Steid cannot use is skipped rather than failing the
1019/// page, exactly as [`parse_refs`] skips one: a branch that cannot be linked to is a
1020/// reason to leave a row out, not to refuse the whole list. A record with the wrong
1021/// number of fields is different — that is git saying something this code does not
1022/// understand, and it is an error.
1023fn parse_branches(stdout: &[u8]) -> Result<Vec<BranchRow>, GitQueryError> {
1024 let fields = ref_fields(stdout);
1025 let mut rows = Vec::with_capacity(fields.len() / 5);
1026
1027 for record in fields.chunks(5) {
1028 let [name, head, commit, committed_at, summary] = record[..] else {
1029 return Err(GitQueryError::new(
1030 "git listed a branch with missing fields",
1031 ));
1032 };
1033
1034 // git's trailing newline arrives in front of the next record's first field.
1035 // A ref name can contain neither a newline nor a space, so trimming cannot eat
1036 // part of one.
1037 let Some(name) = short_ref(name.trim_ascii(), "refs/heads/") else {
1038 continue;
1039 };
1040
1041 let Ok(commit) = ObjectId::new(String::from_utf8_lossy(commit).trim()) else {
1042 continue;
1043 };
1044
1045 let committed_at = String::from_utf8_lossy(committed_at);
1046 let Ok(committed_at) = committed_at.trim().parse::<i64>() else {
1047 continue;
1048 };
1049
1050 rows.push(BranchRow {
1051 name,
1052 // `*` for the branch HEAD names, a space for the rest.
1053 is_default: head.trim_ascii() == b"*",
1054 commit,
1055 summary: subject(summary).unwrap_or_default(),
1056 committed_at: unix_time(committed_at),
1057 });
1058 }
1059
1060 Ok(rows)
1061}
1062
1063/// Parses [`TAG_ROW_FORMAT`] into rows, in the order git sorted them.
1064///
1065/// Skips and errors on the same terms as [`parse_branches`].
1066fn parse_tags(stdout: &[u8]) -> Result<Vec<TagRow>, GitQueryError> {
1067 let fields = ref_fields(stdout);
1068 let mut rows = Vec::with_capacity(fields.len() / 6);
1069
1070 for record in fields.chunks(6) {
1071 let [name, kind, object, peeled, created_at, message] = record[..] else {
1072 return Err(GitQueryError::new("git listed a tag with missing fields"));
1073 };
1074
1075 let Some(name) = short_ref(name.trim_ascii(), "refs/tags/") else {
1076 continue;
1077 };
1078
1079 // An annotated tag's `objectname` is the tag object, so the thing worth linking
1080 // to is the peeled one. A lightweight tag has no peel and already names its
1081 // commit.
1082 let annotated = kind.trim_ascii() == b"tag";
1083 let id = if peeled.trim_ascii().is_empty() {
1084 object
1085 } else {
1086 peeled
1087 };
1088
1089 let Ok(commit) = ObjectId::new(String::from_utf8_lossy(id).trim()) else {
1090 continue;
1091 };
1092
1093 let created_at = String::from_utf8_lossy(created_at);
1094 let Ok(created_at) = created_at.trim().parse::<i64>() else {
1095 continue;
1096 };
1097
1098 rows.push(TagRow {
1099 name,
1100 commit,
1101 // Only an annotated tag has a message of its own; for a lightweight one
1102 // this field is the commit's subject, which belongs to the commit.
1103 message: annotated.then(|| subject(message)).flatten(),
1104 annotated,
1105 created_at: unix_time(created_at),
1106 });
1107 }
1108
1109 Ok(rows)
1110}
1111
1112/// A full ref name reduced to the short form Steid puts in a URL, or `None` when it is
1113/// outside the namespace asked for or is not a name Steid will hand back to git.
1114///
1115/// Validated rather than trusted for the reason [`parse_refs`] gives: this name is
1116/// about to become a link.
1117fn short_ref(full: &[u8], namespace: &str) -> Option<RefName> {
1118 let full = std::str::from_utf8(full).ok()?;
1119 RefName::new(full.strip_prefix(namespace)?).ok()
1120}
1121
1122/// Runs a git command inside a repository and fails on a non-zero exit.
1123///
1124/// Only ever used for commands whose subject has already been confirmed to exist, so a
1125/// failure really is a failure. Built from [`git_command`] so the host isolation 0006
1126/// insists on cannot drift out of this module.
1127/// How long one read-side `git` process may run before it is killed.
1128///
1129/// Every read here is a subprocess on a request path, and nothing bounded it before
1130/// blame and grep arrived — either can run for a long time on a large repository, and
1131/// a request that never finishes holds a worker for as long as the client waits.
1132/// Twenty seconds is far above any read a page should make and far below "hung".
1133const GIT_TIMEOUT: Duration = Duration::from_secs(20);
1134
1135async fn run<I, S>(repo: &Path, args: I) -> Result<Output, GitQueryError>
1136where
1137 I: IntoIterator<Item = S>,
1138 S: AsRef<OsStr>,
1139{
1140 run_within(repo, args, GIT_TIMEOUT).await
1141}
1142
1143// The two exit statuses this module reads as answers rather than as faults. Both are 1
1144// and they are separate constants on purpose: what makes each of them legible is the
1145// question it answers, not the number. See [`run_allowing`], which is the only way
1146// either reaches a command.
1147
1148/// What `git grep` exits with when it matched nothing.
1149const NO_MATCHES: i32 = 1;
1150
1151/// What `git merge-base` exits with for two commits that share no ancestor — documented
1152/// as an answer, with 128 reserved for real errors.
1153const NO_COMMON_ANCESTOR: i32 = 1;
1154
1155/// [`run`] for a command whose exit status is partly an answer.
1156///
1157/// Exists for `git grep` and `git merge-base` alone — grep's 1 is "matched nothing" and
1158/// merge-base's 1 is "no common ancestor", both promised by git's manual. Every other
1159/// command here is asked about something `cat-file --batch-check` has already confirmed
1160/// exists, which is what makes the module's "a non-zero exit is always a fault" rule
1161/// hold. These two are asked questions whose answer can legitimately be "there is
1162/// none".
1163async fn run_allowing<I, S>(repo: &Path, args: I, allowed: &[i32]) -> Result<Output, GitQueryError>
1164where
1165 I: IntoIterator<Item = S>,
1166 S: AsRef<OsStr>,
1167{
1168 run_within_allowing(repo, args, GIT_TIMEOUT, allowed).await
1169}
1170
1171/// [`run`] with an explicit limit, so the timeout path can be tested without waiting
1172/// twenty seconds for it.
1173async fn run_within<I, S>(repo: &Path, args: I, limit: Duration) -> Result<Output, GitQueryError>
1174where
1175 I: IntoIterator<Item = S>,
1176 S: AsRef<OsStr>,
1177{
1178 run_within_allowing(repo, args, limit, &[]).await
1179}
1180
1181/// The whole of running git: isolation, the timeout, and the status rule.
1182async fn run_within_allowing<I, S>(
1183 repo: &Path,
1184 args: I,
1185 limit: Duration,
1186 allowed: &[i32],
1187) -> Result<Output, GitQueryError>
1188where
1189 I: IntoIterator<Item = S>,
1190 S: AsRef<OsStr>,
1191{
1192 let mut command = git_command();
1193 command
1194 .arg("-C")
1195 .arg(repo)
1196 .args(args)
1197 .stdin(Stdio::null())
1198 // Dropping the future on timeout must take the process with it, or a killed
1199 // request leaves git running to completion for nobody.
1200 .kill_on_drop(true);
1201
1202 let output = match tokio::time::timeout(limit, command.output()).await {
1203 Ok(result) => {
1204 result.map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?
1205 }
1206 Err(_elapsed) => return Err(GitQueryError::timed_out(limit)),
1207 };
1208
1209 let expected = output
1210 .status
1211 .code()
1212 .is_some_and(|code| allowed.contains(&code));
1213
1214 if !output.status.success() && !expected {
1215 return Err(GitQueryError::new(format!(
1216 "git exited with {}: {}",
1217 output.status,
1218 String::from_utf8_lossy(&output.stderr).trim()
1219 )));
1220 }
1221
1222 Ok(output)
1223}
1224
1225/// Runs a git command and reads at most `max_bytes` of its output.
1226///
1227/// [`run`] collects everything git writes, which is right for a tree listing and wrong
1228/// for a patch: a single commit can legitimately carry hundreds of megabytes of diff,
1229/// and a page must not be able to pull that into memory. So this one reads through a
1230/// pipe and stops, killing the process rather than draining it — an abandoned `git
1231/// diff-tree` writing into a closed pipe is exactly what SIGPIPE is for.
1232///
1233/// Returns the bytes and whether the cap was hit. **The exit status is only checked
1234/// when it was not**: a process killed part-way through has a status that says so, and
1235/// treating that as a failure would turn every oversized diff into a 500.
1236async fn run_capped<I, S>(
1237 repo: &Path,
1238 args: I,
1239 max_bytes: u64,
1240) -> Result<(Vec<u8>, bool), GitQueryError>
1241where
1242 I: IntoIterator<Item = S>,
1243 S: AsRef<OsStr>,
1244{
1245 let mut command = git_command();
1246 command
1247 .arg("-C")
1248 .arg(repo)
1249 .args(args)
1250 .stdin(Stdio::null())
1251 .stdout(Stdio::piped())
1252 .stderr(Stdio::piped())
1253 .kill_on_drop(true);
1254
1255 let read = async {
1256 let mut child = command
1257 .spawn()
1258 .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?;
1259
1260 let mut stdout = child.stdout.take().expect("stdout was piped");
1261 let mut bytes = Vec::new();
1262
1263 // One byte past the cap, so hitting it exactly is not mistaken for exceeding
1264 // it. The surplus byte is dropped below.
1265 let limit = max_bytes.saturating_add(1);
1266
1267 (&mut stdout)
1268 .take(limit)
1269 .read_to_end(&mut bytes)
1270 .await
1271 .map_err(|error| GitQueryError::new(format!("could not read from git: {error}")))?;
1272
1273 if bytes.len() as u64 > max_bytes {
1274 bytes.truncate(max_bytes as usize);
1275 // Nothing waits for the exit status: git is still writing, and the whole
1276 // point is not to read the rest. `kill_on_drop` reaps it.
1277 let _ = child.start_kill();
1278
1279 return Ok((bytes, true));
1280 }
1281
1282 // stdout is already drained, so this collects stderr and the status. stderr is
1283 // git's own diagnostics — small by construction, and only read on failure.
1284 let output = child
1285 .wait_with_output()
1286 .await
1287 .map_err(|error| GitQueryError::new(format!("could not wait for git: {error}")))?;
1288
1289 if !output.status.success() {
1290 return Err(GitQueryError::new(format!(
1291 "git exited with {}: {}",
1292 output.status,
1293 String::from_utf8_lossy(&output.stderr).trim()
1294 )));
1295 }
1296
1297 Ok((bytes, false))
1298 };
1299
1300 match tokio::time::timeout(GIT_TIMEOUT, read).await {
1301 Ok(result) => result,
1302 Err(_elapsed) => Err(GitQueryError::timed_out(GIT_TIMEOUT)),
1303 }
1304}
1305
1306/// What a log record carries: id, commit time, author name, subject.
1307///
1308/// Every separator is a NUL, and `-z` separates the records themselves. A commit
1309/// message contains newlines as a matter of course and a name can contain almost
1310/// anything, so splitting on lines or whitespace would misread real history rather than
1311/// exotic history.
1312const LOG_FORMAT: &str = "--format=%H%x00%ct%x00%an%x00%s";
1313
1314/// What a commit page needs, in one record: id, tree, parents, author, committer,
1315/// subject, body.
1316///
1317/// NUL-separated for [`LOG_FORMAT`]'s reason, and the body is deliberately **last** —
1318/// it is the one field that can contain anything at all, so parsing takes ten
1319/// separators and treats whatever remains as the body rather than counting fields from
1320/// both ends.
1321///
1322/// `%P` is the parents, space-separated: an empty string for a root commit, two ids for
1323/// a merge.
1324const COMMIT_FORMAT: &str =
1325 "--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%s%x00%b";
1326
1327/// Parses the single record [`COMMIT_FORMAT`] produces.
1328fn parse_commit(stdout: &[u8]) -> Result<CommitDetail, GitQueryError> {
1329 // `splitn` rather than `split`: the body is the eleventh field and may contain
1330 // anything, including — in a message written by a tool rather than a person — a NUL
1331 // of its own. Everything after the tenth separator is the body.
1332 let fields: Vec<&[u8]> = stdout.splitn(11, |byte| *byte == 0).collect();
1333
1334 let [
1335 id,
1336 tree,
1337 parents,
1338 author_name,
1339 author_email,
1340 authored_at,
1341 committer_name,
1342 committer_email,
1343 committed_at,
1344 summary,
1345 body,
1346 ] = fields[..]
1347 else {
1348 return Err(GitQueryError::new(
1349 "git described a commit in a shape we do not understand",
1350 ));
1351 };
1352
1353 let text = |bytes: &[u8]| String::from_utf8_lossy(bytes).into_owned();
1354 let object = |bytes: &[u8]| {
1355 ObjectId::new(String::from_utf8_lossy(bytes).trim())
1356 .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))
1357 };
1358 let seconds = |bytes: &[u8]| {
1359 let value = String::from_utf8_lossy(bytes);
1360 value.trim().parse::<i64>().map(unix_time).map_err(|_| {
1361 GitQueryError::new(format!(
1362 "git dated a commit as {:?}",
1363 value.trim().to_owned()
1364 ))
1365 })
1366 };
1367
1368 Ok(CommitDetail {
1369 id: object(id)?,
1370 tree: object(tree)?,
1371 parents: String::from_utf8_lossy(parents)
1372 .split_whitespace()
1373 .map(|parent| {
1374 ObjectId::new(parent).map_err(|error| {
1375 GitQueryError::new(format!("git named a bad parent id: {error}"))
1376 })
1377 })
1378 .collect::<Result<Vec<_>, _>>()?,
1379 // `%s` is git's subject; trimmed to one line anyway, because that is git's
1380 // invariant rather than something this parser should assume.
1381 summary: text(summary).lines().next().unwrap_or_default().to_owned(),
1382 // git ends the formatted record with a newline of its own, which lands on the
1383 // body because the body is last. Trailing whitespace is not part of a message.
1384 body: text(body).trim_end().to_owned(),
1385 author_name: text(author_name),
1386 author_email: text(author_email),
1387 authored_at: seconds(authored_at)?,
1388 committer_name: text(committer_name),
1389 committer_email: text(committer_email),
1390 committed_at: seconds(committed_at)?,
1391 })
1392}
1393
1394/// Splits `--numstat -p` output into its two halves.
1395///
1396/// git writes every `--numstat` line first and then the patch, so the boundary is the
1397/// first `diff --git ` line — a marker no numstat line can produce, since one always
1398/// begins with a count or the `-` that stands for a binary file. The blank line git
1399/// puts between the two is not relied on.
1400///
1401/// A patch that is entirely absent — a commit that changed nothing, or a read cut short
1402/// before the patch began — leaves the second half empty, which is a state the caller
1403/// already has to handle.
1404fn split_numstat(stdout: &[u8]) -> (&[u8], &[u8]) {
1405 const MARKER: &[u8] = b"diff --git ";
1406
1407 if stdout.starts_with(MARKER) {
1408 return (&[], stdout);
1409 }
1410
1411 for (index, byte) in stdout.iter().enumerate() {
1412 if *byte == b'\n' && stdout[index + 1..].starts_with(MARKER) {
1413 return stdout.split_at(index + 1);
1414 }
1415 }
1416
1417 (stdout, &[])
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422 use std::collections::HashMap;
1423
1424 use tempfile::TempDir;
1425
1426 use super::*;
1427 use crate::domain::EntryKind;
1428
1429 /// Fixed so a timestamp assertion is exact rather than approximate.
1430 const FIRST_COMMIT: i64 = 1_700_000_000;
1431 const SECOND_COMMIT: i64 = 1_700_000_100;
1432 const THIRD_COMMIT: i64 = 1_700_000_200;
1433
1434 /// A subject with the punctuation a naive parser splits on, followed by a body — so
1435 /// a test can prove the body does not leak into the summary.
1436 const ODD_MESSAGE: &str =
1437 "third: 'quotes', \"doubles\" | pipes\n\nbody line one\nbody line two";
1438
1439 const BINARY: &[u8] = &[0x00, 0x01, 0xff, 0xfe, 0x80];
1440
1441 fn handle() -> OrgName {
1442 OrgName::new("jamesgill").expect("valid handle")
1443 }
1444
1445 fn repo_name() -> RepoName {
1446 RepoName::new("steid").expect("valid repository name")
1447 }
1448
1449 fn rev(value: &str) -> RefName {
1450 RefName::new(value).expect("valid revision")
1451 }
1452
1453 fn path(value: &str) -> RepoPath {
1454 RepoPath::new(value).expect("valid path")
1455 }
1456
1457 /// Runs git in a fixture, isolated from the host's configuration the same way the
1458 /// adapter is — otherwise a developer's `commit.gpgsign` or `init.defaultBranch`
1459 /// decides whether the suite passes.
1460 fn git(dir: &Path, when: i64, args: &[&str]) {
1461 let date = format!("@{when} +0000");
1462
1463 let output = std::process::Command::new("git")
1464 .arg("-C")
1465 .arg(dir)
1466 .args(args)
1467 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1468 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1469 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
1470 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
1471 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
1472 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
1473 .env("GIT_AUTHOR_DATE", &date)
1474 .env("GIT_COMMITTER_DATE", &date)
1475 .output()
1476 .expect("git should be on PATH");
1477
1478 assert!(
1479 output.status.success(),
1480 "git {args:?} failed: {}",
1481 String::from_utf8_lossy(&output.stderr)
1482 );
1483 }
1484
1485 /// A data directory holding one empty bare repository, exactly as Steid creates it.
1486 ///
1487 /// The `TempDir` is returned because dropping it deletes the fixture.
1488 fn empty() -> (TempDir, DiskGitQuery) {
1489 let dir = TempDir::new().expect("temp dir");
1490 let query = DiskGitQuery::new(dir.path());
1491 let repo = query.repo_path(&handle(), &repo_name());
1492
1493 std::fs::create_dir_all(repo.parent().expect("has a parent")).expect("create handle dir");
1494 git(
1495 dir.path(),
1496 FIRST_COMMIT,
1497 &[
1498 "init",
1499 "--bare",
1500 "--quiet",
1501 "--template=",
1502 "--initial-branch=main",
1503 "--",
1504 repo.to_str().expect("utf-8 fixture path"),
1505 ],
1506 );
1507
1508 (dir, query)
1509 }
1510
1511 /// The empty repository with three commits pushed into it, the way a real one fills
1512 /// up — a working copy and a push, rather than plumbing straight into the object
1513 /// store.
1514 fn populated() -> (TempDir, DiskGitQuery) {
1515 let (dir, query) = empty();
1516 let repo = query.repo_path(&handle(), &repo_name());
1517 let work = dir.path().join("work");
1518
1519 std::fs::create_dir_all(work.join("src/deep")).expect("create work tree");
1520 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
1521
1522 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
1523 std::fs::write(work.join("with space.txt"), b"spaced\n").expect("write");
1524 std::fs::write(work.join("bin.dat"), BINARY).expect("write");
1525 std::fs::write(work.join("big.txt"), vec![b'x'; 100]).expect("write");
1526 std::fs::write(work.join("src/deep/file.rs"), b"fn main() {}\n").expect("write");
1527 std::os::unix::fs::symlink("README.md", work.join("link")).expect("symlink");
1528
1529 git(&work, FIRST_COMMIT, &["add", "-A"]);
1530 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
1531
1532 std::fs::write(work.join("README.md"), b"hello again\n").expect("write");
1533 git(&work, SECOND_COMMIT, &["add", "-A"]);
1534 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
1535
1536 git(
1537 &work,
1538 THIRD_COMMIT,
1539 &["commit", "--quiet", "--allow-empty", "-m", ODD_MESSAGE],
1540 );
1541
1542 git(
1543 &work,
1544 THIRD_COMMIT,
1545 &[
1546 "push",
1547 "--quiet",
1548 repo.to_str().expect("utf-8 fixture path"),
1549 "main",
1550 ],
1551 );
1552
1553 (dir, query)
1554 }
1555
1556 /// A listing keyed by name, so an assertion does not depend on an order the port
1557 /// explicitly does not promise.
1558 fn by_name(entries: Vec<TreeEntry>) -> HashMap<String, TreeEntry> {
1559 entries
1560 .into_iter()
1561 .map(|entry| (entry.name.clone(), entry))
1562 .collect()
1563 }
1564
1565 // --- grep ------------------------------------------------------------------
1566
1567 async fn hits(query: &str, limit: usize) -> Vec<GrepHit> {
1568 let (_dir, query_port) = populated();
1569 let commit = query_port
1570 .resolve(&handle(), &repo_name(), &rev("main"))
1571 .await
1572 .expect("should read")
1573 .expect("main resolves");
1574
1575 query_port
1576 .grep(&handle(), &repo_name(), &commit, query, limit)
1577 .await
1578 .expect("should grep")
1579 }
1580
1581 #[tokio::test]
1582 async fn a_search_finds_the_line_it_matched() {
1583 let found = hits("fn main", 10).await;
1584
1585 assert_eq!(found.len(), 1);
1586 assert_eq!(found[0].path.as_str(), "src/deep/file.rs");
1587 assert_eq!(found[0].line, 1);
1588 assert_eq!(found[0].text, "fn main() {}");
1589 }
1590
1591 #[tokio::test]
1592 async fn a_search_that_matches_nothing_is_not_a_failure() {
1593 // `git grep` exits 1 here, which every other command in this module would treat
1594 // as a fault. This is the one exception, and this test is what pins it.
1595 assert_eq!(hits("nothing matches this", 10).await, Vec::new());
1596 }
1597
1598 #[tokio::test]
1599 async fn a_binary_file_is_never_reported() {
1600 // `bin.dat` contains 0x00 0x01 0xff 0xfe 0x80, so a byte-wise search would hit
1601 // it. `-I` is what keeps unreadable matches off the page.
1602 let found = hits("\u{fffd}", 10).await;
1603
1604 assert!(
1605 found.iter().all(|hit| hit.path.as_str() != "bin.dat"),
1606 "a binary file should never appear in results"
1607 );
1608 }
1609
1610 #[tokio::test]
1611 async fn a_search_is_a_fixed_string_not_a_pattern() {
1612 // `.` would match every line if this were a regular expression.
1613 assert_eq!(hits("hello.again", 10).await, Vec::new());
1614 }
1615
1616 #[tokio::test]
1617 async fn the_limit_bounds_what_comes_back() {
1618 // Every file in the fixture contains an `e` somewhere, so this is more than one
1619 // match without depending on how many.
1620 assert_eq!(hits("e", 2).await.len(), 2);
1621 }
1622
1623 // --- an empty repository ---------------------------------------------------
1624
1625 #[tokio::test]
1626 async fn an_empty_repository_has_no_default_branch() {
1627 // The distinction the port exists for: HEAD names `main`, but `main` has no
1628 // commits, so "nothing pushed yet" rather than a branch a page can browse.
1629 let (_dir, query) = empty();
1630
1631 assert_eq!(
1632 query
1633 .default_branch(&handle(), &repo_name())
1634 .await
1635 .expect("should read"),
1636 None
1637 );
1638 }
1639
1640 #[tokio::test]
1641 async fn nothing_resolves_in_an_empty_repository() {
1642 let (_dir, query) = empty();
1643
1644 for revision in ["main", "HEAD", "v1.0"] {
1645 assert_eq!(
1646 query
1647 .resolve(&handle(), &repo_name(), &rev(revision))
1648 .await
1649 .expect("should read"),
1650 None,
1651 "{revision} should not resolve"
1652 );
1653 }
1654 }
1655
1656 #[tokio::test]
1657 async fn an_empty_repository_lists_nothing_and_reads_nothing() {
1658 let (_dir, query) = empty();
1659
1660 assert_eq!(
1661 query
1662 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1663 .await
1664 .expect("should read"),
1665 None
1666 );
1667 assert_eq!(
1668 query
1669 .read_blob(
1670 &handle(),
1671 &repo_name(),
1672 &rev("main"),
1673 &path("README.md"),
1674 1024
1675 )
1676 .await
1677 .expect("should read"),
1678 None
1679 );
1680 }
1681
1682 #[tokio::test]
1683 async fn an_empty_repository_has_an_empty_log() {
1684 // `git log` is a fatal error here, and an empty list is what the port promises.
1685 let (_dir, query) = empty();
1686
1687 assert_eq!(
1688 query
1689 .log(&handle(), &repo_name(), &rev("main"), 10)
1690 .await
1691 .expect("should read"),
1692 Vec::new()
1693 );
1694 }
1695
1696 // --- a missing repository is a failure, not a 404 ---------------------------
1697
1698 #[tokio::test]
1699 async fn a_repository_that_is_not_on_disk_is_an_error() {
1700 // A record with no directory is a fault to investigate, not a "no such branch".
1701 // Answering `Ok(None)` here would hide it behind a plausible-looking 404.
1702 let (_dir, query) = empty();
1703 let missing = RepoName::new("never-created").expect("valid repository name");
1704
1705 assert!(query.default_branch(&handle(), &missing).await.is_err());
1706 assert!(
1707 query
1708 .resolve(&handle(), &missing, &rev("main"))
1709 .await
1710 .is_err()
1711 );
1712 assert!(
1713 query
1714 .list_tree(&handle(), &missing, &rev("main"), &RepoPath::root())
1715 .await
1716 .is_err()
1717 );
1718 assert!(
1719 query
1720 .log(&handle(), &missing, &rev("main"), 10)
1721 .await
1722 .is_err()
1723 );
1724 }
1725
1726 // --- default_branch and resolve --------------------------------------------
1727
1728 #[tokio::test]
1729 async fn a_repository_with_commits_reports_its_default_branch() {
1730 let (_dir, query) = populated();
1731
1732 assert_eq!(
1733 query
1734 .default_branch(&handle(), &repo_name())
1735 .await
1736 .expect("should read"),
1737 Some(RefName::from_trusted("main"))
1738 );
1739 }
1740
1741 #[tokio::test]
1742 async fn a_branch_and_head_resolve_to_the_same_commit() {
1743 let (_dir, query) = populated();
1744
1745 let main = query
1746 .resolve(&handle(), &repo_name(), &rev("main"))
1747 .await
1748 .expect("should read")
1749 .expect("main should resolve");
1750 let head = query
1751 .resolve(&handle(), &repo_name(), &rev("HEAD"))
1752 .await
1753 .expect("should read");
1754
1755 assert_eq!(head, Some(main));
1756 }
1757
1758 #[tokio::test]
1759 async fn a_commit_id_resolves_to_itself() {
1760 let (_dir, query) = populated();
1761
1762 let main = query
1763 .resolve(&handle(), &repo_name(), &rev("main"))
1764 .await
1765 .expect("should read")
1766 .expect("main should resolve");
1767
1768 assert_eq!(
1769 query
1770 .resolve(&handle(), &repo_name(), &rev(main.as_str()))
1771 .await
1772 .expect("should read"),
1773 Some(main)
1774 );
1775 }
1776
1777 #[tokio::test]
1778 async fn an_unknown_revision_resolves_to_nothing() {
1779 let (_dir, query) = populated();
1780
1781 assert_eq!(
1782 query
1783 .resolve(&handle(), &repo_name(), &rev("no-such-branch"))
1784 .await
1785 .expect("looking up a missing branch is not a failure"),
1786 None
1787 );
1788 }
1789
1790 // --- list_tree --------------------------------------------------------------
1791
1792 #[tokio::test]
1793 async fn the_root_lists_every_top_level_entry() {
1794 let (_dir, query) = populated();
1795
1796 let entries = by_name(
1797 query
1798 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1799 .await
1800 .expect("should read")
1801 .expect("the root is a directory"),
1802 );
1803
1804 let mut names: Vec<&str> = entries.keys().map(String::as_str).collect();
1805 names.sort_unstable();
1806 assert_eq!(
1807 names,
1808 vec![
1809 "README.md",
1810 "big.txt",
1811 "bin.dat",
1812 "link",
1813 "src",
1814 "with space.txt"
1815 ]
1816 );
1817 assert_eq!(entries["src"].kind, EntryKind::Tree);
1818 assert_eq!(entries["README.md"].kind, EntryKind::Blob);
1819 assert_eq!(
1820 entries["link"].kind,
1821 EntryKind::Symlink,
1822 "a symlink is its own kind, not a file"
1823 );
1824 }
1825
1826 #[tokio::test]
1827 async fn a_listing_carries_blob_sizes_but_not_tree_sizes() {
1828 let (_dir, query) = populated();
1829
1830 let entries = by_name(
1831 query
1832 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1833 .await
1834 .expect("should read")
1835 .expect("the root is a directory"),
1836 );
1837
1838 assert_eq!(entries["big.txt"].size, Some(100));
1839 assert_eq!(
1840 entries["src"].size, None,
1841 "a directory has no size a listing can show"
1842 );
1843 }
1844
1845 #[tokio::test]
1846 async fn a_filename_containing_a_space_survives_the_listing() {
1847 // The reason `-z` is not optional: split on whitespace and this name becomes two.
1848 let (_dir, query) = populated();
1849
1850 let entries = by_name(
1851 query
1852 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
1853 .await
1854 .expect("should read")
1855 .expect("the root is a directory"),
1856 );
1857
1858 assert_eq!(entries["with space.txt"].kind, EntryKind::Blob);
1859 assert_eq!(entries["with space.txt"].size, Some(7));
1860 }
1861
1862 #[tokio::test]
1863 async fn a_nested_directory_lists_only_its_own_entries() {
1864 let (_dir, query) = populated();
1865
1866 let entries = query
1867 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src"))
1868 .await
1869 .expect("should read")
1870 .expect("src is a directory");
1871
1872 assert_eq!(entries.len(), 1);
1873 assert_eq!(entries[0].name, "deep", "names are entry names, not paths");
1874 assert_eq!(entries[0].kind, EntryKind::Tree);
1875
1876 let deeper = query
1877 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src/deep"))
1878 .await
1879 .expect("should read")
1880 .expect("src/deep is a directory");
1881
1882 assert_eq!(deeper.len(), 1);
1883 assert_eq!(deeper[0].name, "file.rs");
1884 }
1885
1886 #[tokio::test]
1887 async fn listing_a_file_as_a_directory_finds_nothing() {
1888 // git calls this a fatal error; to a visitor it is a wrong URL.
1889 let (_dir, query) = populated();
1890
1891 assert_eq!(
1892 query
1893 .list_tree(&handle(), &repo_name(), &rev("main"), &path("README.md"))
1894 .await
1895 .expect("a file is not a failure"),
1896 None
1897 );
1898 }
1899
1900 #[tokio::test]
1901 async fn listing_a_path_that_is_not_there_finds_nothing() {
1902 let (_dir, query) = populated();
1903
1904 for missing in ["nope", "src/nope", "README.md/nope"] {
1905 assert_eq!(
1906 query
1907 .list_tree(&handle(), &repo_name(), &rev("main"), &path(missing))
1908 .await
1909 .expect("should read"),
1910 None,
1911 "{missing} should not be found"
1912 );
1913 }
1914 }
1915
1916 #[tokio::test]
1917 async fn listing_at_an_unknown_revision_finds_nothing() {
1918 let (_dir, query) = populated();
1919
1920 assert_eq!(
1921 query
1922 .list_tree(
1923 &handle(),
1924 &repo_name(),
1925 &rev("no-such-branch"),
1926 &RepoPath::root()
1927 )
1928 .await
1929 .expect("should read"),
1930 None
1931 );
1932 }
1933
1934 #[tokio::test]
1935 async fn a_listing_reflects_the_revision_it_was_asked_for() {
1936 // Proves the revision is actually used rather than HEAD being read every time.
1937 let (_dir, query) = populated();
1938
1939 let first = query
1940 .log(&handle(), &repo_name(), &rev("main"), 10)
1941 .await
1942 .expect("should read")
1943 .last()
1944 .expect("three commits")
1945 .id
1946 .clone();
1947
1948 let old = query
1949 .read_blob(
1950 &handle(),
1951 &repo_name(),
1952 &rev(first.as_str()),
1953 &path("README.md"),
1954 1024,
1955 )
1956 .await
1957 .expect("should read")
1958 .expect("README existed in the first commit");
1959
1960 assert_eq!(old.content.as_deref(), Some(b"hello\n".as_slice()));
1961 }
1962
1963 // --- read_blob --------------------------------------------------------------
1964
1965 #[tokio::test]
1966 async fn a_file_is_read_with_its_size_and_content() {
1967 let (_dir, query) = populated();
1968
1969 let blob = query
1970 .read_blob(
1971 &handle(),
1972 &repo_name(),
1973 &rev("main"),
1974 &path("README.md"),
1975 1024,
1976 )
1977 .await
1978 .expect("should read")
1979 .expect("README.md is a file");
1980
1981 assert_eq!(blob.size, 12);
1982 assert_eq!(blob.content.as_deref(), Some(b"hello again\n".as_slice()));
1983 }
1984
1985 #[tokio::test]
1986 async fn a_binary_file_survives_intact() {
1987 // Nothing in this adapter may assume UTF-8: a lossy conversion here would swap
1988 // bytes for replacement characters and quietly corrupt every download.
1989 let (_dir, query) = populated();
1990
1991 let blob = query
1992 .read_blob(
1993 &handle(),
1994 &repo_name(),
1995 &rev("main"),
1996 &path("bin.dat"),
1997 1024,
1998 )
1999 .await
2000 .expect("should read")
2001 .expect("bin.dat is a file");
2002
2003 assert_eq!(blob.size, BINARY.len() as u64);
2004 assert_eq!(blob.content.as_deref(), Some(BINARY));
2005 }
2006
2007 #[tokio::test]
2008 async fn a_file_over_the_cap_reports_its_size_without_its_content() {
2009 let (_dir, query) = populated();
2010
2011 let blob = query
2012 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 10)
2013 .await
2014 .expect("should read")
2015 .expect("big.txt is a file");
2016
2017 assert_eq!(blob.size, 100, "the page still says how big it is");
2018 assert_eq!(blob.content, None);
2019 }
2020
2021 #[tokio::test]
2022 async fn a_file_exactly_at_the_cap_is_still_read() {
2023 let (_dir, query) = populated();
2024
2025 let blob = query
2026 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 100)
2027 .await
2028 .expect("should read")
2029 .expect("big.txt is a file");
2030
2031 assert_eq!(blob.content.map(|content| content.len()), Some(100));
2032 }
2033
2034 #[tokio::test]
2035 async fn a_file_with_a_space_in_its_name_can_be_read() {
2036 let (_dir, query) = populated();
2037
2038 let blob = query
2039 .read_blob(
2040 &handle(),
2041 &repo_name(),
2042 &rev("main"),
2043 &path("with space.txt"),
2044 1024,
2045 )
2046 .await
2047 .expect("should read")
2048 .expect("the file is there");
2049
2050 assert_eq!(blob.content.as_deref(), Some(b"spaced\n".as_slice()));
2051 }
2052
2053 #[tokio::test]
2054 async fn reading_a_directory_as_a_file_finds_nothing() {
2055 let (_dir, query) = populated();
2056
2057 for directory in ["src", "src/deep", ""] {
2058 assert_eq!(
2059 query
2060 .read_blob(
2061 &handle(),
2062 &repo_name(),
2063 &rev("main"),
2064 &path(directory),
2065 1024
2066 )
2067 .await
2068 .expect("a directory is not a failure"),
2069 None,
2070 "{directory:?} is a directory"
2071 );
2072 }
2073 }
2074
2075 #[tokio::test]
2076 async fn reading_a_path_that_is_not_there_finds_nothing() {
2077 let (_dir, query) = populated();
2078
2079 for missing in ["nope.txt", "src/nope.txt", "README.md/nope"] {
2080 assert_eq!(
2081 query
2082 .read_blob(&handle(), &repo_name(), &rev("main"), &path(missing), 1024)
2083 .await
2084 .expect("should read"),
2085 None,
2086 "{missing} should not be found"
2087 );
2088 }
2089 }
2090
2091 #[tokio::test]
2092 async fn a_blobs_id_matches_the_listing() {
2093 // Two commands, one object: if they disagree, one of the two parsers is wrong.
2094 let (_dir, query) = populated();
2095
2096 let entries = by_name(
2097 query
2098 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
2099 .await
2100 .expect("should read")
2101 .expect("the root is a directory"),
2102 );
2103 let blob = query
2104 .read_blob(
2105 &handle(),
2106 &repo_name(),
2107 &rev("main"),
2108 &path("README.md"),
2109 1024,
2110 )
2111 .await
2112 .expect("should read")
2113 .expect("README.md is a file");
2114
2115 assert_eq!(blob.id, entries["README.md"].id);
2116 assert_eq!(Some(blob.size), entries["README.md"].size);
2117 }
2118
2119 #[tokio::test]
2120 async fn a_symlink_reads_as_its_target_path() {
2121 // The object store cannot tell a symlink from a file — both are blobs — and its
2122 // content is the path it points at. Showing that is more use than a blank page,
2123 // so this is a deliberate choice rather than an oversight.
2124 let (_dir, query) = populated();
2125
2126 let blob = query
2127 .read_blob(&handle(), &repo_name(), &rev("main"), &path("link"), 1024)
2128 .await
2129 .expect("should read")
2130 .expect("a symlink is readable");
2131
2132 assert_eq!(blob.content.as_deref(), Some(b"README.md".as_slice()));
2133 }
2134
2135 // --- log ---------------------------------------------------------------------
2136
2137 #[tokio::test]
2138 async fn the_log_is_newest_first() {
2139 let (_dir, query) = populated();
2140
2141 let commits = query
2142 .log(&handle(), &repo_name(), &rev("main"), 10)
2143 .await
2144 .expect("should read");
2145
2146 assert_eq!(commits.len(), 3);
2147 assert_eq!(
2148 commits
2149 .iter()
2150 .map(|commit| commit.summary.as_str())
2151 .collect::<Vec<_>>(),
2152 vec!["third: 'quotes', \"doubles\" | pipes", "second", "first"]
2153 );
2154 }
2155
2156 #[tokio::test]
2157 async fn the_log_stops_at_the_limit() {
2158 let (_dir, query) = populated();
2159
2160 let commits = query
2161 .log(&handle(), &repo_name(), &rev("main"), 2)
2162 .await
2163 .expect("should read");
2164
2165 assert_eq!(commits.len(), 2);
2166 assert_eq!(commits[0].summary, "third: 'quotes', \"doubles\" | pipes");
2167
2168 assert!(
2169 query
2170 .log(&handle(), &repo_name(), &rev("main"), 0)
2171 .await
2172 .expect("should read")
2173 .is_empty()
2174 );
2175 }
2176
2177 #[tokio::test]
2178 async fn a_commit_message_body_does_not_leak_into_the_summary() {
2179 // The message has a blank line and two body lines. A parser that split the
2180 // stream on newlines would report "body line one" as a separate commit.
2181 let (_dir, query) = populated();
2182
2183 let commits = query
2184 .log(&handle(), &repo_name(), &rev("main"), 10)
2185 .await
2186 .expect("should read");
2187
2188 assert_eq!(commits.len(), 3, "three commits, not five");
2189 assert!(
2190 !commits[0].summary.contains("body line"),
2191 "got: {:?}",
2192 commits[0].summary
2193 );
2194 }
2195
2196 #[tokio::test]
2197 async fn a_log_entry_carries_its_author_and_time() {
2198 let (_dir, query) = populated();
2199
2200 let commits = query
2201 .log(&handle(), &repo_name(), &rev("main"), 10)
2202 .await
2203 .expect("should read");
2204
2205 assert_eq!(commits[0].author_name, "Ada Lovelace");
2206 assert_eq!(
2207 commits[0].committed_at,
2208 UNIX_EPOCH + Duration::from_secs(THIRD_COMMIT as u64)
2209 );
2210 assert_eq!(
2211 commits[2].committed_at,
2212 UNIX_EPOCH + Duration::from_secs(FIRST_COMMIT as u64)
2213 );
2214 }
2215
2216 #[tokio::test]
2217 async fn a_log_entry_id_is_the_commit_the_revision_resolves_to() {
2218 let (_dir, query) = populated();
2219
2220 let head = query
2221 .resolve(&handle(), &repo_name(), &rev("main"))
2222 .await
2223 .expect("should read")
2224 .expect("main resolves");
2225 let commits = query
2226 .log(&handle(), &repo_name(), &rev("main"), 1)
2227 .await
2228 .expect("should read");
2229
2230 assert_eq!(commits[0].id, head);
2231 }
2232
2233 #[tokio::test]
2234 async fn the_log_of_an_unknown_revision_is_empty_rather_than_a_failure() {
2235 let (_dir, query) = populated();
2236
2237 assert_eq!(
2238 query
2239 .log(&handle(), &repo_name(), &rev("no-such-branch"), 10)
2240 .await
2241 .expect("an unknown branch is not a failure"),
2242 Vec::new()
2243 );
2244 }
2245
2246 #[tokio::test]
2247 async fn a_log_can_start_from_an_older_commit() {
2248 let (_dir, query) = populated();
2249
2250 let all = query
2251 .log(&handle(), &repo_name(), &rev("main"), 10)
2252 .await
2253 .expect("should read");
2254 let from_second = query
2255 .log(&handle(), &repo_name(), &rev(all[1].id.as_str()), 10)
2256 .await
2257 .expect("should read");
2258
2259 assert_eq!(from_second.len(), 2, "history behind the second commit");
2260 assert_eq!(from_second[0].id, all[1].id);
2261 }
2262
2263 // --- list_refs ---------------------------------------------------------------
2264
2265 /// The populated repository with a second branch and two tags pushed into it — one
2266 /// lightweight, one annotated, because they are different objects and the switcher
2267 /// must not care.
2268 fn with_refs() -> (TempDir, DiskGitQuery) {
2269 let (dir, query) = populated();
2270 let repo = query.repo_path(&handle(), &repo_name());
2271 let work = dir.path().join("work");
2272 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2273
2274 // A slash in the name, because that is what makes a ref name interesting: it is
2275 // the case the `/-/` separator in the URL exists for.
2276 git(&work, THIRD_COMMIT, &["branch", "feature/login"]);
2277 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
2278 git(
2279 &work,
2280 THIRD_COMMIT,
2281 &["tag", "-a", "v2.0", "-m", "second release"],
2282 );
2283 git(
2284 &work,
2285 THIRD_COMMIT,
2286 &["push", "--quiet", &target, "feature/login"],
2287 );
2288 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
2289
2290 (dir, query)
2291 }
2292
2293 fn named(refs: &[GitRef], kind: RefKind) -> Vec<String> {
2294 let mut names: Vec<String> = refs
2295 .iter()
2296 .filter(|git_ref| git_ref.kind == kind)
2297 .map(|git_ref| git_ref.name.to_string())
2298 .collect();
2299
2300 // The port promises no order, so a test that asserted one would be asserting
2301 // something the adapter is free to change.
2302 names.sort();
2303 names
2304 }
2305
2306 #[tokio::test]
2307 async fn branches_and_tags_are_listed_and_told_apart() {
2308 let (_dir, query) = with_refs();
2309
2310 let refs = query
2311 .list_refs(&handle(), &repo_name())
2312 .await
2313 .expect("should read");
2314
2315 assert_eq!(
2316 named(&refs, RefKind::Branch),
2317 vec!["feature/login".to_owned(), "main".to_owned()]
2318 );
2319 // An annotated tag points at a tag object rather than a commit, and a
2320 // lightweight one points straight at the commit. Both are tags.
2321 assert_eq!(
2322 named(&refs, RefKind::Tag),
2323 vec!["v1.0".to_owned(), "v2.0".to_owned()]
2324 );
2325 }
2326
2327 #[tokio::test]
2328 async fn a_repository_with_one_branch_lists_just_it() {
2329 let (_dir, query) = populated();
2330
2331 let refs = query
2332 .list_refs(&handle(), &repo_name())
2333 .await
2334 .expect("should read");
2335
2336 assert_eq!(refs.len(), 1);
2337 assert_eq!(refs[0].name.as_str(), "main");
2338 assert_eq!(refs[0].kind, RefKind::Branch);
2339 }
2340
2341 #[tokio::test]
2342 async fn an_empty_repository_lists_no_refs() {
2343 // HEAD names `main`, but no ref exists, so there is nothing to switch to. An
2344 // empty list rather than an error: nothing pushed yet is not a failure.
2345 let (_dir, query) = empty();
2346
2347 assert_eq!(
2348 query
2349 .list_refs(&handle(), &repo_name())
2350 .await
2351 .expect("should read"),
2352 Vec::new()
2353 );
2354 }
2355
2356 #[tokio::test]
2357 async fn listing_refs_of_a_repository_that_is_not_on_disk_is_an_error() {
2358 let (_dir, query) = empty();
2359 let missing = RepoName::new("never-created").expect("valid repository name");
2360
2361 assert!(query.list_refs(&handle(), &missing).await.is_err());
2362 }
2363
2364 #[test]
2365 fn refs_are_parsed_from_nul_terminated_records() {
2366 // git ends each record with a newline the format cannot suppress, so it arrives
2367 // in front of the next record's name. Anything outside the two namespaces is
2368 // dropped rather than guessed at.
2369 let stdout = b"refs/heads/main\0\nrefs/tags/v1.0\0\nrefs/pull/7/head\0\n";
2370 let refs = parse_refs(stdout);
2371
2372 assert_eq!(refs.len(), 2);
2373 assert_eq!(refs[0].name.as_str(), "main");
2374 assert_eq!(refs[0].kind, RefKind::Branch);
2375 assert_eq!(refs[1].name.as_str(), "v1.0");
2376 assert_eq!(refs[1].kind, RefKind::Tag);
2377 }
2378
2379 #[test]
2380 fn nothing_is_parsed_from_an_empty_listing() {
2381 assert!(parse_refs(b"").is_empty());
2382 }
2383
2384 // --- count_commits ------------------------------------------------------------
2385
2386 #[tokio::test]
2387 async fn commits_are_counted_from_the_revision_asked_about() {
2388 let (_dir, query) = populated();
2389
2390 assert_eq!(
2391 query
2392 .count_commits(&handle(), &repo_name(), &rev("main"))
2393 .await
2394 .expect("should count"),
2395 3
2396 );
2397 }
2398
2399 #[tokio::test]
2400 async fn a_revision_with_no_commits_counts_zero_rather_than_failing() {
2401 // Both spellings of "nothing here": an empty repository, and a branch that is
2402 // not there. `rev-list` is fatal for each, and a page asking how big a
2403 // repository is wants a number.
2404 let (_dir, empty_query) = empty();
2405 assert_eq!(
2406 empty_query
2407 .count_commits(&handle(), &repo_name(), &rev("main"))
2408 .await
2409 .expect("should count"),
2410 0
2411 );
2412
2413 let (_dir, query) = populated();
2414 assert_eq!(
2415 query
2416 .count_commits(&handle(), &repo_name(), &rev("no-such-branch"))
2417 .await
2418 .expect("should count"),
2419 0
2420 );
2421 }
2422
2423 #[tokio::test]
2424 async fn counting_a_repository_that_is_not_on_disk_is_an_error() {
2425 // Same rule as everywhere else here: absent from disk is a fault, not a zero.
2426 let (_dir, query) = empty();
2427 let missing = RepoName::new("gone").expect("valid repository name");
2428
2429 assert!(
2430 query
2431 .count_commits(&handle(), &missing, &rev("main"))
2432 .await
2433 .is_err()
2434 );
2435 }
2436
2437 // --- latest_tag ---------------------------------------------------------------
2438
2439 /// The populated repository with two annotated tags whose dates disagree with their
2440 /// names, so a test can tell "newest" from "last alphabetically".
2441 fn with_dated_tags() -> (TempDir, DiskGitQuery) {
2442 let (dir, query) = populated();
2443 let repo = query.repo_path(&handle(), &repo_name());
2444 let work = dir.path().join("work");
2445 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2446
2447 git(&work, FIRST_COMMIT, &["tag", "-a", "v1.0", "-m", "first"]);
2448 // Made later but named lower: sorting by name would pick `v1.0`.
2449 git(
2450 &work,
2451 THIRD_COMMIT,
2452 &["tag", "-a", "v0.9", "-m", "backport"],
2453 );
2454 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
2455
2456 (dir, query)
2457 }
2458
2459 #[tokio::test]
2460 async fn the_latest_tag_is_the_newest_one_not_the_last_one_alphabetically() {
2461 let (_dir, query) = with_dated_tags();
2462
2463 let tag = query
2464 .latest_tag(&handle(), &repo_name())
2465 .await
2466 .expect("should read")
2467 .expect("a tag");
2468
2469 assert_eq!(tag.name.as_str(), "v0.9");
2470 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
2471 }
2472
2473 #[tokio::test]
2474 async fn a_lightweight_tag_is_dated_by_the_commit_it_points_at() {
2475 // It has no date of its own, and `creatordate` is what fills that in.
2476 let (dir, query) = populated();
2477 let repo = query.repo_path(&handle(), &repo_name());
2478 let work = dir.path().join("work");
2479 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2480
2481 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
2482 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
2483
2484 let tag = query
2485 .latest_tag(&handle(), &repo_name())
2486 .await
2487 .expect("should read")
2488 .expect("a tag");
2489
2490 assert_eq!(tag.name.as_str(), "v1.0");
2491 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
2492 }
2493
2494 #[tokio::test]
2495 async fn a_repository_with_no_tags_has_no_latest_tag() {
2496 let (_dir, query) = populated();
2497 assert_eq!(
2498 query
2499 .latest_tag(&handle(), &repo_name())
2500 .await
2501 .expect("should read"),
2502 None
2503 );
2504
2505 let (_dir, empty_query) = empty();
2506 assert_eq!(
2507 empty_query
2508 .latest_tag(&handle(), &repo_name())
2509 .await
2510 .expect("should read"),
2511 None
2512 );
2513 }
2514
2515 #[test]
2516 fn a_tag_record_is_parsed_past_the_trailing_newline() {
2517 // `for-each-ref` ends every record with a newline the format cannot suppress,
2518 // exactly as it does for `parse_refs`.
2519 let tag = parse_latest_tag(b"refs/tags/v1.0\x001700000000\x00\n").expect("a tag");
2520
2521 assert_eq!(tag.name.as_str(), "v1.0");
2522 assert_eq!(tag.created_at, unix_time(1_700_000_000));
2523 }
2524
2525 #[test]
2526 fn nothing_is_parsed_from_an_empty_tag_listing() {
2527 assert_eq!(parse_latest_tag(b""), None);
2528 // A ref outside the tags namespace is not a tag, whatever asked for it.
2529 assert_eq!(
2530 parse_latest_tag(b"refs/heads/main\x001700000000\x00\n"),
2531 None
2532 );
2533 }
2534
2535 // --- branches and tags ---------------------------------------------------------
2536
2537 /// The populated repository with two more branches, each left at an older commit so
2538 /// the three tips carry three different dates — otherwise "newest first" is not
2539 /// something a test can see.
2540 fn with_branches() -> (TempDir, DiskGitQuery) {
2541 let (dir, query) = populated();
2542 let repo = query.repo_path(&handle(), &repo_name());
2543 let work = dir.path().join("work");
2544 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2545
2546 git(&work, THIRD_COMMIT, &["branch", "stale", "main~2"]);
2547 // A slash in the name, because that is the case the `/-/` separator exists for.
2548 git(&work, THIRD_COMMIT, &["branch", "feature/login", "main~1"]);
2549 git(
2550 &work,
2551 THIRD_COMMIT,
2552 &["push", "--quiet", &target, "stale", "feature/login"],
2553 );
2554
2555 (dir, query)
2556 }
2557
2558 /// The populated repository with one lightweight tag and two annotated ones, made
2559 /// on three different dates so ordering and the annotated/lightweight split can be
2560 /// asserted together.
2561 fn with_mixed_tags() -> (TempDir, DiskGitQuery) {
2562 let (dir, query) = populated();
2563 let repo = query.repo_path(&handle(), &repo_name());
2564 let work = dir.path().join("work");
2565 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
2566
2567 // Lightweight: no object of its own, so its date is the commit's.
2568 git(&work, FIRST_COMMIT, &["tag", "v0.5", "main~2"]);
2569 git(
2570 &work,
2571 SECOND_COMMIT,
2572 &["tag", "-a", "v1.0", "-m", "first release"],
2573 );
2574 git(
2575 &work,
2576 THIRD_COMMIT,
2577 &["tag", "-a", "v2.0", "-m", "second release\n\nnotes below"],
2578 );
2579 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
2580
2581 (dir, query)
2582 }
2583
2584 #[tokio::test]
2585 async fn branches_are_newest_first_with_the_default_marked() {
2586 let (_dir, query) = with_branches();
2587
2588 let rows = query
2589 .branches(&handle(), &repo_name())
2590 .await
2591 .expect("should read");
2592
2593 assert_eq!(
2594 rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
2595 vec!["main", "feature/login", "stale"]
2596 );
2597 // `%(HEAD)` marks exactly one branch, and it is the one a bare repository's
2598 // HEAD names — which is what the page pins to the top.
2599 assert_eq!(
2600 rows.iter()
2601 .filter(|row| row.is_default)
2602 .map(|row| row.name.as_str())
2603 .collect::<Vec<_>>(),
2604 vec!["main"]
2605 );
2606 }
2607
2608 #[tokio::test]
2609 async fn a_branch_row_carries_its_tip_commit() {
2610 let (_dir, query) = with_branches();
2611
2612 let rows = query
2613 .branches(&handle(), &repo_name())
2614 .await
2615 .expect("should read");
2616
2617 let main = rows.first().expect("main is first");
2618
2619 // The subject only, from a message whose body would leak into it if the format
2620 // were read line-wise.
2621 assert_eq!(main.summary, "third: 'quotes', \"doubles\" | pipes");
2622 assert_eq!(main.committed_at, unix_time(THIRD_COMMIT));
2623 assert_eq!(main.commit.as_str().len(), 40);
2624
2625 let stale = rows.last().expect("stale is last");
2626 assert_eq!(stale.summary, "first");
2627 assert_eq!(stale.committed_at, unix_time(FIRST_COMMIT));
2628 }
2629
2630 #[tokio::test]
2631 async fn an_empty_repository_has_no_branches() {
2632 // The same answer `list_refs` gives, and for the same reason: nothing pushed
2633 // yet is not a failure. It is also how the page knows to show the push snippet.
2634 let (_dir, query) = empty();
2635
2636 assert_eq!(
2637 query
2638 .branches(&handle(), &repo_name())
2639 .await
2640 .expect("should read"),
2641 Vec::new()
2642 );
2643 }
2644
2645 #[tokio::test]
2646 async fn tags_are_newest_first_and_only_annotated_ones_carry_a_message() {
2647 let (_dir, query) = with_mixed_tags();
2648
2649 let rows = query
2650 .tags(&handle(), &repo_name())
2651 .await
2652 .expect("should read");
2653
2654 assert_eq!(
2655 rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
2656 vec!["v2.0", "v1.0", "v0.5"]
2657 );
2658
2659 let newest = &rows[0];
2660 assert!(newest.annotated);
2661 // The subject of the tag's own message, not its body.
2662 assert_eq!(newest.message.as_deref(), Some("second release"));
2663 assert_eq!(newest.created_at, unix_time(THIRD_COMMIT));
2664
2665 let lightweight = &rows[2];
2666 assert!(!lightweight.annotated);
2667 // A lightweight tag has no message of its own; the commit's subject is the
2668 // commit's, and reporting it would invent one.
2669 assert_eq!(lightweight.message, None);
2670 assert_eq!(lightweight.created_at, unix_time(FIRST_COMMIT));
2671 }
2672
2673 #[tokio::test]
2674 async fn an_annotated_tag_reports_the_commit_it_peels_to() {
2675 // Its `objectname` is the tag object, which is not what a visitor browses.
2676 let (_dir, query) = with_mixed_tags();
2677
2678 let tip = query
2679 .branches(&handle(), &repo_name())
2680 .await
2681 .expect("should read")
2682 .into_iter()
2683 .find(|row| row.name.as_str() == "main")
2684 .expect("main");
2685
2686 let annotated = query
2687 .tags(&handle(), &repo_name())
2688 .await
2689 .expect("should read")
2690 .into_iter()
2691 .find(|row| row.name.as_str() == "v1.0")
2692 .expect("v1.0");
2693
2694 assert_eq!(annotated.commit, tip.commit);
2695 }
2696
2697 #[tokio::test]
2698 async fn a_repository_with_no_tags_lists_none() {
2699 let (_dir, query) = populated();
2700 assert_eq!(
2701 query
2702 .tags(&handle(), &repo_name())
2703 .await
2704 .expect("should read"),
2705 Vec::new()
2706 );
2707
2708 let (_dir, empty_query) = empty();
2709 assert_eq!(
2710 empty_query
2711 .tags(&handle(), &repo_name())
2712 .await
2713 .expect("should read"),
2714 Vec::new()
2715 );
2716 }
2717
2718 #[tokio::test]
2719 async fn listing_rows_of_a_repository_that_is_not_on_disk_is_an_error() {
2720 let (_dir, query) = empty();
2721 let missing = RepoName::new("never-created").expect("valid repository name");
2722
2723 assert!(query.branches(&handle(), &missing).await.is_err());
2724 assert!(query.tags(&handle(), &missing).await.is_err());
2725 }
2726
2727 #[test]
2728 fn branch_records_survive_the_newline_git_puts_between_them() {
2729 let rows = parse_branches(
2730 b"refs/heads/main\x00*\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x001700000000\x00first\x00\nrefs/heads/side\x00 \x00bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x001700000100\x00second\x00\n",
2731 )
2732 .expect("should parse");
2733
2734 assert_eq!(rows.len(), 2);
2735 assert!(rows[0].is_default);
2736 assert_eq!(rows[0].summary, "first");
2737 // The newline in front of `refs/heads/side` is git's record separator, not part
2738 // of the name.
2739 assert_eq!(rows[1].name.as_str(), "side");
2740 assert!(!rows[1].is_default);
2741 assert_eq!(rows[1].committed_at, unix_time(1_700_000_100));
2742 }
2743
2744 #[test]
2745 fn a_lightweight_tags_empty_peel_does_not_shift_the_fields_after_it() {
2746 // The reason `ref_fields` keeps empty fields: filtering them would read this
2747 // record's date as its commit id.
2748 let rows = parse_tags(
2749 b"refs/tags/v1.0\x00commit\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x00\x001700000000\x00a commit subject\x00\n",
2750 )
2751 .expect("should parse");
2752
2753 assert_eq!(rows.len(), 1);
2754 assert_eq!(rows[0].name.as_str(), "v1.0");
2755 assert_eq!(rows[0].commit.as_str(), "a".repeat(40));
2756 assert!(!rows[0].annotated);
2757 assert_eq!(rows[0].message, None);
2758 assert_eq!(rows[0].created_at, unix_time(1_700_000_000));
2759 }
2760
2761 #[test]
2762 fn nothing_is_parsed_from_an_empty_row_listing() {
2763 assert_eq!(parse_branches(b"").expect("should parse"), Vec::new());
2764 assert_eq!(parse_tags(b"").expect("should parse"), Vec::new());
2765 }
2766
2767 #[test]
2768 fn a_record_with_the_wrong_number_of_fields_is_a_fault() {
2769 // Skipping a ref Steid cannot link to is right; misreading git's output is not.
2770 assert!(parse_branches(b"refs/heads/main\x00*\x00\n").is_err());
2771 }
2772
2773 // --- helpers ------------------------------------------------------------------
2774
2775 #[tokio::test]
2776 async fn repo_path_lands_under_the_data_directory() {
2777 let query = DiskGitQuery::new("/data");
2778
2779 assert_eq!(
2780 query.repo_path(&handle(), &repo_name()),
2781 PathBuf::from("/data/jamesgill/steid.git")
2782 );
2783 }
2784
2785 #[test]
2786 fn a_pre_epoch_commit_time_does_not_panic() {
2787 // git will hand back a negative `%ct` for an imported history, and
2788 // `UNIX_EPOCH + Duration` cannot represent it.
2789 assert!(unix_time(-1) < UNIX_EPOCH);
2790 assert_eq!(unix_time(0), UNIX_EPOCH);
2791 }
2792
2793 #[tokio::test]
2794 async fn a_read_that_exceeds_its_limit_is_a_timeout_not_a_fault() {
2795 let (_dir, repo) = fixture_repo_for_timeout().await;
2796 let error = run_within(&repo, ["rev-parse", "HEAD"], Duration::ZERO)
2797 .await
2798 .expect_err("a zero limit cannot be met");
2799 assert!(error.is_timeout(), "{error}");
2800 }
2801
2802 /// A bare repository with nothing in it: `rev-parse` failing is not the point, the
2803 /// process being cut off before it can answer is.
2804 async fn fixture_repo_for_timeout() -> (TempDir, std::path::PathBuf) {
2805 let dir = TempDir::new().unwrap();
2806 let repo = dir.path().join("t.git");
2807 let status = git_command()
2808 .args(["init", "--bare", "-q"])
2809 .arg(&repo)
2810 .status()
2811 .await
2812 .unwrap();
2813 assert!(status.success());
2814 (dir, repo)
2815 }
2816
2817 // --- commits, diffs and comparisons ----------------------------------------
2818
2819 /// A repository with the shapes a diff parser has to survive: a rename, a binary
2820 /// file, a file added, a file deleted — plus a second branch and an unrelated
2821 /// history, which is what compare needs.
2822 fn with_history() -> (TempDir, DiskGitQuery) {
2823 let (dir, query) = empty();
2824 let repo = query.repo_path(&handle(), &repo_name());
2825 let work = dir.path().join("work");
2826
2827 std::fs::create_dir_all(work.join("src")).expect("create work tree");
2828 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
2829
2830 std::fs::write(work.join("src/a.txt"), b"aaa\nbbb\nccc\nddd\neee\n").expect("write");
2831 std::fs::write(work.join("gone.txt"), b"going\n").expect("write");
2832 std::fs::write(work.join("logo.bin"), BINARY).expect("write");
2833 git(&work, FIRST_COMMIT, &["add", "-A"]);
2834 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
2835
2836 // A rename git will only find with `-M`: the content is 83% the same.
2837 std::fs::rename(work.join("src/a.txt"), work.join("src/b.txt")).expect("rename");
2838 std::fs::write(work.join("src/b.txt"), b"aaa\nbbb\nccc\nddd\neee\nfff\n").expect("write");
2839 std::fs::remove_file(work.join("gone.txt")).expect("remove");
2840 std::fs::write(work.join("logo.bin"), [0x00, 0x02, 0xff]).expect("write");
2841 std::fs::write(work.join("new.txt"), b"new\n").expect("write");
2842 git(&work, SECOND_COMMIT, &["add", "-A"]);
2843 git(
2844 &work,
2845 SECOND_COMMIT,
2846 &["commit", "--quiet", "-m", "second\n\nwhy it was done"],
2847 );
2848
2849 // A branch ahead of main, for compare.
2850 git(&work, THIRD_COMMIT, &["checkout", "--quiet", "-b", "next"]);
2851 std::fs::write(work.join("new.txt"), b"new\nand more\n").expect("write");
2852 git(&work, THIRD_COMMIT, &["add", "-A"]);
2853 git(&work, THIRD_COMMIT, &["commit", "--quiet", "-m", "third"]);
2854
2855 // A history sharing no ancestor with either, which a compare page must be able
2856 // to report rather than fail on.
2857 git(
2858 &work,
2859 THIRD_COMMIT,
2860 &["checkout", "--quiet", "--orphan", "unrelated"],
2861 );
2862 git(&work, THIRD_COMMIT, &["rm", "-rq", "--cached", "."]);
2863 std::fs::write(work.join("z.txt"), b"z\n").expect("write");
2864 git(&work, THIRD_COMMIT, &["add", "z.txt"]);
2865 git(
2866 &work,
2867 THIRD_COMMIT,
2868 &["commit", "--quiet", "-m", "unrelated"],
2869 );
2870
2871 git(
2872 &work,
2873 THIRD_COMMIT,
2874 &[
2875 "push",
2876 "--quiet",
2877 repo.to_str().expect("utf-8 fixture path"),
2878 "main",
2879 "next",
2880 "unrelated",
2881 ],
2882 );
2883
2884 (dir, query)
2885 }
2886
2887 /// The commit a revision names, for a test that needs the id rather than the name.
2888 async fn commit_id(query: &DiskGitQuery, name: &str) -> ObjectId {
2889 query
2890 .resolve(&handle(), &repo_name(), &rev(name))
2891 .await
2892 .expect("should resolve")
2893 .expect("a commit")
2894 }
2895
2896 #[tokio::test]
2897 async fn a_commit_carries_its_message_its_people_and_its_parent() {
2898 let (_dir, query) = with_history();
2899
2900 let commit = query
2901 .commit(&handle(), &repo_name(), &rev("main"))
2902 .await
2903 .expect("should read")
2904 .expect("a commit");
2905
2906 assert_eq!(commit.summary, "second");
2907 // The body is everything after the blank line, and the trailing newline git
2908 // adds to the record is not part of it.
2909 assert_eq!(commit.body, "why it was done");
2910 assert_eq!(commit.author_name, "Ada Lovelace");
2911 assert_eq!(commit.author_email, "ada@example.com");
2912 assert_eq!(commit.committed_at, unix_time(SECOND_COMMIT));
2913 assert_eq!(commit.parents.len(), 1);
2914 assert!(!commit.is_root());
2915 assert!(!commit.has_distinct_committer());
2916 }
2917
2918 #[tokio::test]
2919 async fn a_first_commit_has_no_parent() {
2920 let (_dir, query) = with_history();
2921
2922 let head = query
2923 .commit(&handle(), &repo_name(), &rev("main"))
2924 .await
2925 .expect("should read")
2926 .expect("a commit");
2927 let parent = RefName::from_trusted(head.parents[0].as_str());
2928
2929 let root = query
2930 .commit(&handle(), &repo_name(), &parent)
2931 .await
2932 .expect("should read")
2933 .expect("a commit");
2934
2935 assert_eq!(root.summary, "first");
2936 assert!(root.is_root());
2937 }
2938
2939 #[tokio::test]
2940 async fn an_abbreviated_id_names_the_same_commit_as_the_branch() {
2941 // What a URL carries when someone clicks a shortened sha in the log.
2942 let (_dir, query) = with_history();
2943
2944 let head = query
2945 .commit(&handle(), &repo_name(), &rev("main"))
2946 .await
2947 .expect("should read")
2948 .expect("a commit");
2949
2950 let short = query
2951 .commit(
2952 &handle(),
2953 &repo_name(),
2954 &RefName::from_trusted(head.id.short()),
2955 )
2956 .await
2957 .expect("should read")
2958 .expect("a commit");
2959
2960 assert_eq!(short.id, head.id);
2961 }
2962
2963 #[tokio::test]
2964 async fn a_revision_that_names_no_commit_has_none() {
2965 let (_dir, query) = with_history();
2966
2967 assert!(
2968 query
2969 .commit(&handle(), &repo_name(), &rev("nope"))
2970 .await
2971 .expect("should read")
2972 .is_none()
2973 );
2974 }
2975
2976 #[tokio::test]
2977 async fn a_diff_carries_its_counts_before_its_patch() {
2978 let (_dir, query) = with_history();
2979 let head = query
2980 .commit(&handle(), &repo_name(), &rev("main"))
2981 .await
2982 .expect("should read")
2983 .expect("a commit");
2984
2985 let raw = query
2986 .diff(
2987 &handle(),
2988 &repo_name(),
2989 head.parents.first(),
2990 &head.id,
2991 1024 * 1024,
2992 )
2993 .await
2994 .expect("should diff");
2995
2996 let numstat = String::from_utf8_lossy(&raw.numstat);
2997 let patch = String::from_utf8_lossy(&raw.patch);
2998
2999 assert!(!raw.truncated);
3000 // Four files: the rename, the deletion, the binary, and the addition.
3001 assert_eq!(numstat.lines().filter(|line| !line.is_empty()).count(), 4);
3002 // The rename is one entry with both names, which is what `-M` buys.
3003 assert!(
3004 numstat.contains("src/{a.txt => b.txt}"),
3005 "expected a rename in {numstat:?}"
3006 );
3007 // A binary file has no counts, which is git's `-` rather than a zero.
3008 assert!(numstat.contains("-\t-\tlogo.bin"), "{numstat:?}");
3009
3010 // And the patch is the patch, starting where the counts stop.
3011 assert!(
3012 patch.starts_with("diff --git "),
3013 "{:?}",
3014 &patch[..60.min(patch.len())]
3015 );
3016 assert!(patch.contains("rename from src/a.txt"));
3017 assert!(patch.contains("Binary files "));
3018 }
3019
3020 #[tokio::test]
3021 async fn a_root_commit_is_diffed_against_nothing_rather_than_skipped() {
3022 let (_dir, query) = with_history();
3023 let head = query
3024 .commit(&handle(), &repo_name(), &rev("main"))
3025 .await
3026 .expect("should read")
3027 .expect("a commit");
3028 let root = query
3029 .commit(
3030 &handle(),
3031 &repo_name(),
3032 &RefName::from_trusted(head.parents[0].as_str()),
3033 )
3034 .await
3035 .expect("should read")
3036 .expect("a commit");
3037
3038 let raw = query
3039 .diff(&handle(), &repo_name(), None, &root.id, 1024 * 1024)
3040 .await
3041 .expect("should diff");
3042
3043 // Without `--root` this would be empty, and the first commit in a repository
3044 // would show as having changed nothing.
3045 assert!(String::from_utf8_lossy(&raw.numstat).contains("src/a.txt"));
3046 assert!(String::from_utf8_lossy(&raw.patch).contains("new file mode"));
3047 }
3048
3049 #[tokio::test]
3050 async fn a_diff_over_the_cap_is_cut_short_with_its_counts_intact() {
3051 // The reason the counts are asked for in the same run: they are written first,
3052 // so they survive a patch that does not fit.
3053 let (_dir, query) = with_history();
3054 let head = query
3055 .commit(&handle(), &repo_name(), &rev("main"))
3056 .await
3057 .expect("should read")
3058 .expect("a commit");
3059
3060 let raw = query
3061 .diff(&handle(), &repo_name(), head.parents.first(), &head.id, 90)
3062 .await
3063 .expect("should diff");
3064
3065 assert!(raw.truncated);
3066 assert_eq!(raw.numstat.len() + raw.patch.len(), 90);
3067 assert!(String::from_utf8_lossy(&raw.numstat).contains("src/{a.txt => b.txt}"));
3068 }
3069
3070 #[tokio::test]
3071 async fn a_merge_base_is_the_point_two_branches_share() {
3072 let (_dir, query) = with_history();
3073 let main = commit_id(&query, "main").await;
3074 let next = commit_id(&query, "next").await;
3075
3076 let base = query
3077 .merge_base(&handle(), &repo_name(), &main, &next)
3078 .await
3079 .expect("should read")
3080 .expect("a merge base");
3081
3082 // `next` branched off the tip of `main`, so that tip is the base.
3083 assert_eq!(base, main);
3084 }
3085
3086 #[tokio::test]
3087 async fn two_histories_with_no_common_ancestor_have_no_merge_base() {
3088 // git says this with exit 1 and no output — the one documented not-found exit
3089 // this module tolerates. It must be an answer, not a 500.
3090 let (_dir, query) = with_history();
3091 let main = commit_id(&query, "main").await;
3092 let unrelated = commit_id(&query, "unrelated").await;
3093
3094 assert!(
3095 query
3096 .merge_base(&handle(), &repo_name(), &main, &unrelated)
3097 .await
3098 .expect("should read")
3099 .is_none()
3100 );
3101 }
3102
3103 #[tokio::test]
3104 async fn a_range_lists_only_what_the_head_adds() {
3105 let (_dir, query) = with_history();
3106 let main = commit_id(&query, "main").await;
3107 let next = commit_id(&query, "next").await;
3108
3109 let commits = query
3110 .log_between(&handle(), &repo_name(), Some(&main), &next, 50)
3111 .await
3112 .expect("should read");
3113
3114 assert_eq!(
3115 commits
3116 .iter()
3117 .map(|commit| commit.summary.as_str())
3118 .collect::<Vec<_>>(),
3119 vec!["third"]
3120 );
3121
3122 // The other way round adds nothing: `main` is contained in `next`.
3123 assert!(
3124 query
3125 .log_between(&handle(), &repo_name(), Some(&next), &main, 50)
3126 .await
3127 .expect("should read")
3128 .is_empty()
3129 );
3130 }
3131
3132 #[test]
3133 fn the_counts_and_the_patch_are_split_at_the_first_file_header() {
3134 let (numstat, patch) = split_numstat(b"1\t1\ta.txt\n\ndiff --git a/a.txt b/a.txt\n@@\n");
3135
3136 assert_eq!(numstat, b"1\t1\ta.txt\n\n");
3137 assert!(patch.starts_with(b"diff --git "));
3138 }
3139
3140 #[test]
3141 fn a_diff_with_nothing_in_it_splits_into_two_empties() {
3142 let (numstat, patch) = split_numstat(b"");
3143
3144 assert!(numstat.is_empty());
3145 assert!(patch.is_empty());
3146 }
3147
3148 // --- blame ----------------------------------------------------------------------
3149
3150 /// A repository with one file rewritten across three commits, so a blame of it has
3151 /// several runs rather than one.
3152 ///
3153 /// Its own fixture rather than another file in [`populated`], because the shape
3154 /// blame needs — the same file touched repeatedly, at known times — is exactly what
3155 /// the other queries do not want.
3156 fn blamed() -> (TempDir, DiskGitQuery) {
3157 let (dir, query) = empty();
3158 let repo = query.repo_path(&handle(), &repo_name());
3159 let work = dir.path().join("blame-work");
3160
3161 std::fs::create_dir_all(work.join("docs")).expect("create work tree");
3162 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
3163
3164 std::fs::write(work.join("notes.md"), b"one\ntwo\nthree\n").expect("write");
3165 std::fs::write(work.join("docs/why.md"), b"because\n").expect("write");
3166 std::fs::write(work.join("logo.png"), BINARY).expect("write");
3167 git(&work, FIRST_COMMIT, &["add", "-A"]);
3168 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
3169
3170 std::fs::write(work.join("notes.md"), b"one\nTWO\nthree\n").expect("write");
3171 git(&work, SECOND_COMMIT, &["add", "-A"]);
3172 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
3173
3174 std::fs::write(work.join("notes.md"), b"one\nTWO\nthree\nfour\n").expect("write");
3175 git(&work, THIRD_COMMIT, &["add", "-A"]);
3176 git(&work, THIRD_COMMIT, &["commit", "--quiet", "-m", "third"]);
3177
3178 git(
3179 &work,
3180 THIRD_COMMIT,
3181 &[
3182 "push",
3183 "--quiet",
3184 repo.to_str().expect("utf-8 fixture path"),
3185 "main",
3186 ],
3187 );
3188
3189 (dir, query)
3190 }
3191
3192 #[tokio::test]
3193 async fn blame_splits_a_file_into_runs_by_the_commit_that_wrote_them() {
3194 let (_dir, query) = blamed();
3195
3196 let blame = query
3197 .blame(&handle(), &repo_name(), &rev("main"), &path("notes.md"))
3198 .await
3199 .expect("should read")
3200 .expect("a blameable file");
3201
3202 // Lines 1 and 3 are the first commit's but are not adjacent, so four runs.
3203 let shape: Vec<(usize, Vec<String>, String)> = blame
3204 .groups
3205 .iter()
3206 .map(|group| {
3207 (
3208 group.start_line,
3209 group.lines.clone(),
3210 group.commit.summary.clone(),
3211 )
3212 })
3213 .collect();
3214
3215 assert_eq!(
3216 shape,
3217 vec![
3218 (1, vec!["one".to_owned()], "first".to_owned()),
3219 (2, vec!["TWO".to_owned()], "second".to_owned()),
3220 (3, vec!["three".to_owned()], "first".to_owned()),
3221 (4, vec!["four".to_owned()], "third".to_owned()),
3222 ]
3223 );
3224 assert_eq!(blame.line_count(), 4);
3225 }
3226
3227 #[tokio::test]
3228 async fn blame_carries_what_a_page_shows_about_each_commit() {
3229 let (_dir, query) = blamed();
3230
3231 let blame = query
3232 .blame(&handle(), &repo_name(), &rev("main"), &path("notes.md"))
3233 .await
3234 .expect("should read")
3235 .expect("a blameable file");
3236 let newest = &blame.groups[3].commit;
3237
3238 assert_eq!(newest.author_name, "Ada Lovelace");
3239 assert_eq!(newest.authored_at, unix_time(THIRD_COMMIT));
3240 assert_eq!(newest.filename, "notes.md");
3241 // Full ids, so the page can link to a commit without an ambiguous abbreviation.
3242 assert_eq!(newest.id.as_str().len(), 40);
3243 // The oldest run is the faintest tint and the newest the strongest.
3244 assert_eq!(blame.groups[0].age, 0);
3245 assert_eq!(blame.groups[3].age, 4);
3246 }
3247
3248 #[tokio::test]
3249 async fn blame_can_be_asked_by_object_id_as_well_as_by_branch() {
3250 // A blame page reached from a commit link names a sha in its URL.
3251 let (_dir, query) = blamed();
3252 let commit = query
3253 .resolve(&handle(), &repo_name(), &rev("main"))
3254 .await
3255 .expect("should read")
3256 .expect("main resolves");
3257
3258 let blame = query
3259 .blame(
3260 &handle(),
3261 &repo_name(),
3262 &rev(commit.as_str()),
3263 &path("notes.md"),
3264 )
3265 .await
3266 .expect("should read")
3267 .expect("a blameable file");
3268
3269 assert_eq!(blame.groups.len(), 4);
3270 }
3271
3272 #[tokio::test]
3273 async fn there_is_nothing_to_blame_that_is_not_a_file() {
3274 // Every one of these is a fatal error if handed straight to `git blame`, which
3275 // is why the existence check runs first.
3276 let (_dir, query) = blamed();
3277
3278 for (revision, target) in [
3279 ("main", "docs"),
3280 ("main", "nope.md"),
3281 ("no-such-branch", "notes.md"),
3282 ] {
3283 assert_eq!(
3284 query
3285 .blame(&handle(), &repo_name(), &rev(revision), &path(target))
3286 .await
3287 .expect("should read"),
3288 None,
3289 "{revision}:{target} should not blame"
3290 );
3291 }
3292 }
3293
3294 #[tokio::test]
3295 async fn an_empty_repository_blames_nothing() {
3296 let (_dir, query) = empty();
3297
3298 assert_eq!(
3299 query
3300 .blame(&handle(), &repo_name(), &rev("main"), &path("README.md"))
3301 .await
3302 .expect("should read"),
3303 None
3304 );
3305 }
3306}