steid

@jamesgill /

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