steid

@jamesgill /

1//! The read port's in-memory implementation, beside the disk one it stands in for.
2//!
3//! It lived in `infrastructure/git.rs` until the git adapters were split by port; the
4//! two implementations of one port belong together, which is the whole point of the
5//! two-implementation rule. `git::InMemoryGitQuery` still resolves, via a re-export.
6
7use std::{
8 collections::HashMap,
9 sync::{Arc, Mutex},
10 time::SystemTime,
11};
12
13use crate::{
14 application::{
15 blame::Blame,
16 port::{Blob, CommitCount, GitQuery, GitQueryError, MergePreview, RawDiff},
17 },
18 domain::{
19 BranchRow, CommitDetail, CommitEntry, CommitSummary, EntryCommit, GitRef, GrepHit,
20 ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
21 },
22};
23
24use super::FOLLOWED_LOG_SCAN;
25
26/// A repository's contents, held in memory, for testing use cases and pages.
27///
28/// The counterpart to `DiskGitQuery`. Seeded with exactly what a test needs rather than
29/// pretending to be a git implementation: it answers the questions the port asks and
30/// knows nothing about how a real repository stores them.
31#[derive(Debug, Default, Clone)]
32pub struct InMemoryGitQuery {
33 default_branch: Option<RefName>,
34 /// Keyed `rev\0path`, because a tree only means anything at a revision.
35 trees: HashMap<String, Vec<TreeEntry>>,
36 blobs: HashMap<String, Vec<u8>>,
37 commits: Vec<CommitSummary>,
38 /// Branches and tags, in whatever order a test seeded them — the real adapter makes
39 /// no ordering promise either.
40 refs: Vec<GitRef>,
41 /// Overrides the count derived from the seeded log, for a test that wants a
42 /// repository with more history than it wants to write out.
43 commit_count: Option<u64>,
44 latest_tag: Option<TagSummary>,
45 /// The branches and tags pages' richer rows, seeded separately from `refs`: the two
46 /// answer different questions and a test usually wants only one of them.
47 branch_rows: Vec<BranchRow>,
48 tag_rows: Vec<TagRow>,
49 /// What any grep answers with, regardless of the query — a fake that matched text
50 /// would be a second, worse implementation of `git grep`.
51 grep_hits: Vec<GrepHit>,
52 /// Makes the next grep report the read timeout, which is a page state rather than
53 /// a failure and therefore worth a test.
54 slow_grep: bool,
55 /// The one commit `commit` answers with, whatever revision it is asked for. A fake
56 /// of git's object store is not what these tests are about.
57 detail: Option<CommitDetail>,
58 /// The raw patch `diff` answers with, and whether the fake should call it truncated.
59 diff: Option<RawDiff>,
60 /// The merge base of any two commits, because a fake has no graph to walk.
61 merge_base: Option<ObjectId>,
62 /// What a merge preview answers with, or `None` for the clean default. A fake that
63 /// really merged two trees would be a second, worse `git merge-tree`; what a use
64 /// case test needs is to choose which of the two answers it gets.
65 merge_preview: Option<MergePreview>,
66 /// What a ref snapshot answers with. Seeded separately from `refs`, and mutable
67 /// through [`InMemoryGitQuery::set_ref_snapshot`], because the whole point of a
68 /// snapshot is that it differs before and after a push.
69 snapshot: Arc<Mutex<Vec<(RefName, ObjectId)>>>,
70
71 /// Keyed the same way trees and blobs are, because a blame only means anything at a
72 /// revision either.
73 blames: HashMap<String, Blame>,
74
75 /// Message bodies, keyed by commit id. Kept beside `commits` rather than inside
76 /// them so `log` and `log_entries` can never disagree about a commit's subject —
77 /// one list of commits, and a body looked up for the reading that wants one.
78 bodies: HashMap<String, String>,
79 /// The last-commit column's answers, keyed the same way trees are — the column is a
80 /// fact about a directory at a revision.
81 last_commits: HashMap<String, HashMap<String, EntryCommit>>,
82
83 /// The commits a path's own log reports, keyed the same way trees are.
84 ///
85 /// Seeded per path rather than filtered out of `commits`, because a fake with no
86 /// trees to walk cannot know which commit touched what — and a test about a
87 /// followed rename is a test about exactly that.
88 path_logs: HashMap<String, Vec<CommitSummary>>,
89
90 /// What a symlink blob points at, by the id the listing carries.
91 symlink_targets: HashMap<ObjectId, String>,
92
93 /// Revisions that resolve to something other than the default id.
94 ///
95 /// Every revision resolves to the same commit unless one is named here, which is
96 /// what makes a comparison of two revisions come back as `Identical` by default —
97 /// convenient for most tests and useless for the one that wants a real comparison.
98 resolved: HashMap<String, ObjectId>,
99}
100
101impl InMemoryGitQuery {
102 /// An empty repository: no default branch, so nothing has been pushed.
103 pub fn empty() -> Self {
104 Self::default()
105 }
106
107 /// A repository whose default branch is `main`.
108 pub fn new() -> Self {
109 Self {
110 default_branch: Some(RefName::from_trusted("main")),
111 ..Self::default()
112 }
113 }
114
115 fn key(rev: &RefName, path: &RepoPath) -> String {
116 format!("{}\0{}", rev.as_str(), path.as_str())
117 }
118
119 pub fn with_tree(mut self, rev: &str, path: &str, entries: Vec<TreeEntry>) -> Self {
120 self.trees.insert(
121 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
122 entries,
123 );
124 self
125 }
126
127 pub fn with_blob(mut self, rev: &str, path: &str, content: impl Into<Vec<u8>>) -> Self {
128 self.blobs.insert(
129 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
130 content.into(),
131 );
132 self
133 }
134
135 /// Seeds the last-commit column for one directory.
136 pub fn with_last_commits(
137 mut self,
138 rev: &str,
139 path: &str,
140 commits: Vec<(&str, EntryCommit)>,
141 ) -> Self {
142 self.last_commits.insert(
143 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
144 commits
145 .into_iter()
146 .map(|(name, commit)| (name.to_owned(), commit))
147 .collect(),
148 );
149 self
150 }
151
152 pub fn with_log(mut self, commits: Vec<CommitSummary>) -> Self {
153 self.commits = commits;
154 self
155 }
156
157 /// Seeds the log of one path at one revision.
158 pub fn with_path_log(mut self, rev: &str, path: &str, commits: Vec<CommitSummary>) -> Self {
159 self.path_logs.insert(
160 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
161 commits,
162 );
163 self
164 }
165
166 /// Seeds what a symlink blob points at.
167 pub fn with_symlink_target(mut self, id: &ObjectId, target: &str) -> Self {
168 self.symlink_targets.insert(id.clone(), target.to_owned());
169 self
170 }
171
172 pub fn with_branch(self, name: &str) -> Self {
173 self.with_ref(name, RefKind::Branch)
174 }
175
176 pub fn with_tag(self, name: &str) -> Self {
177 self.with_ref(name, RefKind::Tag)
178 }
179
180 /// Makes one revision resolve to an id of its own.
181 pub fn with_resolved(mut self, rev: &str, id: &str) -> Self {
182 self.resolved
183 .insert(rev.to_owned(), ObjectId::from_trusted(format!("{id:0>40}")));
184 self
185 }
186
187 pub fn with_commit_count(mut self, count: u64) -> Self {
188 self.commit_count = Some(count);
189 self
190 }
191
192 pub fn with_latest_tag(mut self, name: &str, created_at: SystemTime) -> Self {
193 self.latest_tag = Some(TagSummary {
194 name: RefName::from_trusted(name),
195 created_at,
196 });
197 self
198 }
199
200 /// One row of the branches page. `is_default` is what git's `%(HEAD)` marks.
201 pub fn with_branch_row(
202 mut self,
203 name: &str,
204 is_default: bool,
205 committed_at: SystemTime,
206 ) -> Self {
207 self.branch_rows.push(BranchRow {
208 name: RefName::from_trusted(name),
209 is_default,
210 commit: ObjectId::from_trusted("2".repeat(40)),
211 summary: format!("work on {name}"),
212 committed_at,
213 });
214 self
215 }
216
217 /// One row of the tags page. An annotated tag carries a message; a lightweight one
218 /// has none of its own.
219 pub fn with_tag_row(mut self, name: &str, annotated: bool, created_at: SystemTime) -> Self {
220 self.tag_rows.push(TagRow {
221 name: RefName::from_trusted(name),
222 commit: ObjectId::from_trusted("3".repeat(40)),
223 message: annotated.then(|| format!("release {name}")),
224 annotated,
225 created_at,
226 });
227 self
228 }
229
230 /// Seeds what a search finds. Order is kept, because grouping by file depends on
231 /// git's ordering and a fake that reordered would hide that.
232 pub fn with_grep_hits(mut self, hits: Vec<GrepHit>) -> Self {
233 self.grep_hits = hits;
234 self
235 }
236
237 /// A repository whose grep is killed by the timeout.
238 pub fn with_slow_grep(mut self) -> Self {
239 self.slow_grep = true;
240 self
241 }
242
243 /// Gives a seeded commit a message body, for the feed reading of a log.
244 pub fn with_commit_body(mut self, id: &str, body: &str) -> Self {
245 self.bodies.insert(id.to_owned(), body.to_owned());
246 self
247 }
248
249 pub fn with_commit(mut self, detail: CommitDetail) -> Self {
250 self.detail = Some(detail);
251 self
252 }
253
254 pub fn with_diff(mut self, patch: impl Into<Vec<u8>>, numstat: impl Into<Vec<u8>>) -> Self {
255 self.diff = Some(RawDiff {
256 numstat: numstat.into(),
257 patch: patch.into(),
258 truncated: false,
259 });
260 self
261 }
262
263 pub fn with_merge_base(mut self, id: &str) -> Self {
264 self.merge_base = Some(ObjectId::from_trusted(id));
265 self
266 }
267
268 /// Makes every merge preview report a conflict in these paths.
269 pub fn with_conflicts(mut self, paths: &[&str]) -> Self {
270 self.merge_preview = Some(MergePreview::Conflicted {
271 paths: paths.iter().map(|path| (*path).to_owned()).collect(),
272 });
273 self
274 }
275
276 pub fn with_blame(mut self, rev: &str, path: &str, blame: Blame) -> Self {
277 self.blames.insert(
278 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
279 blame,
280 );
281 self
282 }
283
284 /// Seeds the refs a snapshot reports.
285 pub fn with_ref_snapshot(self, refs: &[(&str, &str)]) -> Self {
286 self.set_ref_snapshot(refs);
287 self
288 }
289
290 /// Replaces what a snapshot reports, standing in for a push having landed.
291 ///
292 /// Takes `&self` so a test can move the refs *between* two reads of the same fake,
293 /// which is the sequence the real push path performs.
294 pub fn set_ref_snapshot(&self, refs: &[(&str, &str)]) {
295 *self.snapshot.lock().expect("lock poisoned") = refs
296 .iter()
297 .map(|(name, id)| {
298 (
299 RefName::from_trusted(*name),
300 ObjectId::from_trusted(format!("{id:0>40}")),
301 )
302 })
303 .collect();
304 }
305
306 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
307 self.refs.push(GitRef {
308 name: RefName::from_trusted(name),
309 kind,
310 });
311 self
312 }
313}
314
315impl GitQuery for InMemoryGitQuery {
316 async fn default_branch(
317 &self,
318 _handle: &OrgName,
319 _name: &RepoName,
320 ) -> Result<Option<RefName>, GitQueryError> {
321 Ok(self.default_branch.clone())
322 }
323
324 async fn resolve(
325 &self,
326 _handle: &OrgName,
327 _name: &RepoName,
328 rev: &RefName,
329 ) -> Result<Option<ObjectId>, GitQueryError> {
330 if let Some(id) = self.resolved.get(rev.as_str()) {
331 return Ok(Some(id.clone()));
332 }
333
334 Ok(self
335 .default_branch
336 .as_ref()
337 .map(|_| ObjectId::from_trusted("0".repeat(40))))
338 }
339
340 async fn list_tree(
341 &self,
342 _handle: &OrgName,
343 _name: &RepoName,
344 rev: &RefName,
345 path: &RepoPath,
346 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
347 Ok(self.trees.get(&Self::key(rev, path)).cloned())
348 }
349
350 async fn last_commits(
351 &self,
352 _handle: &OrgName,
353 _name: &RepoName,
354 rev: &RefName,
355 path: &RepoPath,
356 entries: &[String],
357 ) -> Result<HashMap<String, EntryCommit>, GitQueryError> {
358 let Some(seeded) = self.last_commits.get(&Self::key(rev, path)) else {
359 return Ok(HashMap::new());
360 };
361
362 // Filtered to what was asked about, like the real adapter: an entry that is not
363 // in the listing is not in the answer, whatever a test seeded.
364 Ok(seeded
365 .iter()
366 .filter(|(name, _)| entries.iter().any(|entry| entry == *name))
367 .map(|(name, commit)| (name.clone(), commit.clone()))
368 .collect())
369 }
370
371 async fn read_blob(
372 &self,
373 _handle: &OrgName,
374 _name: &RepoName,
375 rev: &RefName,
376 path: &RepoPath,
377 max_bytes: u64,
378 ) -> Result<Option<Blob>, GitQueryError> {
379 Ok(self.blobs.get(&Self::key(rev, path)).map(|content| {
380 let size = content.len() as u64;
381
382 Blob {
383 id: ObjectId::from_trusted("1".repeat(40)),
384 size,
385 // The same cap the real adapter applies, so a test can exercise the
386 // too-large path without a megabyte of fixture.
387 content: (size <= max_bytes).then(|| content.clone()),
388 }
389 }))
390 }
391
392 async fn log(
393 &self,
394 _handle: &OrgName,
395 _name: &RepoName,
396 _rev: &RefName,
397 offset: usize,
398 limit: usize,
399 ) -> Result<Vec<CommitSummary>, GitQueryError> {
400 Ok(self
401 .commits
402 .iter()
403 .skip(offset)
404 .take(limit)
405 .cloned()
406 .collect())
407 }
408
409 async fn log_entries(
410 &self,
411 _handle: &OrgName,
412 _name: &RepoName,
413 _rev: &RefName,
414 limit: usize,
415 ) -> Result<Vec<CommitEntry>, GitQueryError> {
416 Ok(self
417 .commits
418 .iter()
419 .take(limit)
420 .map(|commit| CommitEntry {
421 body: self
422 .bodies
423 .get(commit.id.as_str())
424 .cloned()
425 .unwrap_or_default(),
426 commit: commit.clone(),
427 })
428 .collect())
429 }
430
431 async fn list_refs(
432 &self,
433 _handle: &OrgName,
434 _name: &RepoName,
435 ) -> Result<Vec<GitRef>, GitQueryError> {
436 Ok(self.refs.clone())
437 }
438
439 async fn count_commits(
440 &self,
441 _handle: &OrgName,
442 _name: &RepoName,
443 _rev: &RefName,
444 ) -> Result<u64, GitQueryError> {
445 Ok(self.commit_count.unwrap_or(self.commits.len() as u64))
446 }
447
448 async fn latest_tag(
449 &self,
450 _handle: &OrgName,
451 _name: &RepoName,
452 ) -> Result<Option<TagSummary>, GitQueryError> {
453 Ok(self.latest_tag.clone())
454 }
455
456 async fn branches(
457 &self,
458 _handle: &OrgName,
459 _name: &RepoName,
460 ) -> Result<Vec<BranchRow>, GitQueryError> {
461 Ok(self.branch_rows.clone())
462 }
463
464 async fn tags(
465 &self,
466 _handle: &OrgName,
467 _name: &RepoName,
468 ) -> Result<Vec<TagRow>, GitQueryError> {
469 Ok(self.tag_rows.clone())
470 }
471
472 async fn grep(
473 &self,
474 _handle: &OrgName,
475 _name: &RepoName,
476 _commit: &ObjectId,
477 _query: &str,
478 limit: usize,
479 ) -> Result<Vec<GrepHit>, GitQueryError> {
480 if self.slow_grep {
481 return Err(GitQueryError::timed_out(std::time::Duration::from_secs(20)));
482 }
483
484 Ok(self.grep_hits.iter().take(limit).cloned().collect())
485 }
486
487 async fn commit(
488 &self,
489 _handle: &OrgName,
490 _name: &RepoName,
491 _rev: &RefName,
492 ) -> Result<Option<CommitDetail>, GitQueryError> {
493 Ok(self.detail.clone())
494 }
495
496 async fn diff(
497 &self,
498 _handle: &OrgName,
499 _name: &RepoName,
500 _base: Option<&ObjectId>,
501 _head: &ObjectId,
502 _max_bytes: u64,
503 ) -> Result<RawDiff, GitQueryError> {
504 Ok(self.diff.clone().unwrap_or_default())
505 }
506
507 async fn merge_base(
508 &self,
509 _handle: &OrgName,
510 _name: &RepoName,
511 _base: &ObjectId,
512 _head: &ObjectId,
513 ) -> Result<Option<ObjectId>, GitQueryError> {
514 Ok(self.merge_base.clone())
515 }
516
517 async fn log_between(
518 &self,
519 _handle: &OrgName,
520 _name: &RepoName,
521 _base: Option<&ObjectId>,
522 _head: &ObjectId,
523 offset: usize,
524 limit: usize,
525 ) -> Result<Vec<CommitSummary>, GitQueryError> {
526 Ok(self
527 .commits
528 .iter()
529 .skip(offset)
530 .take(limit)
531 .cloned()
532 .collect())
533 }
534
535 async fn count_between(
536 &self,
537 _handle: &OrgName,
538 _name: &RepoName,
539 _base: Option<&ObjectId>,
540 _head: &ObjectId,
541 ) -> Result<usize, GitQueryError> {
542 Ok(self.commits.len())
543 }
544
545 async fn blame(
546 &self,
547 _handle: &OrgName,
548 _name: &RepoName,
549 rev: &RefName,
550 path: &RepoPath,
551 ) -> Result<Option<Blame>, GitQueryError> {
552 Ok(self.blames.get(&Self::key(rev, path)).cloned())
553 }
554
555 async fn ref_snapshot(
556 &self,
557 _handle: &OrgName,
558 _name: &RepoName,
559 ) -> Result<Vec<(RefName, ObjectId)>, GitQueryError> {
560 Ok(self.snapshot.lock().expect("lock poisoned").clone())
561 }
562
563 async fn log_at_path(
564 &self,
565 _handle: &OrgName,
566 _name: &RepoName,
567 rev: &RefName,
568 path: &RepoPath,
569 _follow: bool,
570 offset: usize,
571 limit: usize,
572 ) -> Result<Vec<CommitSummary>, GitQueryError> {
573 let Some(commits) = self.path_logs.get(&Self::key(rev, path)) else {
574 return Ok(Vec::new());
575 };
576
577 Ok(commits.iter().skip(offset).take(limit).cloned().collect())
578 }
579
580 async fn count_commits_at_path(
581 &self,
582 _handle: &OrgName,
583 _name: &RepoName,
584 rev: &RefName,
585 path: &RepoPath,
586 follow: bool,
587 ) -> Result<CommitCount, GitQueryError> {
588 let counted = self
589 .path_logs
590 .get(&Self::key(rev, path))
591 .map(Vec::len)
592 .unwrap_or(0);
593
594 // The real adapter's cap, reproduced here rather than left out: a fake that
595 // never caps would let a use case test pass on a case the page cannot reach.
596 // Only a followed walk is capped, which is the adapter's rule too.
597 Ok(match follow {
598 true => CommitCount {
599 total: counted.min(FOLLOWED_LOG_SCAN) as u64,
600 capped: counted > FOLLOWED_LOG_SCAN,
601 },
602 false => CommitCount {
603 total: counted as u64,
604 capped: false,
605 },
606 })
607 }
608
609 async fn symlink_targets(
610 &self,
611 _handle: &OrgName,
612 _name: &RepoName,
613 ids: &[ObjectId],
614 ) -> Result<HashMap<ObjectId, String>, GitQueryError> {
615 Ok(ids
616 .iter()
617 .filter_map(|id| Some((id.clone(), self.symlink_targets.get(id)?.clone())))
618 .collect())
619 }
620
621 async fn merge_preview(
622 &self,
623 _handle: &OrgName,
624 _name: &RepoName,
625 _target: &ObjectId,
626 _source: &ObjectId,
627 ) -> Result<MergePreview, GitQueryError> {
628 // Clean unless a test said otherwise, which is the answer that lets a merge
629 // test be about the merge rather than about seeding a preview first.
630 Ok(self
631 .merge_preview
632 .clone()
633 .unwrap_or_else(|| MergePreview::Clean {
634 tree: ObjectId::from_trusted("7".repeat(40)),
635 }))
636 }
637}