steid

@jamesgill /

steid/src/infrastructure/git_query_tests.rs
Rust73.8 KBCode·Blame·History·Raw
1use std::collections::HashMap;
2
3use tempfile::TempDir;
4
5use super::*;
6use crate::application::port::GitQueryErrorKind;
7use crate::domain::{EntryKind, RefKind};
8
9/// Entry names as the port takes them. Also in `git_query/parse_tree.rs`, whose
10/// unit tests need it too and cannot reach a sibling test module.
11fn names(values: &[&str]) -> Vec<String> {
12 values.iter().map(|value| (*value).to_owned()).collect()
13}
14
15/// Fixed so a timestamp assertion is exact rather than approximate.
16const FIRST_COMMIT: i64 = 1_700_000_000;
17const SECOND_COMMIT: i64 = 1_700_000_100;
18const THIRD_COMMIT: i64 = 1_700_000_200;
19
20/// A subject with the punctuation a naive parser splits on, followed by a body — so
21/// a test can prove the body does not leak into the summary.
22const ODD_MESSAGE: &str = "third: 'quotes', \"doubles\" | pipes\n\nbody line one\nbody line two";
23
24const BINARY: &[u8] = &[0x00, 0x01, 0xff, 0xfe, 0x80];
25
26fn handle() -> OrgName {
27 OrgName::new("jamesgill").expect("valid handle")
28}
29
30fn repo_name() -> RepoName {
31 RepoName::new("steid").expect("valid repository name")
32}
33
34fn rev(value: &str) -> RefName {
35 RefName::new(value).expect("valid revision")
36}
37
38fn path(value: &str) -> RepoPath {
39 RepoPath::new(value).expect("valid path")
40}
41
42/// Runs git in a fixture, isolated from the host's configuration the same way the
43/// adapter is — otherwise a developer's `commit.gpgsign` or `init.defaultBranch`
44/// decides whether the suite passes.
45fn git(dir: &Path, when: i64, args: &[&str]) {
46 let date = format!("@{when} +0000");
47
48 let output = std::process::Command::new("git")
49 .arg("-C")
50 .arg(dir)
51 .args(args)
52 .env("GIT_CONFIG_GLOBAL", "/dev/null")
53 .env("GIT_CONFIG_SYSTEM", "/dev/null")
54 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
55 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
56 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
57 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
58 .env("GIT_AUTHOR_DATE", &date)
59 .env("GIT_COMMITTER_DATE", &date)
60 .output()
61 .expect("git should be on PATH");
62
63 assert!(
64 output.status.success(),
65 "git {args:?} failed: {}",
66 String::from_utf8_lossy(&output.stderr)
67 );
68}
69
70/// A data directory holding one empty bare repository, exactly as Steid creates it.
71///
72/// The `TempDir` is returned because dropping it deletes the fixture.
73fn empty() -> (TempDir, DiskGitQuery) {
74 let dir = TempDir::new().expect("temp dir");
75 let query = DiskGitQuery::new(
76 dir.path(),
77 Arc::new(CatFilePool::new()),
78 Arc::new(MergeCache::new()),
79 );
80 let repo = query.repo_path(&handle(), &repo_name());
81
82 std::fs::create_dir_all(repo.parent().expect("has a parent")).expect("create handle dir");
83 git(
84 dir.path(),
85 FIRST_COMMIT,
86 &[
87 "init",
88 "--bare",
89 "--quiet",
90 "--template=",
91 "--initial-branch=main",
92 "--",
93 repo.to_str().expect("utf-8 fixture path"),
94 ],
95 );
96
97 (dir, query)
98}
99
100/// The empty repository with three commits pushed into it, the way a real one fills
101/// up — a working copy and a push, rather than plumbing straight into the object
102/// store.
103fn populated() -> (TempDir, DiskGitQuery) {
104 let (dir, query) = empty();
105 let repo = query.repo_path(&handle(), &repo_name());
106 let work = dir.path().join("work");
107
108 std::fs::create_dir_all(work.join("src/deep")).expect("create work tree");
109 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
110
111 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
112 std::fs::write(work.join("with space.txt"), b"spaced\n").expect("write");
113 std::fs::write(work.join("bin.dat"), BINARY).expect("write");
114 std::fs::write(work.join("big.txt"), vec![b'x'; 100]).expect("write");
115 std::fs::write(work.join("src/deep/file.rs"), b"fn main() {}\n").expect("write");
116 std::os::unix::fs::symlink("README.md", work.join("link")).expect("symlink");
117
118 git(&work, FIRST_COMMIT, &["add", "-A"]);
119 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
120
121 std::fs::write(work.join("README.md"), b"hello again\n").expect("write");
122 git(&work, SECOND_COMMIT, &["add", "-A"]);
123 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
124
125 git(
126 &work,
127 THIRD_COMMIT,
128 &["commit", "--quiet", "--allow-empty", "-m", ODD_MESSAGE],
129 );
130
131 git(
132 &work,
133 THIRD_COMMIT,
134 &[
135 "push",
136 "--quiet",
137 repo.to_str().expect("utf-8 fixture path"),
138 "main",
139 ],
140 );
141
142 (dir, query)
143}
144
145/// A listing keyed by name, so an assertion does not depend on an order the port
146/// explicitly does not promise.
147fn by_name(entries: Vec<TreeEntry>) -> HashMap<String, TreeEntry> {
148 entries
149 .into_iter()
150 .map(|entry| (entry.name.clone(), entry))
151 .collect()
152}
153
154// --- ref snapshots ---------------------------------------------------------
155
156/// The snapshot as a map, so an assertion does not depend on git's ordering.
157async fn snapshot(query: &DiskGitQuery) -> HashMap<String, String> {
158 query
159 .ref_snapshot(&handle(), &repo_name())
160 .await
161 .expect("should read")
162 .into_iter()
163 .map(|(name, id)| (name.as_str().to_owned(), id.as_str().to_owned()))
164 .collect()
165}
166
167#[tokio::test]
168async fn an_empty_repository_snapshots_to_nothing() {
169 // Nothing pushed yet is not a failure — the first push diffs against this.
170 let (_dir, query) = empty();
171
172 assert!(snapshot(&query).await.is_empty());
173}
174
175#[tokio::test]
176async fn a_snapshot_names_refs_in_full_and_by_the_object_the_ref_itself_holds() {
177 // An annotated tag reports the *tag object*, not the commit it peels to: what a
178 // push changes is the ref, and re-annotating the same commit is a change.
179 let (dir, query) = populated();
180 let repo = query.repo_path(&handle(), &repo_name());
181 let work = dir.path().join("work");
182
183 git(&work, THIRD_COMMIT, &["tag", "-a", "v0.1.0", "-m", "first"]);
184 git(&work, THIRD_COMMIT, &["branch", "feat/x"]);
185 git(
186 &work,
187 THIRD_COMMIT,
188 &[
189 "push",
190 "--quiet",
191 repo.to_str().expect("utf-8 fixture path"),
192 "feat/x",
193 "v0.1.0",
194 ],
195 );
196
197 let snapshot = snapshot(&query).await;
198 let main = query
199 .resolve(&handle(), &repo_name(), &rev("main"))
200 .await
201 .expect("should read")
202 .expect("main resolves");
203
204 assert_eq!(snapshot.len(), 3);
205 assert_eq!(snapshot.get("refs/heads/main"), Some(&main.to_string()));
206 assert_eq!(snapshot.get("refs/heads/feat/x"), Some(&main.to_string()));
207 assert_ne!(
208 snapshot.get("refs/tags/v0.1.0"),
209 Some(&main.to_string()),
210 "an annotated tag is its own object"
211 );
212}
213
214#[tokio::test]
215async fn a_snapshot_taken_twice_around_a_push_shows_what_moved() {
216 // The sequence the push path actually performs, end to end.
217 let (dir, query) = populated();
218 let repo = query.repo_path(&handle(), &repo_name());
219 let work = dir.path().join("work");
220
221 let before = snapshot(&query).await;
222
223 std::fs::write(work.join("README.md"), b"hello once more\n").expect("write");
224 git(&work, THIRD_COMMIT, &["add", "-A"]);
225 git(&work, THIRD_COMMIT, &["commit", "--quiet", "-m", "fourth"]);
226 git(
227 &work,
228 THIRD_COMMIT,
229 &[
230 "push",
231 "--quiet",
232 repo.to_str().expect("utf-8 fixture path"),
233 "main",
234 ],
235 );
236
237 let after = snapshot(&query).await;
238
239 assert_eq!(before.len(), 1);
240 assert_eq!(after.len(), 1);
241 assert_ne!(before.get("refs/heads/main"), after.get("refs/heads/main"));
242}
243
244// --- grep ------------------------------------------------------------------
245
246async fn hits(query: &str, limit: usize) -> Vec<GrepHit> {
247 let (_dir, query_port) = populated();
248 let commit = query_port
249 .resolve(&handle(), &repo_name(), &rev("main"))
250 .await
251 .expect("should read")
252 .expect("main resolves");
253
254 query_port
255 .grep(&handle(), &repo_name(), &commit, query, limit)
256 .await
257 .expect("should grep")
258}
259
260#[tokio::test]
261async fn a_search_finds_the_line_it_matched() {
262 let found = hits("fn main", 10).await;
263
264 assert_eq!(found.len(), 1);
265 assert_eq!(found[0].path.as_str(), "src/deep/file.rs");
266 assert_eq!(found[0].line, 1);
267 assert_eq!(found[0].text, "fn main() {}");
268}
269
270#[tokio::test]
271async fn a_search_that_matches_nothing_is_not_a_failure() {
272 // `git grep` exits 1 here, which every other command in this module would treat
273 // as a fault. This is the one exception, and this test is what pins it.
274 assert_eq!(hits("nothing matches this", 10).await, Vec::new());
275}
276
277#[tokio::test]
278async fn a_binary_file_is_never_reported() {
279 // `bin.dat` contains 0x00 0x01 0xff 0xfe 0x80, so a byte-wise search would hit
280 // it. `-I` is what keeps unreadable matches off the page.
281 let found = hits("\u{fffd}", 10).await;
282
283 assert!(
284 found.iter().all(|hit| hit.path.as_str() != "bin.dat"),
285 "a binary file should never appear in results"
286 );
287}
288
289#[tokio::test]
290async fn a_search_is_a_fixed_string_not_a_pattern() {
291 // `.` would match every line if this were a regular expression.
292 assert_eq!(hits("hello.again", 10).await, Vec::new());
293}
294
295#[tokio::test]
296async fn the_limit_bounds_what_comes_back() {
297 // Every file in the fixture contains an `e` somewhere, so this is more than one
298 // match without depending on how many.
299 assert_eq!(hits("e", 2).await.len(), 2);
300}
301
302/// The cap `grep` reads through, exercised directly because
303/// [`MAX_GREP_BYTES`](super::MAX_GREP_BYTES) is far above anything a fixture produces.
304///
305/// Two properties in one test, because they are the two halves of what the capped read
306/// had to keep: a truncated read is an answer rather than a fault, **and** exit 1 for
307/// "matched nothing" is still an answer once the cap is in play.
308#[tokio::test]
309async fn a_search_that_floods_is_truncated_rather_than_collected() {
310 let (_dir, query_port) = populated();
311 let repo = query_port.repo_path(&handle(), &repo_name());
312 let commit = query_port
313 .resolve(&handle(), &repo_name(), &rev("main"))
314 .await
315 .expect("should read")
316 .expect("main resolves");
317
318 let args = |needle: &'static str| {
319 vec![
320 "grep".to_owned(),
321 "-I".to_owned(),
322 "-n".to_owned(),
323 "-z".to_owned(),
324 "-F".to_owned(),
325 "--column".to_owned(),
326 "--no-color".to_owned(),
327 "-e".to_owned(),
328 needle.to_owned(),
329 commit.as_str().to_owned(),
330 "--".to_owned(),
331 ]
332 };
333
334 // Ten bytes cannot hold one record, so the read stops and says so instead of
335 // draining everything `e` matches.
336 let (bytes, truncated) = run_capped_allowing(&repo, args("e"), 10, &[NO_MATCHES])
337 .await
338 .expect("a truncated read is an answer");
339
340 assert!(truncated, "a flooding search must report that it was cut");
341 assert_eq!(bytes.len(), 10);
342
343 // And the exception the uncapped `run_allowing` carried has to survive the move.
344 let (bytes, truncated) =
345 run_capped_allowing(&repo, args("nothing matches this"), 10, &[NO_MATCHES])
346 .await
347 .expect("exit 1 means matched nothing, not a fault");
348
349 assert!(!truncated);
350 assert!(bytes.is_empty());
351}
352
353// --- an empty repository ---------------------------------------------------
354
355#[tokio::test]
356async fn an_empty_repository_has_no_default_branch() {
357 // The distinction the port exists for: HEAD names `main`, but `main` has no
358 // commits, so "nothing pushed yet" rather than a branch a page can browse.
359 let (_dir, query) = empty();
360
361 assert_eq!(
362 query
363 .default_branch(&handle(), &repo_name())
364 .await
365 .expect("should read"),
366 None
367 );
368}
369
370#[tokio::test]
371async fn nothing_resolves_in_an_empty_repository() {
372 let (_dir, query) = empty();
373
374 for revision in ["main", "HEAD", "v1.0"] {
375 assert_eq!(
376 query
377 .resolve(&handle(), &repo_name(), &rev(revision))
378 .await
379 .expect("should read"),
380 None,
381 "{revision} should not resolve"
382 );
383 }
384}
385
386#[tokio::test]
387async fn an_empty_repository_lists_nothing_and_reads_nothing() {
388 let (_dir, query) = empty();
389
390 assert_eq!(
391 query
392 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
393 .await
394 .expect("should read"),
395 None
396 );
397 assert_eq!(
398 query
399 .read_blob(
400 &handle(),
401 &repo_name(),
402 &rev("main"),
403 &path("README.md"),
404 1024
405 )
406 .await
407 .expect("should read"),
408 None
409 );
410}
411
412#[tokio::test]
413async fn an_empty_repository_has_an_empty_log() {
414 // `git log` is a fatal error here, and an empty list is what the port promises.
415 let (_dir, query) = empty();
416
417 assert_eq!(
418 query
419 .log(&handle(), &repo_name(), &rev("main"), 0, 10)
420 .await
421 .expect("should read"),
422 Vec::new()
423 );
424}
425
426// --- a missing repository is a failure, not a 404 ---------------------------
427
428#[tokio::test]
429async fn a_repository_that_is_not_on_disk_is_an_error() {
430 // A record with no directory is a fault to investigate, not a "no such branch".
431 // Answering `Ok(None)` here would hide it behind a plausible-looking 404.
432 let (_dir, query) = empty();
433 let missing = RepoName::new("never-created").expect("valid repository name");
434
435 assert!(query.default_branch(&handle(), &missing).await.is_err());
436 assert!(
437 query
438 .resolve(&handle(), &missing, &rev("main"))
439 .await
440 .is_err()
441 );
442 assert!(
443 query
444 .list_tree(&handle(), &missing, &rev("main"), &RepoPath::root())
445 .await
446 .is_err()
447 );
448 assert!(
449 query
450 .log(&handle(), &missing, &rev("main"), 0, 10)
451 .await
452 .is_err()
453 );
454}
455
456// --- default_branch and resolve --------------------------------------------
457
458#[tokio::test]
459async fn a_repository_with_commits_reports_its_default_branch() {
460 let (_dir, query) = populated();
461
462 assert_eq!(
463 query
464 .default_branch(&handle(), &repo_name())
465 .await
466 .expect("should read"),
467 Some(RefName::from_trusted("main"))
468 );
469}
470
471#[tokio::test]
472async fn a_branch_and_head_resolve_to_the_same_commit() {
473 let (_dir, query) = populated();
474
475 let main = query
476 .resolve(&handle(), &repo_name(), &rev("main"))
477 .await
478 .expect("should read")
479 .expect("main should resolve");
480 let head = query
481 .resolve(&handle(), &repo_name(), &rev("HEAD"))
482 .await
483 .expect("should read");
484
485 assert_eq!(head, Some(main));
486}
487
488#[tokio::test]
489async fn a_commit_id_resolves_to_itself() {
490 let (_dir, query) = populated();
491
492 let main = query
493 .resolve(&handle(), &repo_name(), &rev("main"))
494 .await
495 .expect("should read")
496 .expect("main should resolve");
497
498 assert_eq!(
499 query
500 .resolve(&handle(), &repo_name(), &rev(main.as_str()))
501 .await
502 .expect("should read"),
503 Some(main)
504 );
505}
506
507#[tokio::test]
508async fn an_unknown_revision_resolves_to_nothing() {
509 let (_dir, query) = populated();
510
511 assert_eq!(
512 query
513 .resolve(&handle(), &repo_name(), &rev("no-such-branch"))
514 .await
515 .expect("looking up a missing branch is not a failure"),
516 None
517 );
518}
519
520// --- list_tree --------------------------------------------------------------
521
522#[tokio::test]
523async fn the_root_lists_every_top_level_entry() {
524 let (_dir, query) = populated();
525
526 let entries = by_name(
527 query
528 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
529 .await
530 .expect("should read")
531 .expect("the root is a directory"),
532 );
533
534 let mut names: Vec<&str> = entries.keys().map(String::as_str).collect();
535 names.sort_unstable();
536 assert_eq!(
537 names,
538 vec![
539 "README.md",
540 "big.txt",
541 "bin.dat",
542 "link",
543 "src",
544 "with space.txt"
545 ]
546 );
547 assert_eq!(entries["src"].kind, EntryKind::Tree);
548 assert_eq!(entries["README.md"].kind, EntryKind::Blob);
549 assert_eq!(
550 entries["link"].kind,
551 EntryKind::Symlink,
552 "a symlink is its own kind, not a file"
553 );
554}
555
556#[tokio::test]
557async fn a_listing_carries_blob_sizes_but_not_tree_sizes() {
558 let (_dir, query) = populated();
559
560 let entries = by_name(
561 query
562 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
563 .await
564 .expect("should read")
565 .expect("the root is a directory"),
566 );
567
568 assert_eq!(entries["big.txt"].size, Some(100));
569 assert_eq!(
570 entries["src"].size, None,
571 "a directory has no size a listing can show"
572 );
573}
574
575#[tokio::test]
576async fn a_filename_containing_a_space_survives_the_listing() {
577 // The reason `-z` is not optional: split on whitespace and this name becomes two.
578 let (_dir, query) = populated();
579
580 let entries = by_name(
581 query
582 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
583 .await
584 .expect("should read")
585 .expect("the root is a directory"),
586 );
587
588 assert_eq!(entries["with space.txt"].kind, EntryKind::Blob);
589 assert_eq!(entries["with space.txt"].size, Some(7));
590}
591
592#[tokio::test]
593async fn a_nested_directory_lists_only_its_own_entries() {
594 let (_dir, query) = populated();
595
596 let entries = query
597 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src"))
598 .await
599 .expect("should read")
600 .expect("src is a directory");
601
602 assert_eq!(entries.len(), 1);
603 assert_eq!(entries[0].name, "deep", "names are entry names, not paths");
604 assert_eq!(entries[0].kind, EntryKind::Tree);
605
606 let deeper = query
607 .list_tree(&handle(), &repo_name(), &rev("main"), &path("src/deep"))
608 .await
609 .expect("should read")
610 .expect("src/deep is a directory");
611
612 assert_eq!(deeper.len(), 1);
613 assert_eq!(deeper[0].name, "file.rs");
614}
615
616#[tokio::test]
617async fn listing_a_file_as_a_directory_finds_nothing() {
618 // git calls this a fatal error; to a visitor it is a wrong URL.
619 let (_dir, query) = populated();
620
621 assert_eq!(
622 query
623 .list_tree(&handle(), &repo_name(), &rev("main"), &path("README.md"))
624 .await
625 .expect("a file is not a failure"),
626 None
627 );
628}
629
630#[tokio::test]
631async fn listing_a_path_that_is_not_there_finds_nothing() {
632 let (_dir, query) = populated();
633
634 for missing in ["nope", "src/nope", "README.md/nope"] {
635 assert_eq!(
636 query
637 .list_tree(&handle(), &repo_name(), &rev("main"), &path(missing))
638 .await
639 .expect("should read"),
640 None,
641 "{missing} should not be found"
642 );
643 }
644}
645
646#[tokio::test]
647async fn listing_at_an_unknown_revision_finds_nothing() {
648 let (_dir, query) = populated();
649
650 assert_eq!(
651 query
652 .list_tree(
653 &handle(),
654 &repo_name(),
655 &rev("no-such-branch"),
656 &RepoPath::root()
657 )
658 .await
659 .expect("should read"),
660 None
661 );
662}
663
664#[tokio::test]
665async fn a_listing_reflects_the_revision_it_was_asked_for() {
666 // Proves the revision is actually used rather than HEAD being read every time.
667 let (_dir, query) = populated();
668
669 let first = query
670 .log(&handle(), &repo_name(), &rev("main"), 0, 10)
671 .await
672 .expect("should read")
673 .last()
674 .expect("three commits")
675 .id
676 .clone();
677
678 let old = query
679 .read_blob(
680 &handle(),
681 &repo_name(),
682 &rev(first.as_str()),
683 &path("README.md"),
684 1024,
685 )
686 .await
687 .expect("should read")
688 .expect("README existed in the first commit");
689
690 assert_eq!(old.content.as_deref(), Some(b"hello\n".as_slice()));
691}
692
693// --- read_blob --------------------------------------------------------------
694
695#[tokio::test]
696async fn a_file_is_read_with_its_size_and_content() {
697 let (_dir, query) = populated();
698
699 let blob = query
700 .read_blob(
701 &handle(),
702 &repo_name(),
703 &rev("main"),
704 &path("README.md"),
705 1024,
706 )
707 .await
708 .expect("should read")
709 .expect("README.md is a file");
710
711 assert_eq!(blob.size, 12);
712 assert_eq!(blob.content.as_deref(), Some(b"hello again\n".as_slice()));
713}
714
715#[tokio::test]
716async fn a_binary_file_survives_intact() {
717 // Nothing in this adapter may assume UTF-8: a lossy conversion here would swap
718 // bytes for replacement characters and quietly corrupt every download.
719 let (_dir, query) = populated();
720
721 let blob = query
722 .read_blob(
723 &handle(),
724 &repo_name(),
725 &rev("main"),
726 &path("bin.dat"),
727 1024,
728 )
729 .await
730 .expect("should read")
731 .expect("bin.dat is a file");
732
733 assert_eq!(blob.size, BINARY.len() as u64);
734 assert_eq!(blob.content.as_deref(), Some(BINARY));
735}
736
737#[tokio::test]
738async fn a_file_over_the_cap_reports_its_size_without_its_content() {
739 let (_dir, query) = populated();
740
741 let blob = query
742 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 10)
743 .await
744 .expect("should read")
745 .expect("big.txt is a file");
746
747 assert_eq!(blob.size, 100, "the page still says how big it is");
748 assert_eq!(blob.content, None);
749}
750
751#[tokio::test]
752async fn a_file_exactly_at_the_cap_is_still_read() {
753 let (_dir, query) = populated();
754
755 let blob = query
756 .read_blob(&handle(), &repo_name(), &rev("main"), &path("big.txt"), 100)
757 .await
758 .expect("should read")
759 .expect("big.txt is a file");
760
761 assert_eq!(blob.content.map(|content| content.len()), Some(100));
762}
763
764#[tokio::test]
765async fn a_file_with_a_space_in_its_name_can_be_read() {
766 let (_dir, query) = populated();
767
768 let blob = query
769 .read_blob(
770 &handle(),
771 &repo_name(),
772 &rev("main"),
773 &path("with space.txt"),
774 1024,
775 )
776 .await
777 .expect("should read")
778 .expect("the file is there");
779
780 assert_eq!(blob.content.as_deref(), Some(b"spaced\n".as_slice()));
781}
782
783#[tokio::test]
784async fn reading_a_directory_as_a_file_finds_nothing() {
785 let (_dir, query) = populated();
786
787 for directory in ["src", "src/deep", ""] {
788 assert_eq!(
789 query
790 .read_blob(
791 &handle(),
792 &repo_name(),
793 &rev("main"),
794 &path(directory),
795 1024
796 )
797 .await
798 .expect("a directory is not a failure"),
799 None,
800 "{directory:?} is a directory"
801 );
802 }
803}
804
805#[tokio::test]
806async fn reading_a_path_that_is_not_there_finds_nothing() {
807 let (_dir, query) = populated();
808
809 for missing in ["nope.txt", "src/nope.txt", "README.md/nope"] {
810 assert_eq!(
811 query
812 .read_blob(&handle(), &repo_name(), &rev("main"), &path(missing), 1024)
813 .await
814 .expect("should read"),
815 None,
816 "{missing} should not be found"
817 );
818 }
819}
820
821#[tokio::test]
822async fn a_blobs_id_matches_the_listing() {
823 // Two commands, one object: if they disagree, one of the two parsers is wrong.
824 let (_dir, query) = populated();
825
826 let entries = by_name(
827 query
828 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
829 .await
830 .expect("should read")
831 .expect("the root is a directory"),
832 );
833 let blob = query
834 .read_blob(
835 &handle(),
836 &repo_name(),
837 &rev("main"),
838 &path("README.md"),
839 1024,
840 )
841 .await
842 .expect("should read")
843 .expect("README.md is a file");
844
845 assert_eq!(blob.id, entries["README.md"].id);
846 assert_eq!(Some(blob.size), entries["README.md"].size);
847}
848
849#[tokio::test]
850async fn a_symlink_reads_as_its_target_path() {
851 // The object store cannot tell a symlink from a file — both are blobs — and its
852 // content is the path it points at. Showing that is more use than a blank page,
853 // so this is a deliberate choice rather than an oversight.
854 let (_dir, query) = populated();
855
856 let blob = query
857 .read_blob(&handle(), &repo_name(), &rev("main"), &path("link"), 1024)
858 .await
859 .expect("should read")
860 .expect("a symlink is readable");
861
862 assert_eq!(blob.content.as_deref(), Some(b"README.md".as_slice()));
863}
864
865// --- log ---------------------------------------------------------------------
866
867#[tokio::test]
868async fn the_log_is_newest_first() {
869 let (_dir, query) = populated();
870
871 let commits = query
872 .log(&handle(), &repo_name(), &rev("main"), 0, 10)
873 .await
874 .expect("should read");
875
876 assert_eq!(commits.len(), 3);
877 assert_eq!(
878 commits
879 .iter()
880 .map(|commit| commit.summary.as_str())
881 .collect::<Vec<_>>(),
882 vec!["third: 'quotes', \"doubles\" | pipes", "second", "first"]
883 );
884}
885
886#[tokio::test]
887async fn the_second_page_of_a_log_starts_where_the_first_ended() {
888 let (_dir, query) = populated();
889
890 let first = query
891 .log(&handle(), &repo_name(), &rev("main"), 0, 2)
892 .await
893 .expect("should read");
894 let second = query
895 .log(&handle(), &repo_name(), &rev("main"), 2, 2)
896 .await
897 .expect("should read");
898
899 assert_eq!(first.len(), 2);
900 assert_eq!(second.len(), 1, "one commit left over");
901 assert_eq!(second[0].summary, "first");
902 assert!(
903 !first.iter().any(|commit| commit.id == second[0].id),
904 "the pages do not overlap"
905 );
906}
907
908#[tokio::test]
909async fn an_offset_past_the_end_of_a_log_is_an_empty_page() {
910 // What a bookmarked deep page becomes after a branch is rewritten. The page
911 // clamps before it asks, but the port must not fail if one gets through.
912 let (_dir, query) = populated();
913
914 let commits = query
915 .log(&handle(), &repo_name(), &rev("main"), 500, 10)
916 .await
917 .expect("should read");
918
919 assert!(commits.is_empty());
920}
921
922#[tokio::test]
923async fn the_log_stops_at_the_limit() {
924 let (_dir, query) = populated();
925
926 let commits = query
927 .log(&handle(), &repo_name(), &rev("main"), 0, 2)
928 .await
929 .expect("should read");
930
931 assert_eq!(commits.len(), 2);
932 assert_eq!(commits[0].summary, "third: 'quotes', \"doubles\" | pipes");
933
934 assert!(
935 query
936 .log(&handle(), &repo_name(), &rev("main"), 0, 0)
937 .await
938 .expect("should read")
939 .is_empty()
940 );
941}
942
943#[tokio::test]
944async fn a_commit_message_body_does_not_leak_into_the_summary() {
945 // The message has a blank line and two body lines. A parser that split the
946 // stream on newlines would report "body line one" as a separate commit.
947 let (_dir, query) = populated();
948
949 let commits = query
950 .log(&handle(), &repo_name(), &rev("main"), 0, 10)
951 .await
952 .expect("should read");
953
954 assert_eq!(commits.len(), 3, "three commits, not five");
955 assert!(
956 !commits[0].summary.contains("body line"),
957 "got: {:?}",
958 commits[0].summary
959 );
960}
961
962#[tokio::test]
963async fn a_log_entry_carries_its_author_and_time() {
964 let (_dir, query) = populated();
965
966 let commits = query
967 .log(&handle(), &repo_name(), &rev("main"), 0, 10)
968 .await
969 .expect("should read");
970
971 assert_eq!(commits[0].author_name, "Ada Lovelace");
972 assert_eq!(
973 commits[0].committed_at,
974 UNIX_EPOCH + Duration::from_secs(THIRD_COMMIT as u64)
975 );
976 assert_eq!(
977 commits[2].committed_at,
978 UNIX_EPOCH + Duration::from_secs(FIRST_COMMIT as u64)
979 );
980}
981
982#[tokio::test]
983async fn a_log_entry_id_is_the_commit_the_revision_resolves_to() {
984 let (_dir, query) = populated();
985
986 let head = query
987 .resolve(&handle(), &repo_name(), &rev("main"))
988 .await
989 .expect("should read")
990 .expect("main resolves");
991 let commits = query
992 .log(&handle(), &repo_name(), &rev("main"), 0, 1)
993 .await
994 .expect("should read");
995
996 assert_eq!(commits[0].id, head);
997}
998
999#[tokio::test]
1000async fn the_log_of_an_unknown_revision_is_empty_rather_than_a_failure() {
1001 let (_dir, query) = populated();
1002
1003 assert_eq!(
1004 query
1005 .log(&handle(), &repo_name(), &rev("no-such-branch"), 0, 10)
1006 .await
1007 .expect("an unknown branch is not a failure"),
1008 Vec::new()
1009 );
1010}
1011
1012#[tokio::test]
1013async fn a_log_can_start_from_an_older_commit() {
1014 let (_dir, query) = populated();
1015
1016 let all = query
1017 .log(&handle(), &repo_name(), &rev("main"), 0, 10)
1018 .await
1019 .expect("should read");
1020 let from_second = query
1021 .log(&handle(), &repo_name(), &rev(all[1].id.as_str()), 0, 10)
1022 .await
1023 .expect("should read");
1024
1025 assert_eq!(from_second.len(), 2, "history behind the second commit");
1026 assert_eq!(from_second[0].id, all[1].id);
1027}
1028
1029// --- list_refs ---------------------------------------------------------------
1030
1031/// The populated repository with a second branch and two tags pushed into it — one
1032/// lightweight, one annotated, because they are different objects and the switcher
1033/// must not care.
1034fn with_refs() -> (TempDir, DiskGitQuery) {
1035 let (dir, query) = populated();
1036 let repo = query.repo_path(&handle(), &repo_name());
1037 let work = dir.path().join("work");
1038 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1039
1040 // A slash in the name, because that is what makes a ref name interesting: it is
1041 // the case the `/-/` separator in the URL exists for.
1042 git(&work, THIRD_COMMIT, &["branch", "feature/login"]);
1043 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1044 git(
1045 &work,
1046 THIRD_COMMIT,
1047 &["tag", "-a", "v2.0", "-m", "second release"],
1048 );
1049 git(
1050 &work,
1051 THIRD_COMMIT,
1052 &["push", "--quiet", &target, "feature/login"],
1053 );
1054 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1055
1056 (dir, query)
1057}
1058
1059fn named(refs: &[GitRef], kind: RefKind) -> Vec<String> {
1060 let mut names: Vec<String> = refs
1061 .iter()
1062 .filter(|git_ref| git_ref.kind == kind)
1063 .map(|git_ref| git_ref.name.to_string())
1064 .collect();
1065
1066 // The port promises no order, so a test that asserted one would be asserting
1067 // something the adapter is free to change.
1068 names.sort();
1069 names
1070}
1071
1072#[tokio::test]
1073async fn branches_and_tags_are_listed_and_told_apart() {
1074 let (_dir, query) = with_refs();
1075
1076 let refs = query
1077 .list_refs(&handle(), &repo_name())
1078 .await
1079 .expect("should read");
1080
1081 assert_eq!(
1082 named(&refs, RefKind::Branch),
1083 vec!["feature/login".to_owned(), "main".to_owned()]
1084 );
1085 // An annotated tag points at a tag object rather than a commit, and a
1086 // lightweight one points straight at the commit. Both are tags.
1087 assert_eq!(
1088 named(&refs, RefKind::Tag),
1089 vec!["v1.0".to_owned(), "v2.0".to_owned()]
1090 );
1091}
1092
1093#[tokio::test]
1094async fn a_repository_with_one_branch_lists_just_it() {
1095 let (_dir, query) = populated();
1096
1097 let refs = query
1098 .list_refs(&handle(), &repo_name())
1099 .await
1100 .expect("should read");
1101
1102 assert_eq!(refs.len(), 1);
1103 assert_eq!(refs[0].name.as_str(), "main");
1104 assert_eq!(refs[0].kind, RefKind::Branch);
1105}
1106
1107#[tokio::test]
1108async fn an_empty_repository_lists_no_refs() {
1109 // HEAD names `main`, but no ref exists, so there is nothing to switch to. An
1110 // empty list rather than an error: nothing pushed yet is not a failure.
1111 let (_dir, query) = empty();
1112
1113 assert_eq!(
1114 query
1115 .list_refs(&handle(), &repo_name())
1116 .await
1117 .expect("should read"),
1118 Vec::new()
1119 );
1120}
1121
1122#[tokio::test]
1123async fn listing_refs_of_a_repository_that_is_not_on_disk_is_an_error() {
1124 let (_dir, query) = empty();
1125 let missing = RepoName::new("never-created").expect("valid repository name");
1126
1127 assert!(query.list_refs(&handle(), &missing).await.is_err());
1128}
1129
1130// --- count_commits ------------------------------------------------------------
1131
1132#[tokio::test]
1133async fn commits_are_counted_from_the_revision_asked_about() {
1134 let (_dir, query) = populated();
1135
1136 assert_eq!(
1137 query
1138 .count_commits(&handle(), &repo_name(), &rev("main"))
1139 .await
1140 .expect("should count"),
1141 3
1142 );
1143}
1144
1145#[tokio::test]
1146async fn a_revision_with_no_commits_counts_zero_rather_than_failing() {
1147 // Both spellings of "nothing here": an empty repository, and a branch that is
1148 // not there. `rev-list` is fatal for each, and a page asking how big a
1149 // repository is wants a number.
1150 let (_dir, empty_query) = empty();
1151 assert_eq!(
1152 empty_query
1153 .count_commits(&handle(), &repo_name(), &rev("main"))
1154 .await
1155 .expect("should count"),
1156 0
1157 );
1158
1159 let (_dir, query) = populated();
1160 assert_eq!(
1161 query
1162 .count_commits(&handle(), &repo_name(), &rev("no-such-branch"))
1163 .await
1164 .expect("should count"),
1165 0
1166 );
1167}
1168
1169#[tokio::test]
1170async fn counting_a_repository_that_is_not_on_disk_is_an_error() {
1171 // Same rule as everywhere else here: absent from disk is a fault, not a zero.
1172 let (_dir, query) = empty();
1173 let missing = RepoName::new("gone").expect("valid repository name");
1174
1175 assert!(
1176 query
1177 .count_commits(&handle(), &missing, &rev("main"))
1178 .await
1179 .is_err()
1180 );
1181}
1182
1183// --- latest_tag ---------------------------------------------------------------
1184
1185/// The populated repository with two annotated tags whose dates disagree with their
1186/// names, so a test can tell "newest" from "last alphabetically".
1187fn with_dated_tags() -> (TempDir, DiskGitQuery) {
1188 let (dir, query) = populated();
1189 let repo = query.repo_path(&handle(), &repo_name());
1190 let work = dir.path().join("work");
1191 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1192
1193 git(&work, FIRST_COMMIT, &["tag", "-a", "v1.0", "-m", "first"]);
1194 // Made later but named lower: sorting by name would pick `v1.0`.
1195 git(
1196 &work,
1197 THIRD_COMMIT,
1198 &["tag", "-a", "v0.9", "-m", "backport"],
1199 );
1200 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1201
1202 (dir, query)
1203}
1204
1205#[tokio::test]
1206async fn the_latest_tag_is_the_newest_one_not_the_last_one_alphabetically() {
1207 let (_dir, query) = with_dated_tags();
1208
1209 let tag = query
1210 .latest_tag(&handle(), &repo_name())
1211 .await
1212 .expect("should read")
1213 .expect("a tag");
1214
1215 assert_eq!(tag.name.as_str(), "v0.9");
1216 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
1217}
1218
1219#[tokio::test]
1220async fn a_lightweight_tag_is_dated_by_the_commit_it_points_at() {
1221 // It has no date of its own, and `creatordate` is what fills that in.
1222 let (dir, query) = populated();
1223 let repo = query.repo_path(&handle(), &repo_name());
1224 let work = dir.path().join("work");
1225 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1226
1227 git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1228 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1229
1230 let tag = query
1231 .latest_tag(&handle(), &repo_name())
1232 .await
1233 .expect("should read")
1234 .expect("a tag");
1235
1236 assert_eq!(tag.name.as_str(), "v1.0");
1237 assert_eq!(tag.created_at, unix_time(THIRD_COMMIT));
1238}
1239
1240#[tokio::test]
1241async fn a_repository_with_no_tags_has_no_latest_tag() {
1242 let (_dir, query) = populated();
1243 assert_eq!(
1244 query
1245 .latest_tag(&handle(), &repo_name())
1246 .await
1247 .expect("should read"),
1248 None
1249 );
1250
1251 let (_dir, empty_query) = empty();
1252 assert_eq!(
1253 empty_query
1254 .latest_tag(&handle(), &repo_name())
1255 .await
1256 .expect("should read"),
1257 None
1258 );
1259}
1260
1261// --- branches and tags ---------------------------------------------------------
1262
1263/// The populated repository with two more branches, each left at an older commit so
1264/// the three tips carry three different dates — otherwise "newest first" is not
1265/// something a test can see.
1266fn with_branches() -> (TempDir, DiskGitQuery) {
1267 let (dir, query) = populated();
1268 let repo = query.repo_path(&handle(), &repo_name());
1269 let work = dir.path().join("work");
1270 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1271
1272 git(&work, THIRD_COMMIT, &["branch", "stale", "main~2"]);
1273 // A slash in the name, because that is the case the `/-/` separator exists for.
1274 git(&work, THIRD_COMMIT, &["branch", "feature/login", "main~1"]);
1275 git(
1276 &work,
1277 THIRD_COMMIT,
1278 &["push", "--quiet", &target, "stale", "feature/login"],
1279 );
1280
1281 (dir, query)
1282}
1283
1284/// The populated repository with one lightweight tag and two annotated ones, made
1285/// on three different dates so ordering and the annotated/lightweight split can be
1286/// asserted together.
1287fn with_mixed_tags() -> (TempDir, DiskGitQuery) {
1288 let (dir, query) = populated();
1289 let repo = query.repo_path(&handle(), &repo_name());
1290 let work = dir.path().join("work");
1291 let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1292
1293 // Lightweight: no object of its own, so its date is the commit's.
1294 git(&work, FIRST_COMMIT, &["tag", "v0.5", "main~2"]);
1295 git(
1296 &work,
1297 SECOND_COMMIT,
1298 &["tag", "-a", "v1.0", "-m", "first release"],
1299 );
1300 git(
1301 &work,
1302 THIRD_COMMIT,
1303 &["tag", "-a", "v2.0", "-m", "second release\n\nnotes below"],
1304 );
1305 git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1306
1307 (dir, query)
1308}
1309
1310#[tokio::test]
1311async fn branches_are_newest_first_with_the_default_marked() {
1312 let (_dir, query) = with_branches();
1313
1314 let rows = query
1315 .branches(&handle(), &repo_name())
1316 .await
1317 .expect("should read");
1318
1319 assert_eq!(
1320 rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
1321 vec!["main", "feature/login", "stale"]
1322 );
1323 // `%(HEAD)` marks exactly one branch, and it is the one a bare repository's
1324 // HEAD names — which is what the page pins to the top.
1325 assert_eq!(
1326 rows.iter()
1327 .filter(|row| row.is_default)
1328 .map(|row| row.name.as_str())
1329 .collect::<Vec<_>>(),
1330 vec!["main"]
1331 );
1332}
1333
1334#[tokio::test]
1335async fn a_branch_row_carries_its_tip_commit() {
1336 let (_dir, query) = with_branches();
1337
1338 let rows = query
1339 .branches(&handle(), &repo_name())
1340 .await
1341 .expect("should read");
1342
1343 let main = rows.first().expect("main is first");
1344
1345 // The subject only, from a message whose body would leak into it if the format
1346 // were read line-wise.
1347 assert_eq!(main.summary, "third: 'quotes', \"doubles\" | pipes");
1348 assert_eq!(main.committed_at, unix_time(THIRD_COMMIT));
1349 assert_eq!(main.commit.as_str().len(), 40);
1350
1351 let stale = rows.last().expect("stale is last");
1352 assert_eq!(stale.summary, "first");
1353 assert_eq!(stale.committed_at, unix_time(FIRST_COMMIT));
1354}
1355
1356#[tokio::test]
1357async fn an_empty_repository_has_no_branches() {
1358 // The same answer `list_refs` gives, and for the same reason: nothing pushed
1359 // yet is not a failure. It is also how the page knows to show the push snippet.
1360 let (_dir, query) = empty();
1361
1362 assert_eq!(
1363 query
1364 .branches(&handle(), &repo_name())
1365 .await
1366 .expect("should read"),
1367 Vec::new()
1368 );
1369}
1370
1371#[tokio::test]
1372async fn tags_are_newest_first_and_only_annotated_ones_carry_a_message() {
1373 let (_dir, query) = with_mixed_tags();
1374
1375 let rows = query
1376 .tags(&handle(), &repo_name())
1377 .await
1378 .expect("should read");
1379
1380 assert_eq!(
1381 rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
1382 vec!["v2.0", "v1.0", "v0.5"]
1383 );
1384
1385 let newest = &rows[0];
1386 assert!(newest.annotated);
1387 // The subject of the tag's own message, not its body.
1388 assert_eq!(newest.message.as_deref(), Some("second release"));
1389 assert_eq!(newest.created_at, unix_time(THIRD_COMMIT));
1390
1391 let lightweight = &rows[2];
1392 assert!(!lightweight.annotated);
1393 // A lightweight tag has no message of its own; the commit's subject is the
1394 // commit's, and reporting it would invent one.
1395 assert_eq!(lightweight.message, None);
1396 assert_eq!(lightweight.created_at, unix_time(FIRST_COMMIT));
1397}
1398
1399#[tokio::test]
1400async fn an_annotated_tag_reports_the_commit_it_peels_to() {
1401 // Its `objectname` is the tag object, which is not what a visitor browses.
1402 let (_dir, query) = with_mixed_tags();
1403
1404 let tip = query
1405 .branches(&handle(), &repo_name())
1406 .await
1407 .expect("should read")
1408 .into_iter()
1409 .find(|row| row.name.as_str() == "main")
1410 .expect("main");
1411
1412 let annotated = query
1413 .tags(&handle(), &repo_name())
1414 .await
1415 .expect("should read")
1416 .into_iter()
1417 .find(|row| row.name.as_str() == "v1.0")
1418 .expect("v1.0");
1419
1420 assert_eq!(annotated.commit, tip.commit);
1421}
1422
1423#[tokio::test]
1424async fn a_repository_with_no_tags_lists_none() {
1425 let (_dir, query) = populated();
1426 assert_eq!(
1427 query
1428 .tags(&handle(), &repo_name())
1429 .await
1430 .expect("should read"),
1431 Vec::new()
1432 );
1433
1434 let (_dir, empty_query) = empty();
1435 assert_eq!(
1436 empty_query
1437 .tags(&handle(), &repo_name())
1438 .await
1439 .expect("should read"),
1440 Vec::new()
1441 );
1442}
1443
1444#[tokio::test]
1445async fn listing_rows_of_a_repository_that_is_not_on_disk_is_an_error() {
1446 let (_dir, query) = empty();
1447 let missing = RepoName::new("never-created").expect("valid repository name");
1448
1449 assert!(query.branches(&handle(), &missing).await.is_err());
1450 assert!(query.tags(&handle(), &missing).await.is_err());
1451}
1452
1453// --- helpers ------------------------------------------------------------------
1454
1455#[tokio::test]
1456async fn repo_path_lands_under_the_data_directory() {
1457 let query = DiskGitQuery::new(
1458 "/data",
1459 Arc::new(CatFilePool::new()),
1460 Arc::new(MergeCache::new()),
1461 );
1462
1463 assert_eq!(
1464 query.repo_path(&handle(), &repo_name()),
1465 PathBuf::from("/data/jamesgill/steid.git")
1466 );
1467}
1468
1469#[test]
1470fn a_pre_epoch_commit_time_does_not_panic() {
1471 // git will hand back a negative `%ct` for an imported history, and
1472 // `UNIX_EPOCH + Duration` cannot represent it.
1473 assert!(unix_time(-1) < UNIX_EPOCH);
1474 assert_eq!(unix_time(0), UNIX_EPOCH);
1475}
1476
1477#[tokio::test]
1478async fn a_read_that_exceeds_its_limit_is_a_timeout_not_a_fault() {
1479 let (_dir, repo) = fixture_repo_for_timeout().await;
1480 let error = run_within(&repo, ["rev-parse", "HEAD"], Duration::ZERO)
1481 .await
1482 .expect_err("a zero limit cannot be met");
1483 assert!(error.is_timeout(), "{error}");
1484}
1485
1486/// A bare repository with nothing in it: `rev-parse` failing is not the point, the
1487/// process being cut off before it can answer is.
1488async fn fixture_repo_for_timeout() -> (TempDir, std::path::PathBuf) {
1489 let dir = TempDir::new().unwrap();
1490 let repo = dir.path().join("t.git");
1491 let status = git_command()
1492 .args(["init", "--bare", "-q"])
1493 .arg(&repo)
1494 .status()
1495 .await
1496 .unwrap();
1497 assert!(status.success());
1498 (dir, repo)
1499}
1500
1501// --- commits, diffs and comparisons ----------------------------------------
1502
1503/// A repository with the shapes a diff parser has to survive: a rename, a binary
1504/// file, a file added, a file deleted — plus a second branch and an unrelated
1505/// history, which is what compare needs.
1506fn with_history() -> (TempDir, DiskGitQuery) {
1507 let (dir, query) = empty();
1508 let repo = query.repo_path(&handle(), &repo_name());
1509 let work = dir.path().join("work");
1510
1511 std::fs::create_dir_all(work.join("src")).expect("create work tree");
1512 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
1513
1514 std::fs::write(work.join("src/a.txt"), b"aaa\nbbb\nccc\nddd\neee\n").expect("write");
1515 std::fs::write(work.join("gone.txt"), b"going\n").expect("write");
1516 std::fs::write(work.join("logo.bin"), BINARY).expect("write");
1517 git(&work, FIRST_COMMIT, &["add", "-A"]);
1518 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
1519
1520 // A rename git will only find with `-M`: the content is 83% the same.
1521 std::fs::rename(work.join("src/a.txt"), work.join("src/b.txt")).expect("rename");
1522 std::fs::write(work.join("src/b.txt"), b"aaa\nbbb\nccc\nddd\neee\nfff\n").expect("write");
1523 std::fs::remove_file(work.join("gone.txt")).expect("remove");
1524 std::fs::write(work.join("logo.bin"), [0x00, 0x02, 0xff]).expect("write");
1525 std::fs::write(work.join("new.txt"), b"new\n").expect("write");
1526 git(&work, SECOND_COMMIT, &["add", "-A"]);
1527 git(
1528 &work,
1529 SECOND_COMMIT,
1530 &["commit", "--quiet", "-m", "second\n\nwhy it was done"],
1531 );
1532
1533 // A branch ahead of main, for compare.
1534 git(&work, THIRD_COMMIT, &["checkout", "--quiet", "-b", "next"]);
1535 std::fs::write(work.join("new.txt"), b"new\nand more\n").expect("write");
1536 git(&work, THIRD_COMMIT, &["add", "-A"]);
1537 git(&work, THIRD_COMMIT, &["commit", "--quiet", "-m", "third"]);
1538
1539 // A history sharing no ancestor with either, which a compare page must be able
1540 // to report rather than fail on.
1541 git(
1542 &work,
1543 THIRD_COMMIT,
1544 &["checkout", "--quiet", "--orphan", "unrelated"],
1545 );
1546 git(&work, THIRD_COMMIT, &["rm", "-rq", "--cached", "."]);
1547 std::fs::write(work.join("z.txt"), b"z\n").expect("write");
1548 git(&work, THIRD_COMMIT, &["add", "z.txt"]);
1549 git(
1550 &work,
1551 THIRD_COMMIT,
1552 &["commit", "--quiet", "-m", "unrelated"],
1553 );
1554
1555 git(
1556 &work,
1557 THIRD_COMMIT,
1558 &[
1559 "push",
1560 "--quiet",
1561 repo.to_str().expect("utf-8 fixture path"),
1562 "main",
1563 "next",
1564 "unrelated",
1565 ],
1566 );
1567
1568 (dir, query)
1569}
1570
1571/// The commit a revision names, for a test that needs the id rather than the name.
1572async fn commit_id(query: &DiskGitQuery, name: &str) -> ObjectId {
1573 query
1574 .resolve(&handle(), &repo_name(), &rev(name))
1575 .await
1576 .expect("should resolve")
1577 .expect("a commit")
1578}
1579
1580#[tokio::test]
1581async fn a_commit_carries_its_message_its_people_and_its_parent() {
1582 let (_dir, query) = with_history();
1583
1584 let commit = query
1585 .commit(&handle(), &repo_name(), &rev("main"))
1586 .await
1587 .expect("should read")
1588 .expect("a commit");
1589
1590 assert_eq!(commit.summary, "second");
1591 // The body is everything after the blank line, and the trailing newline git
1592 // adds to the record is not part of it.
1593 assert_eq!(commit.body, "why it was done");
1594 assert_eq!(commit.author_name, "Ada Lovelace");
1595 assert_eq!(commit.author_email, "ada@example.com");
1596 assert_eq!(commit.committed_at, unix_time(SECOND_COMMIT));
1597 assert_eq!(commit.parents.len(), 1);
1598 assert!(!commit.is_root());
1599 assert!(!commit.has_distinct_committer());
1600}
1601
1602#[tokio::test]
1603async fn a_first_commit_has_no_parent() {
1604 let (_dir, query) = with_history();
1605
1606 let head = query
1607 .commit(&handle(), &repo_name(), &rev("main"))
1608 .await
1609 .expect("should read")
1610 .expect("a commit");
1611 let parent = RefName::from_trusted(head.parents[0].as_str());
1612
1613 let root = query
1614 .commit(&handle(), &repo_name(), &parent)
1615 .await
1616 .expect("should read")
1617 .expect("a commit");
1618
1619 assert_eq!(root.summary, "first");
1620 assert!(root.is_root());
1621}
1622
1623#[tokio::test]
1624async fn an_abbreviated_id_names_the_same_commit_as_the_branch() {
1625 // What a URL carries when someone clicks a shortened sha in the log.
1626 let (_dir, query) = with_history();
1627
1628 let head = query
1629 .commit(&handle(), &repo_name(), &rev("main"))
1630 .await
1631 .expect("should read")
1632 .expect("a commit");
1633
1634 let short = query
1635 .commit(
1636 &handle(),
1637 &repo_name(),
1638 &RefName::from_trusted(head.id.short()),
1639 )
1640 .await
1641 .expect("should read")
1642 .expect("a commit");
1643
1644 assert_eq!(short.id, head.id);
1645}
1646
1647#[tokio::test]
1648async fn a_revision_that_names_no_commit_has_none() {
1649 let (_dir, query) = with_history();
1650
1651 assert!(
1652 query
1653 .commit(&handle(), &repo_name(), &rev("nope"))
1654 .await
1655 .expect("should read")
1656 .is_none()
1657 );
1658}
1659
1660#[tokio::test]
1661async fn a_diff_carries_its_counts_before_its_patch() {
1662 let (_dir, query) = with_history();
1663 let head = query
1664 .commit(&handle(), &repo_name(), &rev("main"))
1665 .await
1666 .expect("should read")
1667 .expect("a commit");
1668
1669 let raw = query
1670 .diff(
1671 &handle(),
1672 &repo_name(),
1673 head.parents.first(),
1674 &head.id,
1675 1024 * 1024,
1676 )
1677 .await
1678 .expect("should diff");
1679
1680 let numstat = String::from_utf8_lossy(&raw.numstat);
1681 let patch = String::from_utf8_lossy(&raw.patch);
1682
1683 assert!(!raw.truncated);
1684 // Four files: the rename, the deletion, the binary, and the addition.
1685 assert_eq!(numstat.lines().filter(|line| !line.is_empty()).count(), 4);
1686 // The rename is one entry with both names, which is what `-M` buys.
1687 assert!(
1688 numstat.contains("src/{a.txt => b.txt}"),
1689 "expected a rename in {numstat:?}"
1690 );
1691 // A binary file has no counts, which is git's `-` rather than a zero.
1692 assert!(numstat.contains("-\t-\tlogo.bin"), "{numstat:?}");
1693
1694 // And the patch is the patch, starting where the counts stop.
1695 assert!(
1696 patch.starts_with("diff --git "),
1697 "{:?}",
1698 &patch[..60.min(patch.len())]
1699 );
1700 assert!(patch.contains("rename from src/a.txt"));
1701 assert!(patch.contains("Binary files "));
1702}
1703
1704#[tokio::test]
1705async fn a_root_commit_is_diffed_against_nothing_rather_than_skipped() {
1706 let (_dir, query) = with_history();
1707 let head = query
1708 .commit(&handle(), &repo_name(), &rev("main"))
1709 .await
1710 .expect("should read")
1711 .expect("a commit");
1712 let root = query
1713 .commit(
1714 &handle(),
1715 &repo_name(),
1716 &RefName::from_trusted(head.parents[0].as_str()),
1717 )
1718 .await
1719 .expect("should read")
1720 .expect("a commit");
1721
1722 let raw = query
1723 .diff(&handle(), &repo_name(), None, &root.id, 1024 * 1024)
1724 .await
1725 .expect("should diff");
1726
1727 // Without `--root` this would be empty, and the first commit in a repository
1728 // would show as having changed nothing.
1729 assert!(String::from_utf8_lossy(&raw.numstat).contains("src/a.txt"));
1730 assert!(String::from_utf8_lossy(&raw.patch).contains("new file mode"));
1731}
1732
1733#[tokio::test]
1734async fn a_diff_over_the_cap_is_cut_short_with_its_counts_intact() {
1735 // The reason the counts are asked for in the same run: they are written first,
1736 // so they survive a patch that does not fit.
1737 let (_dir, query) = with_history();
1738 let head = query
1739 .commit(&handle(), &repo_name(), &rev("main"))
1740 .await
1741 .expect("should read")
1742 .expect("a commit");
1743
1744 let raw = query
1745 .diff(&handle(), &repo_name(), head.parents.first(), &head.id, 90)
1746 .await
1747 .expect("should diff");
1748
1749 assert!(raw.truncated);
1750 assert_eq!(raw.numstat.len() + raw.patch.len(), 90);
1751 assert!(String::from_utf8_lossy(&raw.numstat).contains("src/{a.txt => b.txt}"));
1752}
1753
1754#[tokio::test]
1755async fn a_merge_base_is_the_point_two_branches_share() {
1756 let (_dir, query) = with_history();
1757 let main = commit_id(&query, "main").await;
1758 let next = commit_id(&query, "next").await;
1759
1760 let base = query
1761 .merge_base(&handle(), &repo_name(), &main, &next)
1762 .await
1763 .expect("should read")
1764 .expect("a merge base");
1765
1766 // `next` branched off the tip of `main`, so that tip is the base.
1767 assert_eq!(base, main);
1768}
1769
1770#[tokio::test]
1771async fn two_histories_with_no_common_ancestor_have_no_merge_base() {
1772 // git says this with exit 1 and no output — the one documented not-found exit
1773 // this module tolerates. It must be an answer, not a 500.
1774 let (_dir, query) = with_history();
1775 let main = commit_id(&query, "main").await;
1776 let unrelated = commit_id(&query, "unrelated").await;
1777
1778 assert!(
1779 query
1780 .merge_base(&handle(), &repo_name(), &main, &unrelated)
1781 .await
1782 .expect("should read")
1783 .is_none()
1784 );
1785}
1786
1787#[tokio::test]
1788async fn a_range_lists_only_what_the_head_adds() {
1789 let (_dir, query) = with_history();
1790 let main = commit_id(&query, "main").await;
1791 let next = commit_id(&query, "next").await;
1792
1793 let commits = query
1794 .log_between(&handle(), &repo_name(), Some(&main), &next, 0, 50)
1795 .await
1796 .expect("should read");
1797
1798 assert_eq!(
1799 commits
1800 .iter()
1801 .map(|commit| commit.summary.as_str())
1802 .collect::<Vec<_>>(),
1803 vec!["third"]
1804 );
1805
1806 // The other way round adds nothing: `main` is contained in `next`.
1807 assert!(
1808 query
1809 .log_between(&handle(), &repo_name(), Some(&next), &main, 0, 50)
1810 .await
1811 .expect("should read")
1812 .is_empty()
1813 );
1814}
1815
1816#[tokio::test]
1817async fn a_range_counts_what_the_head_adds_and_pages_from_it() {
1818 let (_dir, query) = with_history();
1819 let main = commit_id(&query, "main").await;
1820 let next = commit_id(&query, "next").await;
1821
1822 let total = query
1823 .count_between(&handle(), &repo_name(), Some(&main), &next)
1824 .await
1825 .expect("should count");
1826 let second = query
1827 .log_between(&handle(), &repo_name(), Some(&main), &next, 1, 50)
1828 .await
1829 .expect("should read");
1830
1831 assert_eq!(total, 1);
1832 assert!(second.is_empty(), "one commit, so nothing on a second page");
1833}
1834
1835// --- blame ----------------------------------------------------------------------
1836
1837/// A repository with one file rewritten across three commits, so a blame of it has
1838/// several runs rather than one.
1839///
1840/// Its own fixture rather than another file in [`populated`], because the shape
1841/// blame needs — the same file touched repeatedly, at known times — is exactly what
1842/// the other queries do not want.
1843fn blamed() -> (TempDir, DiskGitQuery) {
1844 let (dir, query) = empty();
1845 let repo = query.repo_path(&handle(), &repo_name());
1846 let work = dir.path().join("blame-work");
1847
1848 std::fs::create_dir_all(work.join("docs")).expect("create work tree");
1849 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
1850
1851 std::fs::write(work.join("notes.md"), b"one\ntwo\nthree\n").expect("write");
1852 std::fs::write(work.join("docs/why.md"), b"because\n").expect("write");
1853 std::fs::write(work.join("logo.png"), BINARY).expect("write");
1854 git(&work, FIRST_COMMIT, &["add", "-A"]);
1855 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
1856
1857 std::fs::write(work.join("notes.md"), b"one\nTWO\nthree\n").expect("write");
1858 git(&work, SECOND_COMMIT, &["add", "-A"]);
1859 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "second"]);
1860
1861 std::fs::write(work.join("notes.md"), b"one\nTWO\nthree\nfour\n").expect("write");
1862 git(&work, THIRD_COMMIT, &["add", "-A"]);
1863 git(&work, THIRD_COMMIT, &["commit", "--quiet", "-m", "third"]);
1864
1865 git(
1866 &work,
1867 THIRD_COMMIT,
1868 &[
1869 "push",
1870 "--quiet",
1871 repo.to_str().expect("utf-8 fixture path"),
1872 "main",
1873 ],
1874 );
1875
1876 (dir, query)
1877}
1878
1879#[tokio::test]
1880async fn blame_splits_a_file_into_runs_by_the_commit_that_wrote_them() {
1881 let (_dir, query) = blamed();
1882
1883 let blame = query
1884 .blame(&handle(), &repo_name(), &rev("main"), &path("notes.md"))
1885 .await
1886 .expect("should read")
1887 .expect("a blameable file");
1888
1889 // Lines 1 and 3 are the first commit's but are not adjacent, so four runs.
1890 let shape: Vec<(usize, Vec<String>, String)> = blame
1891 .groups
1892 .iter()
1893 .map(|group| {
1894 (
1895 group.start_line,
1896 group.lines.clone(),
1897 group.commit.summary.clone(),
1898 )
1899 })
1900 .collect();
1901
1902 assert_eq!(
1903 shape,
1904 vec![
1905 (1, vec!["one".to_owned()], "first".to_owned()),
1906 (2, vec!["TWO".to_owned()], "second".to_owned()),
1907 (3, vec!["three".to_owned()], "first".to_owned()),
1908 (4, vec!["four".to_owned()], "third".to_owned()),
1909 ]
1910 );
1911 assert_eq!(blame.line_count(), 4);
1912}
1913
1914#[tokio::test]
1915async fn blame_carries_what_a_page_shows_about_each_commit() {
1916 let (_dir, query) = blamed();
1917
1918 let blame = query
1919 .blame(&handle(), &repo_name(), &rev("main"), &path("notes.md"))
1920 .await
1921 .expect("should read")
1922 .expect("a blameable file");
1923 let newest = &blame.groups[3].commit;
1924
1925 assert_eq!(newest.author_name, "Ada Lovelace");
1926 assert_eq!(newest.authored_at, unix_time(THIRD_COMMIT));
1927 assert_eq!(newest.filename, "notes.md");
1928 // Full ids, so the page can link to a commit without an ambiguous abbreviation.
1929 assert_eq!(newest.id.as_str().len(), 40);
1930 // The oldest run is the faintest tint and the newest the strongest.
1931 assert_eq!(blame.groups[0].age, 0);
1932 assert_eq!(blame.groups[3].age, 4);
1933}
1934
1935#[tokio::test]
1936async fn blame_can_be_asked_by_object_id_as_well_as_by_branch() {
1937 // A blame page reached from a commit link names a sha in its URL.
1938 let (_dir, query) = blamed();
1939 let commit = query
1940 .resolve(&handle(), &repo_name(), &rev("main"))
1941 .await
1942 .expect("should read")
1943 .expect("main resolves");
1944
1945 let blame = query
1946 .blame(
1947 &handle(),
1948 &repo_name(),
1949 &rev(commit.as_str()),
1950 &path("notes.md"),
1951 )
1952 .await
1953 .expect("should read")
1954 .expect("a blameable file");
1955
1956 assert_eq!(blame.groups.len(), 4);
1957}
1958
1959#[tokio::test]
1960async fn there_is_nothing_to_blame_that_is_not_a_file() {
1961 // Every one of these is a fatal error if handed straight to `git blame`, which
1962 // is why the existence check runs first.
1963 let (_dir, query) = blamed();
1964
1965 for (revision, target) in [
1966 ("main", "docs"),
1967 ("main", "nope.md"),
1968 ("no-such-branch", "notes.md"),
1969 ] {
1970 assert_eq!(
1971 query
1972 .blame(&handle(), &repo_name(), &rev(revision), &path(target))
1973 .await
1974 .expect("should read"),
1975 None,
1976 "{revision}:{target} should not blame"
1977 );
1978 }
1979}
1980
1981#[tokio::test]
1982async fn an_empty_repository_blames_nothing() {
1983 let (_dir, query) = empty();
1984
1985 assert_eq!(
1986 query
1987 .blame(&handle(), &repo_name(), &rev("main"), &path("README.md"))
1988 .await
1989 .expect("should read"),
1990 None
1991 );
1992}
1993
1994// --- log entries -------------------------------------------------------------
1995
1996#[tokio::test]
1997async fn a_log_entry_carries_the_message_body() {
1998 let (_dir, query) = populated();
1999
2000 let entries = query
2001 .log_entries(&handle(), &repo_name(), &rev("main"), 10)
2002 .await
2003 .expect("should read");
2004
2005 assert_eq!(entries.len(), 3);
2006 assert_eq!(
2007 entries[0].commit.summary,
2008 "third: 'quotes', \"doubles\" | pipes"
2009 );
2010 assert_eq!(entries[0].body, "body line one\nbody line two");
2011}
2012
2013#[tokio::test]
2014async fn a_one_line_message_has_an_empty_body_and_does_not_shift_the_record() {
2015 let (_dir, query) = populated();
2016
2017 let entries = query
2018 .log_entries(&handle(), &repo_name(), &rev("main"), 10)
2019 .await
2020 .expect("should read");
2021
2022 // The two older commits were made with `-m` alone, so both bodies are empty —
2023 // which is exactly the case a parser that drops empty fields misreads, reporting
2024 // the next commit's id as this one's body and sliding every field after it.
2025 assert_eq!(entries[1].commit.summary, "second");
2026 assert_eq!(entries[1].body, "");
2027 assert_eq!(entries[2].commit.summary, "first");
2028 assert_eq!(entries[2].body, "");
2029 assert_eq!(entries[2].commit.author_name, "Ada Lovelace");
2030}
2031
2032#[tokio::test]
2033async fn log_entries_agree_with_the_log_they_come_from() {
2034 let (_dir, query) = populated();
2035
2036 let commits = query
2037 .log(&handle(), &repo_name(), &rev("main"), 0, 10)
2038 .await
2039 .expect("should read");
2040 let entries = query
2041 .log_entries(&handle(), &repo_name(), &rev("main"), 10)
2042 .await
2043 .expect("should read");
2044
2045 assert_eq!(
2046 commits,
2047 entries
2048 .into_iter()
2049 .map(|entry| entry.commit)
2050 .collect::<Vec<_>>()
2051 );
2052}
2053
2054#[tokio::test]
2055async fn an_empty_repository_has_no_log_entries() {
2056 let (_dir, query) = empty();
2057
2058 assert!(
2059 query
2060 .log_entries(&handle(), &repo_name(), &rev("main"), 10)
2061 .await
2062 .expect("should read")
2063 .is_empty()
2064 );
2065 assert!(
2066 query
2067 .log_entries(&handle(), &repo_name(), &rev("main"), 0)
2068 .await
2069 .expect("should read")
2070 .is_empty()
2071 );
2072}
2073
2074// The pure parsers are tested beside themselves, in `git_query/parse_*.rs`. What
2075// stays here is everything that needs a fixture repository — the round trip that
2076// proves git and the parsers agree.
2077
2078// --- The last-commit column ---------------------------------------------------
2079
2080#[tokio::test]
2081async fn a_listing_says_what_last_touched_each_of_its_entries() {
2082 let (_dir, query) = with_history();
2083
2084 let found = query
2085 .last_commits(
2086 &handle(),
2087 &repo_name(),
2088 &rev("main"),
2089 &RepoPath::root(),
2090 &names(&["new.txt", "src", "never-existed.txt"]),
2091 )
2092 .await
2093 .expect("should read");
2094
2095 // `new.txt` and `src` were both last touched by the second commit; the entry
2096 // that was never in the repository is absent rather than guessed at.
2097 assert_eq!(found.len(), 2);
2098 assert_eq!(found["new.txt"].summary, "second");
2099 assert_eq!(found["src"].summary, "second");
2100 assert!(!found.contains_key("never-existed.txt"));
2101}
2102
2103#[tokio::test]
2104async fn an_empty_repository_attributes_nothing() {
2105 let (_dir, query) = empty();
2106
2107 assert!(
2108 query
2109 .last_commits(
2110 &handle(),
2111 &repo_name(),
2112 &rev("main"),
2113 &RepoPath::root(),
2114 &names(&["a.txt"]),
2115 )
2116 .await
2117 .expect("should read")
2118 .is_empty()
2119 );
2120}
2121
2122// --- a path's own log ------------------------------------------------------
2123
2124/// The summaries of a path's log, which is what a history page renders.
2125async fn path_log(
2126 query: &DiskGitQuery,
2127 at: &str,
2128 follow: bool,
2129 offset: usize,
2130 limit: usize,
2131) -> Vec<String> {
2132 query
2133 .log_at_path(
2134 &handle(),
2135 &repo_name(),
2136 &rev("main"),
2137 &path(at),
2138 follow,
2139 offset,
2140 limit,
2141 )
2142 .await
2143 .expect("should read")
2144 .into_iter()
2145 .map(|commit| commit.summary)
2146 .collect()
2147}
2148
2149#[tokio::test]
2150async fn a_paths_log_holds_only_the_commits_that_touched_it() {
2151 let (_dir, query) = with_history();
2152
2153 // `new.txt` arrived in the second commit and the first knows nothing about it.
2154 assert_eq!(path_log(&query, "new.txt", false, 0, 50).await, ["second"]);
2155}
2156
2157#[tokio::test]
2158async fn a_directorys_log_is_every_commit_that_touched_anything_beneath_it() {
2159 let (_dir, query) = with_history();
2160
2161 assert_eq!(
2162 path_log(&query, "src", false, 0, 50).await,
2163 ["second", "first"]
2164 );
2165}
2166
2167#[tokio::test]
2168async fn following_a_rename_reaches_the_history_before_it() {
2169 let (_dir, query) = with_history();
2170
2171 // `src/a.txt` became `src/b.txt` in the second commit. Without `--follow` the
2172 // file's history starts at the rename, which is the thing a reader least wants.
2173 assert_eq!(
2174 path_log(&query, "src/b.txt", false, 0, 50).await,
2175 ["second"]
2176 );
2177 assert_eq!(
2178 path_log(&query, "src/b.txt", true, 0, 50).await,
2179 ["second", "first"]
2180 );
2181}
2182
2183#[tokio::test]
2184async fn a_paths_log_pages_by_skipping() {
2185 let (_dir, query) = with_history();
2186
2187 assert_eq!(path_log(&query, "src", false, 1, 50).await, ["first"]);
2188 assert!(path_log(&query, "src", false, 9, 50).await.is_empty());
2189 assert_eq!(path_log(&query, "src", false, 0, 1).await, ["second"]);
2190}
2191
2192#[tokio::test]
2193async fn a_paths_log_of_an_unknown_revision_is_empty_rather_than_a_failure() {
2194 let (_dir, query) = with_history();
2195
2196 let commits = query
2197 .log_at_path(
2198 &handle(),
2199 &repo_name(),
2200 &rev("nope"),
2201 &path("src"),
2202 false,
2203 0,
2204 50,
2205 )
2206 .await
2207 .expect("should read");
2208
2209 assert!(commits.is_empty());
2210}
2211
2212#[tokio::test]
2213async fn a_paths_commits_are_counted_the_same_way_they_are_listed() {
2214 let (_dir, query) = with_history();
2215
2216 let count = async |at: &str, follow: bool| {
2217 query
2218 .count_commits_at_path(&handle(), &repo_name(), &rev("main"), &path(at), follow)
2219 .await
2220 .expect("should count")
2221 };
2222
2223 // The followed count must match the followed listing, or a page says "showing
2224 // 1–2 of 1".
2225 assert_eq!(count("src/b.txt", false).await.total, 1);
2226 assert_eq!(count("src/b.txt", true).await.total, 2);
2227 assert_eq!(count("src", false).await.total, 2);
2228 // A path nothing ever touched is zero rather than an error.
2229 assert_eq!(count("never-existed.txt", false).await.total, 0);
2230 // Nothing here is anywhere near the cap, so nothing claims to be.
2231 assert!(!count("src/b.txt", true).await.capped);
2232}
2233
2234#[tokio::test]
2235async fn counting_a_path_in_an_empty_repository_is_zero() {
2236 let (_dir, query) = empty();
2237
2238 assert_eq!(
2239 query
2240 .count_commits_at_path(&handle(), &repo_name(), &rev("main"), &path("a.txt"), false)
2241 .await
2242 .expect("should count"),
2243 CommitCount::default()
2244 );
2245}
2246
2247// --- symlink targets -------------------------------------------------------
2248
2249#[tokio::test]
2250async fn a_symlinks_target_is_read_from_the_blob_it_is_stored_as() {
2251 let (_dir, query) = populated();
2252
2253 let entries = by_name(
2254 query
2255 .list_tree(&handle(), &repo_name(), &rev("main"), &RepoPath::root())
2256 .await
2257 .expect("should read")
2258 .expect("a directory"),
2259 );
2260 let link = &entries["link"];
2261
2262 assert_eq!(link.kind, EntryKind::Symlink);
2263
2264 let targets = query
2265 .symlink_targets(&handle(), &repo_name(), std::slice::from_ref(&link.id))
2266 .await
2267 .expect("should read");
2268
2269 assert_eq!(targets[&link.id], "README.md");
2270}
2271
2272#[tokio::test]
2273async fn asking_about_no_symlinks_reads_nothing() {
2274 let (_dir, query) = populated();
2275
2276 assert!(
2277 query
2278 .symlink_targets(&handle(), &repo_name(), &[])
2279 .await
2280 .expect("should read")
2281 .is_empty()
2282 );
2283}
2284
2285#[tokio::test]
2286async fn an_id_that_is_not_a_blob_is_left_out_rather_than_guessed_at() {
2287 let (_dir, query) = populated();
2288
2289 let tree = query
2290 .resolve(&handle(), &repo_name(), &rev("main"))
2291 .await
2292 .expect("should read")
2293 .expect("a commit");
2294
2295 // A commit id, which is not a link and has no target.
2296 assert!(
2297 query
2298 .symlink_targets(&handle(), &repo_name(), &[tree])
2299 .await
2300 .expect("should read")
2301 .is_empty()
2302 );
2303}
2304
2305// --- merge preview ---------------------------------------------------------------
2306
2307/// A bare repository holding `main` plus two branches off it: one that merges
2308/// cleanly and one that does not, both touching the same commit's tree.
2309///
2310/// Built by pushing from a working copy, like [`populated`], rather than by
2311/// plumbing into the object store — the merge is about what a real branch looks
2312/// like, and a hand-built one would prove less.
2313fn branched() -> (TempDir, DiskGitQuery) {
2314 let (dir, query) = empty();
2315 let repo = query.repo_path(&handle(), &repo_name());
2316 let work = dir.path().join("merge-work");
2317
2318 std::fs::create_dir_all(&work).expect("create work tree");
2319 git(&work, FIRST_COMMIT, &["init", "--quiet", "-b", "main"]);
2320
2321 std::fs::write(work.join("shared.txt"), b"one\n").expect("write");
2322 git(&work, FIRST_COMMIT, &["add", "-A"]);
2323 git(&work, FIRST_COMMIT, &["commit", "--quiet", "-m", "first"]);
2324
2325 // A branch that touches a file `main` never does: nothing to disagree about.
2326 git(&work, SECOND_COMMIT, &["checkout", "--quiet", "-b", "tidy"]);
2327 std::fs::write(work.join("other.txt"), b"tidy\n").expect("write");
2328 git(&work, SECOND_COMMIT, &["add", "-A"]);
2329 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "tidy"]);
2330
2331 // A branch that rewrites the same line `main` is about to rewrite differently.
2332 git(&work, SECOND_COMMIT, &["checkout", "--quiet", "main"]);
2333 git(
2334 &work,
2335 SECOND_COMMIT,
2336 &["checkout", "--quiet", "-b", "clash"],
2337 );
2338 std::fs::write(work.join("shared.txt"), b"theirs\n").expect("write");
2339 git(&work, SECOND_COMMIT, &["add", "-A"]);
2340 git(&work, SECOND_COMMIT, &["commit", "--quiet", "-m", "theirs"]);
2341
2342 git(&work, THIRD_COMMIT, &["checkout", "--quiet", "main"]);
2343 std::fs::write(work.join("shared.txt"), b"ours\n").expect("write");
2344 git(&work, THIRD_COMMIT, &["add", "-A"]);
2345 git(&work, THIRD_COMMIT, &["commit", "--quiet", "-m", "ours"]);
2346
2347 git(
2348 &work,
2349 THIRD_COMMIT,
2350 &[
2351 "push",
2352 "--quiet",
2353 repo.to_str().expect("utf-8 fixture path"),
2354 "main",
2355 "tidy",
2356 "clash",
2357 ],
2358 );
2359
2360 (dir, query)
2361}
2362
2363/// Resolves a revision in the fixture, so a test can name a branch and hand the
2364/// port the ids it takes.
2365async fn resolved(query: &DiskGitQuery, revision: &str) -> ObjectId {
2366 query
2367 .resolve(&handle(), &repo_name(), &rev(revision))
2368 .await
2369 .expect("should resolve")
2370 .expect("the revision exists")
2371}
2372
2373#[tokio::test]
2374async fn two_branches_that_do_not_overlap_merge_cleanly() {
2375 let (_dir, query) = branched();
2376 let target = resolved(&query, "main").await;
2377 let source = resolved(&query, "tidy").await;
2378
2379 let preview = query
2380 .merge_preview(&handle(), &repo_name(), &target, &source)
2381 .await
2382 .expect("should preview");
2383
2384 // The tree is what makes this usable: `commit_tree` takes it straight, so a
2385 // clean preview is the merge, already computed.
2386 let MergePreview::Clean { tree } = preview else {
2387 panic!("expected a clean merge, got {preview:?}");
2388 };
2389 assert_eq!(tree.as_str().len(), 40);
2390}
2391
2392#[tokio::test]
2393async fn two_branches_that_rewrite_one_line_report_the_file() {
2394 let (_dir, query) = branched();
2395 let target = resolved(&query, "main").await;
2396 let source = resolved(&query, "clash").await;
2397
2398 let preview = query
2399 .merge_preview(&handle(), &repo_name(), &target, &source)
2400 .await
2401 .expect("a conflict is an answer, not a failure");
2402
2403 // Named once, though git reports three stages of it: three stages of a file is
2404 // one file to whoever has to resolve it.
2405 assert_eq!(
2406 preview,
2407 MergePreview::Conflicted {
2408 paths: vec!["shared.txt".to_owned()],
2409 }
2410 );
2411}
2412
2413#[tokio::test]
2414async fn a_second_look_at_the_same_pair_is_answered_from_the_cache() {
2415 // What the pull request page was paying per view: a full three-way merge, and a
2416 // fresh set of objects `--write-tree` leaves unreferenced. Counted by asking the
2417 // cache rather than by counting processes, because the pair is the key and a
2418 // populated key is exactly what stops the second run.
2419 let (dir, _built) = branched();
2420 let merges = std::sync::Arc::new(MergeCache::new());
2421 let query = DiskGitQuery::new(dir.path(), Arc::new(CatFilePool::new()), merges.clone());
2422
2423 let target = resolved(&query, "main").await;
2424 let source = resolved(&query, "clash").await;
2425
2426 assert_eq!(merges.get(&target, &source), None);
2427
2428 let first = query
2429 .merge_preview(&handle(), &repo_name(), &target, &source)
2430 .await
2431 .expect("should preview");
2432
2433 assert_eq!(merges.get(&target, &source).as_ref(), Some(&first));
2434
2435 let second = query
2436 .merge_preview(&handle(), &repo_name(), &target, &source)
2437 .await
2438 .expect("should preview");
2439
2440 assert_eq!(second, first);
2441}
2442
2443#[tokio::test]
2444async fn merging_a_branch_already_contained_in_the_target_is_clean() {
2445 // The fast-forward case seen from the preview's side: `main` contains `main`,
2446 // so there is nothing to disagree about and the tree is the one it already has.
2447 let (_dir, query) = branched();
2448 let main = resolved(&query, "main").await;
2449
2450 assert!(
2451 query
2452 .merge_preview(&handle(), &repo_name(), &main, &main)
2453 .await
2454 .expect("should preview")
2455 .is_clean()
2456 );
2457}
2458
2459#[tokio::test]
2460async fn a_plain_read_that_outgrows_its_cap_is_too_large_not_a_fault() {
2461 // The backstop under every non-grep, non-diff read. A cut-short ref list or log
2462 // would parse into a *wrong* answer rather than a smaller one, so the cap is an
2463 // error and the page says there is too much to show — the timeout's sibling.
2464 let (_dir, query) = populated();
2465 let repo = query.repo_path(&handle(), &repo_name());
2466
2467 let error = run_bounded(&repo, ["rev-parse", "HEAD"], 4)
2468 .await
2469 .expect_err("a forty-byte sha cannot fit in four bytes");
2470
2471 assert_eq!(error.kind(), GitQueryErrorKind::TooLarge, "{error}");
2472 assert!(!error.is_timeout(), "{error}");
2473
2474 // And the same read under a cap it fits inside is untouched.
2475 let output = run_bounded(&repo, ["rev-parse", "HEAD"], 1024)
2476 .await
2477 .expect("a sha fits in a kibibyte");
2478
2479 assert_eq!(String::from_utf8_lossy(&output.stdout).trim().len(), 40);
2480}