steid

@jamesgill /

1//! The `git` binary, behind the ports the application declares.
2//!
3//! One module owns *how* Steid invokes git — see [`run_git`] — so that isolation and
4//! error handling cannot drift between call sites. Milestone 4's protocol commands
5//! belong here too rather than growing a second recipe.
6
7use std::{
8 collections::{HashMap, HashSet},
9 ffi::OsStr,
10 io,
11 path::PathBuf,
12 process::{Output, Stdio},
13 sync::{Arc, Mutex},
14 time::SystemTime,
15};
16
17use tokio::{
18 io::{AsyncBufReadExt, AsyncReadExt, BufReader},
19 process::{ChildStdout, Command},
20};
21
22use crate::{
23 application::port::{
24 ArchiveRequest, Blob, ByteStream, GitArchive, GitArchiveError, GitMethod, GitProtocolError,
25 GitProtocolServer, GitQuery, GitQueryError, GitRequest, GitResponse, GitStorage,
26 GitStorageError, RawDiff,
27 },
28 domain::{
29 BranchRow, CommitDetail, CommitSummary, GitRef, GrepHit, ObjectId, OrgName, RefKind,
30 RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
31 },
32};
33
34/// The most CGI headers `git http-backend` will ever emit, with room to spare.
35///
36/// A guard rather than a real expectation: the header block is read before anything is
37/// streamed, and an unbounded read of a subprocess's stdout is a hang waiting to happen.
38const MAX_CGI_HEADERS: usize = 64;
39
40/// Environment variables that redirect where git reads and writes data.
41///
42/// Steid's own environment must not reach into a repository's layout. These are set
43/// whenever a process is spawned from inside a git hook, which is exactly the shape
44/// Milestone 5 will have, and the failure is silent — objects land somewhere else and
45/// the repository looks empty.
46const REDIRECTING_VARS: &[&str] = &[
47 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
48 "GIT_DIR",
49 "GIT_INDEX_FILE",
50 "GIT_OBJECT_DIRECTORY",
51 "GIT_WORK_TREE",
52];
53
54/// Bare repositories on disk, laid out as `{data_dir}/{handle}/{name}.git`.
55#[derive(Debug, Clone)]
56pub struct DiskGitStorage {
57 data_dir: PathBuf,
58}
59
60impl DiskGitStorage {
61 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
62 Self {
63 data_dir: data_dir.into(),
64 }
65 }
66}
67
68impl GitStorage for DiskGitStorage {
69 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
70 let path = self.repo_path(handle, name);
71
72 // `git init` on an existing repository exits 0 and re-initialises in silence,
73 // so this check has to be ours. A directory with no matching record is an
74 // orphan from a create that died between the two writes; adopting it would
75 // resurface a private repository's objects under a fresh record.
76 if path.exists() {
77 return Err(GitStorageError::AlreadyExists);
78 }
79
80 // No `create_dir_all` for the parent: `git init` creates missing directories.
81 run_git([
82 OsStr::new("init"),
83 OsStr::new("--bare"),
84 OsStr::new("--quiet"),
85 // Skip the template directory, which otherwise seeds every repository with
86 // sixteen `.sample` hooks. Steid installs its own hooks later, and they
87 // would be noise to work around.
88 OsStr::new("--template="),
89 // Explicit, so the host's `init.defaultBranch` cannot decide what the
90 // default branch of a Steid repository is.
91 OsStr::new("--initial-branch=main"),
92 // `RepoName` already forbids a leading hyphen; this makes it impossible for
93 // a path to be read as a flag at the boundary where it costs nothing.
94 OsStr::new("--"),
95 path.as_os_str(),
96 ])
97 .await
98 .map(|_| ())
99 }
100
101 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
102 let path = self.repo_path(handle, name);
103
104 // `tokio::fs` rather than `std::fs`: removing a repository with real history
105 // walks every loose object, which is long enough to stall a runtime worker.
106 match tokio::fs::remove_dir_all(&path).await {
107 Ok(()) => Ok(()),
108 // Compensation must not fail because there was nothing left to undo.
109 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
110 Err(error) => Err(GitStorageError::backend(error)),
111 }
112 }
113
114 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
115 // Nothing is sanitised here. `OrgName` and `RepoName` already made traversal
116 // impossible, and re-checking at the call site is how that responsibility gets
117 // diffused until nobody owns it.
118 self.data_dir
119 .join(handle.as_str())
120 .join(format!("{name}.git"))
121 }
122}
123
124/// A `git` command isolated from the host.
125///
126/// The one place that decides what git inherits: no ambient configuration, no
127/// redirected object storage. Both the lifecycle commands and the protocol backend
128/// build on this, which is the point — 0006 exists because these flags are exactly what
129/// drifts silently between call sites.
130pub(crate) fn git_command() -> Command {
131 let mut command = Command::new("git");
132
133 // Host configuration must not leak into repositories Steid creates, for the same
134 // reason `--initial-branch` is passed explicitly.
135 command
136 .env("GIT_CONFIG_GLOBAL", "/dev/null")
137 .env("GIT_CONFIG_SYSTEM", "/dev/null");
138
139 for variable in REDIRECTING_VARS {
140 command.env_remove(variable);
141 }
142
143 command
144}
145
146/// Runs `git` and fails on a non-zero exit.
147///
148/// The single place that decides how Steid invokes git, so every call site gets the
149/// same isolation from the host: no ambient configuration, no redirected object
150/// storage, no inherited stdin. Never depends on the working directory — callers pass
151/// absolute paths.
152async fn run_git<I, S>(args: I) -> Result<Output, GitStorageError>
153where
154 I: IntoIterator<Item = S>,
155 S: AsRef<OsStr>,
156{
157 let mut command = git_command();
158 command.args(args).stdin(Stdio::null());
159
160 // `output()` pipes stdout and stderr and waits without blocking the runtime.
161 let output = command
162 .output()
163 .await
164 .map_err(|error| GitStorageError::backend(format!("could not run git: {error}")))?;
165
166 if !output.status.success() {
167 // Carry git's own words. "command failed" sends the next person to read this
168 // code instead of reading the error.
169 return Err(GitStorageError::backend(format!(
170 "git exited with {}: {}",
171 output.status,
172 String::from_utf8_lossy(&output.stderr).trim()
173 )));
174 }
175
176 Ok(output)
177}
178
179/// Bare repositories tracked in memory, for testing use cases without touching disk.
180///
181/// The counterpart to [`DiskGitStorage`], the way `StubHasher` is the counterpart to
182/// the real Argon2 hasher. It enforces the same `AlreadyExists` rule, because a use
183/// case that only passes against a permissive fake proves nothing about the real one.
184#[derive(Debug, Default, Clone)]
185pub struct InMemoryGitStorage {
186 created: Arc<Mutex<HashSet<PathBuf>>>,
187}
188
189impl InMemoryGitStorage {
190 pub fn new() -> Self {
191 Self::default()
192 }
193
194 /// Whether a repository exists, for assertions.
195 pub fn contains(&self, handle: &OrgName, name: &RepoName) -> bool {
196 self.created
197 .lock()
198 .expect("lock poisoned")
199 .contains(&self.repo_path(handle, name))
200 }
201
202 /// How many repositories exist, for asserting that nothing was created.
203 pub fn len(&self) -> usize {
204 self.created.lock().expect("lock poisoned").len()
205 }
206
207 pub fn is_empty(&self) -> bool {
208 self.len() == 0
209 }
210}
211
212impl GitStorage for InMemoryGitStorage {
213 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
214 let mut created = self.created.lock().expect("lock poisoned");
215
216 if !created.insert(self.repo_path(handle, name)) {
217 return Err(GitStorageError::AlreadyExists);
218 }
219
220 Ok(())
221 }
222
223 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
224 self.created
225 .lock()
226 .expect("lock poisoned")
227 .remove(&self.repo_path(handle, name));
228
229 Ok(())
230 }
231
232 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
233 PathBuf::from("/in-memory")
234 .join(handle.as_str())
235 .join(format!("{name}.git"))
236 }
237}
238
239/// The git smart-HTTP protocol, served by `git http-backend`.
240///
241/// The binary is a CGI: it takes an environment and a request body on stdin, and writes
242/// CRLF-terminated headers, a blank line, then the response body. Its contract was
243/// probed rather than assumed — see `plans/progress.md` under Milestone 4a.
244#[derive(Debug, Clone)]
245pub struct GitHttpBackend {
246 data_dir: PathBuf,
247}
248
249impl GitHttpBackend {
250 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
251 Self {
252 data_dir: data_dir.into(),
253 }
254 }
255}
256
257impl GitProtocolServer for GitHttpBackend {
258 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
259 let mut command = git_command();
260
261 // Before the subcommand: `git -c … http-backend`. Pushes are refused by the
262 // backend unless this says otherwise, and it is set only for a request the use
263 // case already authorized — so a bug in Steid's rules meets git's refusal rather
264 // than an open door.
265 if request.allow_receive_pack {
266 command.arg("-c").arg("http.receivepack=true");
267 }
268
269 command
270 .arg("http-backend")
271 .env("GIT_PROJECT_ROOT", &self.data_dir)
272 // Steid decides visibility from the `repositories` table, in the use case.
273 // Without this, git applies its own rule and refuses everything lacking a
274 // `git-daemon-export-ok` marker file — a second source of truth for the same
275 // question, free to drift from the first.
276 .env("GIT_HTTP_EXPORT_ALL", "1")
277 .env("PATH_INFO", &request.path_info)
278 .env("QUERY_STRING", &request.query)
279 .env("REQUEST_METHOD", request.method.as_str())
280 .stdin(Stdio::piped())
281 .stdout(Stdio::piped())
282 .stderr(Stdio::piped());
283
284 // CGI gives only Content-Type and Content-Length unprefixed names; every other
285 // request header arrives `HTTP_`-prefixed. Passing `CONTENT_ENCODING` instead of
286 // `HTTP_CONTENT_ENCODING` makes the backend hand a still-gzipped body to
287 // upload-pack, and the client reports `expected 'packfile'` with nothing naming
288 // the cause. Measured, not guessed.
289 for (variable, value) in [
290 ("CONTENT_TYPE", &request.content_type),
291 ("CONTENT_LENGTH", &request.content_length),
292 ("HTTP_CONTENT_ENCODING", &request.content_encoding),
293 ("HTTP_GIT_PROTOCOL", &request.git_protocol),
294 ] {
295 if let Some(value) = value {
296 command.env(variable, value);
297 }
298 }
299
300 let mut child = command
301 .spawn()
302 .map_err(|error| GitProtocolError::new(format!("could not run git: {error}")))?;
303
304 let mut stdin = child.stdin.take().expect("stdin was piped");
305 let stdout = child.stdout.take().expect("stdout was piped");
306 let mut stderr = child.stderr.take().expect("stderr was piped");
307 let mut body = request.body;
308
309 // The request body streams in while the response streams out; a push is far too
310 // large to buffer, and a fetch would otherwise wait for a body it already has.
311 // Dropping stdin closes the pipe, which is what tells the backend the request is
312 // complete — an error here is the client having gone away, which the backend
313 // then sees as EOF.
314 tokio::spawn(async move {
315 let _ = tokio::io::copy(&mut body, &mut stdin).await;
316 });
317
318 // Reaps the child and surfaces its complaint. This cannot gate the response: a
319 // protocol failure exits non-zero *after* a complete, successful-looking header
320 // block has already been written, so by the time the status is known it has been
321 // sent. Draining stderr is not optional either — an unread pipe fills and blocks
322 // the backend mid-transfer.
323 tokio::spawn(async move {
324 let mut complaint = String::new();
325 let _ = stderr.read_to_string(&mut complaint).await;
326
327 match child.wait().await {
328 Ok(status) if status.success() => {}
329 Ok(status) => eprintln!(
330 "steid: git http-backend exited with {status}: {}",
331 complaint.trim()
332 ),
333 Err(error) => eprintln!("steid: could not wait for git http-backend: {error}"),
334 }
335 });
336
337 // `BufReader` keeps whatever it read past the header block, and handing the
338 // reader itself back as the body is what makes that safe — the first bytes of
339 // the pack are already buffered inside it.
340 let mut reader = BufReader::new(stdout);
341 let (status, headers) = read_cgi_headers(&mut reader).await?;
342
343 Ok(GitResponse {
344 status,
345 headers,
346 body: Box::pin(reader),
347 })
348 }
349}
350
351/// Reads the CGI header block, stopping at the blank line that ends it.
352///
353/// `Status:` is git's way of reporting failure and appears only then, so its absence
354/// means 200. It is translated into the response status rather than forwarded as a
355/// header, which would be meaningless to a client.
356async fn read_cgi_headers(
357 reader: &mut BufReader<ChildStdout>,
358) -> Result<(u16, Vec<(String, String)>), GitProtocolError> {
359 let mut status = 200;
360 let mut headers = Vec::new();
361 let mut line = Vec::new();
362
363 loop {
364 line.clear();
365
366 let read = reader
367 .read_until(b'\n', &mut line)
368 .await
369 .map_err(|error| GitProtocolError::new(format!("reading git's headers: {error}")))?;
370
371 if read == 0 {
372 return Err(GitProtocolError::new(
373 "git http-backend produced no headers before closing",
374 ));
375 }
376
377 // Tolerates a bare LF as well as the CRLF actually observed: a header reader
378 // that hangs on an unexpected line ending is a bad way to find out.
379 let text = String::from_utf8_lossy(&line);
380 let text = text.trim_end_matches(['\r', '\n']);
381
382 if text.is_empty() {
383 return Ok((status, headers));
384 }
385
386 let Some((name, value)) = text.split_once(": ") else {
387 return Err(GitProtocolError::new(format!(
388 "git http-backend wrote an unparseable header: {text:?}"
389 )));
390 };
391
392 if name.eq_ignore_ascii_case("status") {
393 status = value
394 .split_whitespace()
395 .next()
396 .and_then(|code| code.parse().ok())
397 .ok_or_else(|| {
398 GitProtocolError::new(format!("git http-backend wrote a bad status: {value:?}"))
399 })?;
400 } else {
401 headers.push((name.to_owned(), value.to_owned()));
402 }
403
404 if headers.len() > MAX_CGI_HEADERS {
405 return Err(GitProtocolError::new(
406 "git http-backend wrote more headers than a CGI response can plausibly have",
407 ));
408 }
409 }
410}
411
412/// What a [`InMemoryGitProtocol`] was asked for, minus the body.
413///
414/// The body is a stream and comparing it would mean draining it; every rule worth
415/// asserting on lives in the metadata anyway.
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub struct RecordedGitRequest {
418 pub method: GitMethod,
419 pub path_info: String,
420 pub query: String,
421 pub git_protocol: Option<String>,
422 pub content_encoding: Option<String>,
423 pub allow_receive_pack: bool,
424}
425
426/// A git protocol that records what it was asked and never runs git.
427///
428/// The counterpart to [`GitHttpBackend`]. What it is really for is proving a negative:
429/// that a use case refused *before* reaching the protocol. `was_called` is how a test
430/// says "and no bytes flowed".
431#[derive(Debug, Default, Clone)]
432pub struct InMemoryGitProtocol {
433 requests: Arc<Mutex<Vec<RecordedGitRequest>>>,
434}
435
436impl InMemoryGitProtocol {
437 pub fn new() -> Self {
438 Self::default()
439 }
440
441 pub fn requests(&self) -> Vec<RecordedGitRequest> {
442 self.requests.lock().expect("lock poisoned").clone()
443 }
444
445 /// Whether the protocol was reached at all.
446 pub fn was_called(&self) -> bool {
447 !self.requests.lock().expect("lock poisoned").is_empty()
448 }
449}
450
451impl GitProtocolServer for InMemoryGitProtocol {
452 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
453 self.requests
454 .lock()
455 .expect("lock poisoned")
456 .push(RecordedGitRequest {
457 method: request.method,
458 path_info: request.path_info,
459 query: request.query,
460 git_protocol: request.git_protocol,
461 content_encoding: request.content_encoding,
462 allow_receive_pack: request.allow_receive_pack,
463 });
464
465 Ok(GitResponse {
466 status: 200,
467 headers: vec![(
468 "Content-Type".to_owned(),
469 "application/x-git-upload-pack-advertisement".to_owned(),
470 )],
471 body: Box::pin(std::io::Cursor::new(b"0000".to_vec())),
472 })
473 }
474}
475
476/// `git archive`, streamed.
477///
478/// Beside the protocol server rather than beside the read queries because it has the
479/// protocol's shape, not theirs: the output is as large as the repository and is handed
480/// on as it arrives. `DiskGitQuery`'s contract — bounded bytes, collected — is exactly
481/// what an archive must not do.
482#[derive(Debug, Clone)]
483pub struct DiskGitArchive {
484 data_dir: PathBuf,
485}
486
487impl DiskGitArchive {
488 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
489 Self {
490 data_dir: data_dir.into(),
491 }
492 }
493
494 /// Where a repository lives, matching `DiskGitStorage`'s layout.
495 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
496 self.data_dir
497 .join(handle.as_str())
498 .join(format!("{name}.git"))
499 }
500}
501
502impl GitArchive for DiskGitArchive {
503 async fn archive(&self, request: ArchiveRequest) -> Result<ByteStream, GitArchiveError> {
504 let repo = self.repo_path(&request.handle, &request.name);
505
506 // Both values are the use case's own construction — a resolved object id and a
507 // prefix built from the repository's name — so nothing from a URL reaches git's
508 // revision parser here. They are still written `--flag=value` rather than as
509 // separate arguments, so neither can be read as a flag of its own.
510 let mut command = git_command();
511 command
512 .arg("-C")
513 .arg(&repo)
514 .arg("archive")
515 .arg(format!("--format={}", request.format.as_str()))
516 .arg(format!("--prefix={}", request.prefix))
517 .arg(request.commit.as_str())
518 .stdin(Stdio::null())
519 .stdout(Stdio::piped())
520 .stderr(Stdio::piped())
521 // A client that gives up mid-download must take the packing with it;
522 // without this, git finishes compressing a repository for nobody.
523 .kill_on_drop(true);
524
525 let mut child = command
526 .spawn()
527 .map_err(|error| GitArchiveError::new(format!("could not run git: {error}")))?;
528
529 let stdout = child.stdout.take().expect("stdout was piped");
530 let mut stderr = child.stderr.take().expect("stderr was piped");
531
532 // The child is reaped in its own task, exactly as the protocol server's is, and
533 // for the same two reasons: dropping a `Child` with `kill_on_drop` would kill
534 // the process the response body is still reading from, and an unread stderr pipe
535 // eventually fills and stalls the transfer. The exit status cannot gate the
536 // response — by the time it is known, bytes have been sent — so a late failure
537 // is a truncated download and a line in the log.
538 tokio::spawn(async move {
539 let mut complaint = String::new();
540 let _ = stderr.read_to_string(&mut complaint).await;
541
542 match child.wait().await {
543 Ok(status) if status.success() => {}
544 Ok(status) => {
545 eprintln!(
546 "steid: git archive exited with {status}: {}",
547 complaint.trim()
548 )
549 }
550 Err(error) => eprintln!("steid: could not wait for git archive: {error}"),
551 }
552 });
553
554 Ok(Box::pin(stdout))
555 }
556}
557
558/// What an [`InMemoryGitArchive`] was asked for.
559///
560/// The request minus nothing: unlike a protocol request there is no body, so every
561/// field is worth asserting on.
562#[derive(Debug, Clone, PartialEq, Eq)]
563pub struct RecordedArchive {
564 pub handle: OrgName,
565 pub name: RepoName,
566 pub format: crate::application::port::ArchiveFormat,
567 pub commit: ObjectId,
568 pub prefix: String,
569}
570
571/// An archive port that records what it was asked and never runs git.
572///
573/// Like [`InMemoryGitProtocol`], what it is really for is proving a negative: that a
574/// use case refused before a single byte was packed.
575#[derive(Debug, Default, Clone)]
576pub struct InMemoryGitArchive {
577 requests: Arc<Mutex<Vec<RecordedArchive>>>,
578}
579
580impl InMemoryGitArchive {
581 pub fn new() -> Self {
582 Self::default()
583 }
584
585 pub fn requests(&self) -> Vec<RecordedArchive> {
586 self.requests.lock().expect("lock poisoned").clone()
587 }
588
589 /// Whether anything was ever packed.
590 pub fn was_called(&self) -> bool {
591 !self.requests.lock().expect("lock poisoned").is_empty()
592 }
593}
594
595impl GitArchive for InMemoryGitArchive {
596 async fn archive(&self, request: ArchiveRequest) -> Result<ByteStream, GitArchiveError> {
597 self.requests
598 .lock()
599 .expect("lock poisoned")
600 .push(RecordedArchive {
601 handle: request.handle,
602 name: request.name,
603 format: request.format,
604 commit: request.commit,
605 prefix: request.prefix,
606 });
607
608 Ok(Box::pin(std::io::Cursor::new(b"archive".to_vec())))
609 }
610}
611
612/// A repository's contents, held in memory, for testing use cases and pages.
613///
614/// The counterpart to `DiskGitQuery`. Seeded with exactly what a test needs rather than
615/// pretending to be a git implementation: it answers the questions the port asks and
616/// knows nothing about how a real repository stores them.
617#[derive(Debug, Default, Clone)]
618pub struct InMemoryGitQuery {
619 default_branch: Option<RefName>,
620 /// Keyed `rev\0path`, because a tree only means anything at a revision.
621 trees: HashMap<String, Vec<TreeEntry>>,
622 blobs: HashMap<String, Vec<u8>>,
623 commits: Vec<CommitSummary>,
624 /// Branches and tags, in whatever order a test seeded them — the real adapter makes
625 /// no ordering promise either.
626 refs: Vec<GitRef>,
627 /// Overrides the count derived from the seeded log, for a test that wants a
628 /// repository with more history than it wants to write out.
629 commit_count: Option<u64>,
630 latest_tag: Option<TagSummary>,
631 /// The branches and tags pages' richer rows, seeded separately from `refs`: the two
632 /// answer different questions and a test usually wants only one of them.
633 branch_rows: Vec<BranchRow>,
634 tag_rows: Vec<TagRow>,
635 /// What any grep answers with, regardless of the query — a fake that matched text
636 /// would be a second, worse implementation of `git grep`.
637 grep_hits: Vec<GrepHit>,
638 /// Makes the next grep report the read timeout, which is a page state rather than
639 /// a failure and therefore worth a test.
640 slow_grep: bool,
641 /// The one commit `commit` answers with, whatever revision it is asked for. A fake
642 /// of git's object store is not what these tests are about.
643 detail: Option<CommitDetail>,
644 /// The raw patch `diff` answers with, and whether the fake should call it truncated.
645 diff: Option<RawDiff>,
646 /// The merge base of any two commits, because a fake has no graph to walk.
647 merge_base: Option<ObjectId>,
648}
649
650impl InMemoryGitQuery {
651 /// An empty repository: no default branch, so nothing has been pushed.
652 pub fn empty() -> Self {
653 Self::default()
654 }
655
656 /// A repository whose default branch is `main`.
657 pub fn new() -> Self {
658 Self {
659 default_branch: Some(RefName::from_trusted("main")),
660 ..Self::default()
661 }
662 }
663
664 fn key(rev: &RefName, path: &RepoPath) -> String {
665 format!("{}\0{}", rev.as_str(), path.as_str())
666 }
667
668 pub fn with_tree(mut self, rev: &str, path: &str, entries: Vec<TreeEntry>) -> Self {
669 self.trees.insert(
670 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
671 entries,
672 );
673 self
674 }
675
676 pub fn with_blob(mut self, rev: &str, path: &str, content: impl Into<Vec<u8>>) -> Self {
677 self.blobs.insert(
678 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
679 content.into(),
680 );
681 self
682 }
683
684 pub fn with_log(mut self, commits: Vec<CommitSummary>) -> Self {
685 self.commits = commits;
686 self
687 }
688
689 pub fn with_branch(self, name: &str) -> Self {
690 self.with_ref(name, RefKind::Branch)
691 }
692
693 pub fn with_tag(self, name: &str) -> Self {
694 self.with_ref(name, RefKind::Tag)
695 }
696
697 pub fn with_commit_count(mut self, count: u64) -> Self {
698 self.commit_count = Some(count);
699 self
700 }
701
702 pub fn with_latest_tag(mut self, name: &str, created_at: SystemTime) -> Self {
703 self.latest_tag = Some(TagSummary {
704 name: RefName::from_trusted(name),
705 created_at,
706 });
707 self
708 }
709
710 /// One row of the branches page. `is_default` is what git's `%(HEAD)` marks.
711 pub fn with_branch_row(
712 mut self,
713 name: &str,
714 is_default: bool,
715 committed_at: SystemTime,
716 ) -> Self {
717 self.branch_rows.push(BranchRow {
718 name: RefName::from_trusted(name),
719 is_default,
720 commit: ObjectId::from_trusted("2".repeat(40)),
721 summary: format!("work on {name}"),
722 committed_at,
723 });
724 self
725 }
726
727 /// One row of the tags page. An annotated tag carries a message; a lightweight one
728 /// has none of its own.
729 pub fn with_tag_row(mut self, name: &str, annotated: bool, created_at: SystemTime) -> Self {
730 self.tag_rows.push(TagRow {
731 name: RefName::from_trusted(name),
732 commit: ObjectId::from_trusted("3".repeat(40)),
733 message: annotated.then(|| format!("release {name}")),
734 annotated,
735 created_at,
736 });
737 self
738 }
739
740 /// Seeds what a search finds. Order is kept, because grouping by file depends on
741 /// git's ordering and a fake that reordered would hide that.
742 pub fn with_grep_hits(mut self, hits: Vec<GrepHit>) -> Self {
743 self.grep_hits = hits;
744 self
745 }
746
747 /// A repository whose grep is killed by the timeout.
748 pub fn with_slow_grep(mut self) -> Self {
749 self.slow_grep = true;
750 self
751 }
752
753 pub fn with_commit(mut self, detail: CommitDetail) -> Self {
754 self.detail = Some(detail);
755 self
756 }
757
758 pub fn with_diff(mut self, patch: impl Into<Vec<u8>>, numstat: impl Into<Vec<u8>>) -> Self {
759 self.diff = Some(RawDiff {
760 numstat: numstat.into(),
761 patch: patch.into(),
762 truncated: false,
763 });
764 self
765 }
766
767 pub fn with_merge_base(mut self, id: &str) -> Self {
768 self.merge_base = Some(ObjectId::from_trusted(id));
769 self
770 }
771
772 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
773 self.refs.push(GitRef {
774 name: RefName::from_trusted(name),
775 kind,
776 });
777 self
778 }
779}
780
781impl GitQuery for InMemoryGitQuery {
782 async fn default_branch(
783 &self,
784 _handle: &OrgName,
785 _name: &RepoName,
786 ) -> Result<Option<RefName>, GitQueryError> {
787 Ok(self.default_branch.clone())
788 }
789
790 async fn resolve(
791 &self,
792 _handle: &OrgName,
793 _name: &RepoName,
794 _rev: &RefName,
795 ) -> Result<Option<ObjectId>, GitQueryError> {
796 Ok(self
797 .default_branch
798 .as_ref()
799 .map(|_| ObjectId::from_trusted("0".repeat(40))))
800 }
801
802 async fn list_tree(
803 &self,
804 _handle: &OrgName,
805 _name: &RepoName,
806 rev: &RefName,
807 path: &RepoPath,
808 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
809 Ok(self.trees.get(&Self::key(rev, path)).cloned())
810 }
811
812 async fn read_blob(
813 &self,
814 _handle: &OrgName,
815 _name: &RepoName,
816 rev: &RefName,
817 path: &RepoPath,
818 max_bytes: u64,
819 ) -> Result<Option<Blob>, GitQueryError> {
820 Ok(self.blobs.get(&Self::key(rev, path)).map(|content| {
821 let size = content.len() as u64;
822
823 Blob {
824 id: ObjectId::from_trusted("1".repeat(40)),
825 size,
826 // The same cap the real adapter applies, so a test can exercise the
827 // too-large path without a megabyte of fixture.
828 content: (size <= max_bytes).then(|| content.clone()),
829 }
830 }))
831 }
832
833 async fn log(
834 &self,
835 _handle: &OrgName,
836 _name: &RepoName,
837 _rev: &RefName,
838 limit: usize,
839 ) -> Result<Vec<CommitSummary>, GitQueryError> {
840 Ok(self.commits.iter().take(limit).cloned().collect())
841 }
842
843 async fn list_refs(
844 &self,
845 _handle: &OrgName,
846 _name: &RepoName,
847 ) -> Result<Vec<GitRef>, GitQueryError> {
848 Ok(self.refs.clone())
849 }
850
851 async fn count_commits(
852 &self,
853 _handle: &OrgName,
854 _name: &RepoName,
855 _rev: &RefName,
856 ) -> Result<u64, GitQueryError> {
857 Ok(self.commit_count.unwrap_or(self.commits.len() as u64))
858 }
859
860 async fn latest_tag(
861 &self,
862 _handle: &OrgName,
863 _name: &RepoName,
864 ) -> Result<Option<TagSummary>, GitQueryError> {
865 Ok(self.latest_tag.clone())
866 }
867
868 async fn branches(
869 &self,
870 _handle: &OrgName,
871 _name: &RepoName,
872 ) -> Result<Vec<BranchRow>, GitQueryError> {
873 Ok(self.branch_rows.clone())
874 }
875
876 async fn tags(
877 &self,
878 _handle: &OrgName,
879 _name: &RepoName,
880 ) -> Result<Vec<TagRow>, GitQueryError> {
881 Ok(self.tag_rows.clone())
882 }
883
884 async fn grep(
885 &self,
886 _handle: &OrgName,
887 _name: &RepoName,
888 _commit: &ObjectId,
889 _query: &str,
890 limit: usize,
891 ) -> Result<Vec<GrepHit>, GitQueryError> {
892 if self.slow_grep {
893 return Err(GitQueryError::timed_out(std::time::Duration::from_secs(20)));
894 }
895
896 Ok(self.grep_hits.iter().take(limit).cloned().collect())
897 }
898
899 async fn commit(
900 &self,
901 _handle: &OrgName,
902 _name: &RepoName,
903 _rev: &RefName,
904 ) -> Result<Option<CommitDetail>, GitQueryError> {
905 Ok(self.detail.clone())
906 }
907
908 async fn diff(
909 &self,
910 _handle: &OrgName,
911 _name: &RepoName,
912 _base: Option<&ObjectId>,
913 _head: &ObjectId,
914 _max_bytes: u64,
915 ) -> Result<RawDiff, GitQueryError> {
916 Ok(self.diff.clone().unwrap_or_default())
917 }
918
919 async fn merge_base(
920 &self,
921 _handle: &OrgName,
922 _name: &RepoName,
923 _base: &ObjectId,
924 _head: &ObjectId,
925 ) -> Result<Option<ObjectId>, GitQueryError> {
926 Ok(self.merge_base.clone())
927 }
928
929 async fn log_between(
930 &self,
931 _handle: &OrgName,
932 _name: &RepoName,
933 _base: Option<&ObjectId>,
934 _head: &ObjectId,
935 limit: usize,
936 ) -> Result<Vec<CommitSummary>, GitQueryError> {
937 Ok(self.commits.iter().take(limit).cloned().collect())
938 }
939}
940
941#[cfg(test)]
942mod tests {
943 use std::path::Path;
944
945 use tempfile::TempDir;
946
947 use tokio::io::AsyncReadExt;
948
949 use super::*;
950 use crate::application::port::ArchiveFormat;
951
952 /// The `TempDir` is returned alongside the storage because dropping it deletes the
953 /// data directory — binding it to `_` would remove the fixture mid-test.
954 fn storage() -> (TempDir, DiskGitStorage) {
955 let dir = TempDir::new().expect("temp dir");
956 let storage = DiskGitStorage::new(dir.path());
957 (dir, storage)
958 }
959
960 fn handle() -> OrgName {
961 OrgName::new("jamesgill").expect("valid handle")
962 }
963
964 fn repo_name(value: &str) -> RepoName {
965 RepoName::new(value).expect("valid repository name")
966 }
967
968 /// Asks git about a repository, so assertions test what git believes rather than
969 /// what the directory looks like.
970 fn git_says(path: &Path, args: &[&str]) -> String {
971 let output = std::process::Command::new("git")
972 .arg("-C")
973 .arg(path)
974 .args(args)
975 .output()
976 .expect("git should be on PATH");
977
978 assert!(
979 output.status.success(),
980 "git {args:?} failed: {}",
981 String::from_utf8_lossy(&output.stderr)
982 );
983
984 String::from_utf8_lossy(&output.stdout).trim().to_owned()
985 }
986
987 #[tokio::test]
988 async fn init_bare_creates_a_bare_repository() {
989 let (_dir, storage) = storage();
990
991 storage
992 .init_bare(&handle(), &repo_name("steid"))
993 .await
994 .expect("should create");
995
996 let path = storage.repo_path(&handle(), &repo_name("steid"));
997 assert!(path.is_dir(), "expected a repository at {path:?}");
998 assert_eq!(
999 git_says(&path, &["rev-parse", "--is-bare-repository"]),
1000 "true"
1001 );
1002 }
1003
1004 #[tokio::test]
1005 async fn a_new_repository_is_empty() {
1006 // Empty, like GitHub: no initial commit and no branch yet.
1007 let (_dir, storage) = storage();
1008 storage
1009 .init_bare(&handle(), &repo_name("steid"))
1010 .await
1011 .expect("should create");
1012
1013 let path = storage.repo_path(&handle(), &repo_name("steid"));
1014
1015 assert_eq!(git_says(&path, &["for-each-ref"]), "");
1016 }
1017
1018 #[tokio::test]
1019 async fn a_new_repository_defaults_to_main() {
1020 // Pinned so the host's `init.defaultBranch` cannot decide this. It currently
1021 // agrees on this machine, which is exactly why a drift would go unnoticed.
1022 let (_dir, storage) = storage();
1023 storage
1024 .init_bare(&handle(), &repo_name("steid"))
1025 .await
1026 .expect("should create");
1027
1028 let path = storage.repo_path(&handle(), &repo_name("steid"));
1029
1030 assert_eq!(
1031 git_says(&path, &["symbolic-ref", "HEAD"]),
1032 "refs/heads/main"
1033 );
1034 }
1035
1036 #[tokio::test]
1037 async fn no_sample_hooks_are_installed() {
1038 // Pins `--template=`. A default init seeds sixteen `.sample` files.
1039 let (_dir, storage) = storage();
1040 storage
1041 .init_bare(&handle(), &repo_name("steid"))
1042 .await
1043 .expect("should create");
1044
1045 let hooks = storage
1046 .repo_path(&handle(), &repo_name("steid"))
1047 .join("hooks");
1048
1049 let samples = std::fs::read_dir(&hooks)
1050 .map(|entries| entries.count())
1051 .unwrap_or(0);
1052 assert_eq!(samples, 0, "expected no templated hooks in {hooks:?}");
1053 }
1054
1055 #[tokio::test]
1056 async fn init_bare_creates_the_handle_directory() {
1057 let (dir, storage) = storage();
1058 assert!(!dir.path().join("jamesgill").exists());
1059
1060 storage
1061 .init_bare(&handle(), &repo_name("steid"))
1062 .await
1063 .expect("should create");
1064
1065 assert!(dir.path().join("jamesgill").is_dir());
1066 }
1067
1068 #[tokio::test]
1069 async fn one_handle_can_own_several_repositories() {
1070 let (_dir, storage) = storage();
1071
1072 for name in ["steid", "foo.js", ".github"] {
1073 storage
1074 .init_bare(&handle(), &repo_name(name))
1075 .await
1076 .unwrap_or_else(|error| panic!("{name} should create: {error}"));
1077 }
1078
1079 for name in ["steid", "foo.js", ".github"] {
1080 assert!(storage.repo_path(&handle(), &repo_name(name)).is_dir());
1081 }
1082 }
1083
1084 #[tokio::test]
1085 async fn init_bare_refuses_a_repository_that_already_exists() {
1086 let (_dir, storage) = storage();
1087 storage
1088 .init_bare(&handle(), &repo_name("steid"))
1089 .await
1090 .expect("should create");
1091
1092 let error = storage
1093 .init_bare(&handle(), &repo_name("steid"))
1094 .await
1095 .expect_err("should refuse");
1096
1097 assert!(matches!(error, GitStorageError::AlreadyExists));
1098 }
1099
1100 #[tokio::test]
1101 async fn a_refused_init_leaves_the_existing_repository_alone() {
1102 // git would happily re-initialise in place. The point of refusing is that
1103 // whatever is already there is not touched.
1104 let (_dir, storage) = storage();
1105 storage
1106 .init_bare(&handle(), &repo_name("steid"))
1107 .await
1108 .expect("should create");
1109
1110 let path = storage.repo_path(&handle(), &repo_name("steid"));
1111 let marker = path.join("objects").join("marker");
1112 std::fs::write(&marker, b"existing data").expect("write marker");
1113
1114 let _ = storage.init_bare(&handle(), &repo_name("steid")).await;
1115
1116 assert_eq!(
1117 std::fs::read(&marker).expect("marker should survive"),
1118 b"existing data"
1119 );
1120 }
1121
1122 #[tokio::test]
1123 async fn repo_path_creates_nothing() {
1124 let (dir, storage) = storage();
1125
1126 let path = storage.repo_path(&handle(), &repo_name("never-created"));
1127
1128 assert!(!path.exists());
1129 assert_eq!(
1130 std::fs::read_dir(dir.path())
1131 .expect("data dir should exist")
1132 .count(),
1133 0,
1134 "repo_path must be pure"
1135 );
1136 }
1137
1138 #[tokio::test]
1139 async fn repo_path_lands_under_the_data_directory() {
1140 let (dir, storage) = storage();
1141
1142 let path = storage.repo_path(&handle(), &repo_name("steid"));
1143
1144 assert_eq!(path, dir.path().join("jamesgill").join("steid.git"));
1145 }
1146
1147 #[tokio::test]
1148 async fn remove_deletes_the_repository() {
1149 let (_dir, storage) = storage();
1150 storage
1151 .init_bare(&handle(), &repo_name("steid"))
1152 .await
1153 .expect("should create");
1154
1155 storage
1156 .remove(&handle(), &repo_name("steid"))
1157 .await
1158 .expect("should remove");
1159
1160 assert!(!storage.repo_path(&handle(), &repo_name("steid")).exists());
1161 }
1162
1163 #[tokio::test]
1164 async fn removing_what_is_not_there_succeeds() {
1165 // Compensation runs when a create failed, which may be before anything landed.
1166 let (_dir, storage) = storage();
1167
1168 storage
1169 .remove(&handle(), &repo_name("never-created"))
1170 .await
1171 .expect("should succeed with nothing to do");
1172 }
1173
1174 #[tokio::test]
1175 async fn a_compensated_create_can_be_retried() {
1176 // The whole point of `remove`: create, fail to record it, undo, try again.
1177 let (_dir, storage) = storage();
1178
1179 storage
1180 .init_bare(&handle(), &repo_name("steid"))
1181 .await
1182 .expect("should create");
1183 storage
1184 .remove(&handle(), &repo_name("steid"))
1185 .await
1186 .expect("should remove");
1187 storage
1188 .init_bare(&handle(), &repo_name("steid"))
1189 .await
1190 .expect("should create again");
1191 }
1192
1193 #[tokio::test]
1194 async fn removing_one_repository_leaves_its_neighbours() {
1195 let (_dir, storage) = storage();
1196 storage
1197 .init_bare(&handle(), &repo_name("steid"))
1198 .await
1199 .expect("should create");
1200 storage
1201 .init_bare(&handle(), &repo_name("keeper"))
1202 .await
1203 .expect("should create");
1204
1205 storage
1206 .remove(&handle(), &repo_name("steid"))
1207 .await
1208 .expect("should remove");
1209
1210 assert!(storage.repo_path(&handle(), &repo_name("keeper")).is_dir());
1211 }
1212
1213 #[tokio::test]
1214 async fn a_failing_git_invocation_carries_gits_own_message() {
1215 let error = run_git(["not-a-real-subcommand"])
1216 .await
1217 .expect_err("should fail");
1218
1219 let message = error.to_string();
1220 assert!(
1221 message.contains("not-a-real-subcommand"),
1222 "expected git's own words, got: {message}"
1223 );
1224 }
1225
1226 // --- GitHttpBackend --------------------------------------------------------
1227
1228 /// A data directory holding one bare repository at `acme/steid.git`.
1229 async fn backend() -> (TempDir, GitHttpBackend) {
1230 let dir = TempDir::new().expect("temp dir");
1231 let storage = DiskGitStorage::new(dir.path());
1232
1233 let acme = OrgName::new("acme").expect("valid handle");
1234 storage
1235 .init_bare(&acme, &repo_name("steid"))
1236 .await
1237 .expect("init bare");
1238
1239 let backend = GitHttpBackend::new(dir.path());
1240 (dir, backend)
1241 }
1242
1243 fn advertisement(path_info: &str) -> GitRequest {
1244 GitRequest {
1245 method: GitMethod::Get,
1246 path_info: path_info.to_owned(),
1247 query: "service=git-upload-pack".to_owned(),
1248 content_type: None,
1249 content_encoding: None,
1250 content_length: None,
1251 git_protocol: None,
1252 allow_receive_pack: false,
1253 body: Box::pin(tokio::io::empty()),
1254 }
1255 }
1256
1257 async fn drain(response: GitResponse) -> Vec<u8> {
1258 let mut body = response.body;
1259 let mut bytes = Vec::new();
1260 body.read_to_end(&mut bytes).await.expect("read body");
1261 bytes
1262 }
1263
1264 #[tokio::test]
1265 async fn the_backend_advertises_refs() {
1266 let (_dir, backend) = backend().await;
1267
1268 let response = backend
1269 .serve(advertisement("/acme/steid.git/info/refs"))
1270 .await
1271 .expect("should serve");
1272
1273 assert_eq!(response.status, 200);
1274 assert!(
1275 response
1276 .headers
1277 .iter()
1278 .any(|(name, value)| name == "Content-Type"
1279 && value == "application/x-git-upload-pack-advertisement"),
1280 "git sets its own content type and we forward it: {:?}",
1281 response.headers
1282 );
1283
1284 // The pkt-line the smart protocol opens with. Getting this from git rather than
1285 // writing it is the whole reason the backend is a subprocess.
1286 let body = drain(response).await;
1287 assert!(
1288 body.starts_with(b"001e# service=git-upload-pack\n"),
1289 "unexpected advertisement: {:?}",
1290 String::from_utf8_lossy(&body[..body.len().min(40)])
1291 );
1292 }
1293
1294 #[tokio::test]
1295 async fn a_missing_repository_is_reported_as_404_not_as_a_failure() {
1296 // Failure arrives in the CGI stream, not the exit code: git exits 0 here and
1297 // says 404 in a header. Keying off the exit code instead would answer 200.
1298 let (_dir, backend) = backend().await;
1299
1300 let response = backend
1301 .serve(advertisement("/acme/nothing-here.git/info/refs"))
1302 .await
1303 .expect("serving should not itself fail");
1304
1305 assert_eq!(response.status, 404);
1306 }
1307
1308 #[tokio::test]
1309 async fn the_status_header_is_translated_rather_than_forwarded() {
1310 let (_dir, backend) = backend().await;
1311
1312 let response = backend
1313 .serve(advertisement("/acme/nothing-here.git/info/refs"))
1314 .await
1315 .expect("should serve");
1316
1317 assert!(
1318 !response
1319 .headers
1320 .iter()
1321 .any(|(name, _)| name.eq_ignore_ascii_case("status")),
1322 "Status: is CGI's, and means nothing to an HTTP client: {:?}",
1323 response.headers
1324 );
1325 }
1326
1327 #[tokio::test]
1328 async fn the_protocol_version_reaches_upload_pack() {
1329 // Protocol v2 answers an advertisement with a capability list rather than refs.
1330 // If `HTTP_GIT_PROTOCOL` is dropped the exchange silently falls back to v0, which
1331 // still works — so nothing fails, it just quietly gets worse.
1332 let (_dir, backend) = backend().await;
1333
1334 let mut request = advertisement("/acme/steid.git/info/refs");
1335 request.git_protocol = Some("version=2".to_owned());
1336
1337 let body = drain(backend.serve(request).await.expect("should serve")).await;
1338
1339 assert!(
1340 String::from_utf8_lossy(&body).contains("version 2"),
1341 "expected a v2 capability advertisement: {:?}",
1342 String::from_utf8_lossy(&body[..body.len().min(80)])
1343 );
1344 }
1345
1346 #[tokio::test]
1347 async fn the_in_memory_protocol_records_what_it_was_asked() {
1348 let protocol = InMemoryGitProtocol::new();
1349
1350 protocol
1351 .serve(advertisement("/acme/steid.git/info/refs"))
1352 .await
1353 .expect("should serve");
1354
1355 assert_eq!(
1356 protocol.requests(),
1357 vec![RecordedGitRequest {
1358 method: GitMethod::Get,
1359 path_info: "/acme/steid.git/info/refs".to_owned(),
1360 query: "service=git-upload-pack".to_owned(),
1361 git_protocol: None,
1362 content_encoding: None,
1363 allow_receive_pack: false,
1364 }]
1365 );
1366 assert!(protocol.was_called());
1367 }
1368
1369 // --- git archive ---------------------------------------------------------------
1370
1371 /// A bare repository with one commit in it, packed the way a real one is.
1372 ///
1373 /// Built by pushing from a working copy rather than by writing objects directly,
1374 /// for the same reason `git_query`'s fixture is: it is what actually happens.
1375 fn archivable() -> (TempDir, DiskGitArchive, ObjectId) {
1376 let dir = TempDir::new().expect("temp dir");
1377 let storage = DiskGitStorage::new(dir.path());
1378 let repo = storage.repo_path(&handle(), &repo_name("steid"));
1379 let work = dir.path().join("work");
1380
1381 let git = |at: &Path, args: &[&str]| {
1382 let output = std::process::Command::new("git")
1383 .arg("-C")
1384 .arg(at)
1385 .args(args)
1386 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1387 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1388 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
1389 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
1390 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
1391 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
1392 .output()
1393 .expect("git should be on PATH");
1394
1395 assert!(
1396 output.status.success(),
1397 "git {args:?} failed: {}",
1398 String::from_utf8_lossy(&output.stderr)
1399 );
1400
1401 String::from_utf8_lossy(&output.stdout).trim().to_owned()
1402 };
1403
1404 std::fs::create_dir_all(&work).expect("create work tree");
1405 git(
1406 dir.path(),
1407 &[
1408 "init",
1409 "--bare",
1410 "--quiet",
1411 "--template=",
1412 "--initial-branch=main",
1413 "--",
1414 repo.to_str().expect("utf-8 fixture path"),
1415 ],
1416 );
1417 git(&work, &["init", "--quiet", "-b", "main"]);
1418 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
1419 git(&work, &["add", "-A"]);
1420 git(&work, &["commit", "--quiet", "-m", "first"]);
1421 git(
1422 &work,
1423 &["push", "--quiet", repo.to_str().expect("utf-8"), "main"],
1424 );
1425
1426 let head = git(&repo, &["rev-parse", "main"]);
1427 let archives = DiskGitArchive::new(dir.path());
1428
1429 (
1430 dir,
1431 archives,
1432 ObjectId::new(head).expect("a real object id"),
1433 )
1434 }
1435
1436 async fn packed(format: ArchiveFormat) -> Vec<u8> {
1437 let (dir, archives, commit) = archivable();
1438
1439 let mut stream = archives
1440 .archive(ArchiveRequest {
1441 handle: handle(),
1442 name: repo_name("steid"),
1443 format,
1444 commit,
1445 prefix: "steid-main/".to_owned(),
1446 })
1447 .await
1448 .expect("should pack");
1449
1450 let mut bytes = Vec::new();
1451 stream.read_to_end(&mut bytes).await.expect("should stream");
1452
1453 // Held until the bytes are read: dropping the fixture deletes the repository
1454 // git is still reading from.
1455 drop(dir);
1456
1457 bytes
1458 }
1459
1460 #[tokio::test]
1461 async fn a_tarball_carries_the_prefix_directory() {
1462 let bytes = packed(ArchiveFormat::TarGz).await;
1463
1464 // The gzip magic number, so the format flag is doing something rather than
1465 // silently producing an uncompressed tar.
1466 assert_eq!(&bytes[..2], &[0x1f, 0x8b], "expected gzip");
1467 assert!(bytes.len() > 100, "expected a real archive");
1468 }
1469
1470 #[tokio::test]
1471 async fn a_zip_is_a_zip() {
1472 let bytes = packed(ArchiveFormat::Zip).await;
1473
1474 assert_eq!(&bytes[..2], b"PK", "expected a zip");
1475 // The prefix is stored as part of every entry name, uncompressed in the
1476 // central directory, so it is readable in the raw bytes.
1477 assert!(
1478 bytes.windows(11).any(|window| window == b"steid-main/"),
1479 "expected the prefix directory in the archive"
1480 );
1481 }
1482}