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