steid

@jamesgill /

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