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,
27 },
28 domain::{
29 BranchRow, CommitSummary, GitRef, GrepHit, ObjectId, OrgName, RefKind, RefName, RepoName,
30 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}
642
643impl InMemoryGitQuery {
644 /// An empty repository: no default branch, so nothing has been pushed.
645 pub fn empty() -> Self {
646 Self::default()
647 }
648
649 /// A repository whose default branch is `main`.
650 pub fn new() -> Self {
651 Self {
652 default_branch: Some(RefName::from_trusted("main")),
653 ..Self::default()
654 }
655 }
656
657 fn key(rev: &RefName, path: &RepoPath) -> String {
658 format!("{}\0{}", rev.as_str(), path.as_str())
659 }
660
661 pub fn with_tree(mut self, rev: &str, path: &str, entries: Vec<TreeEntry>) -> Self {
662 self.trees.insert(
663 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
664 entries,
665 );
666 self
667 }
668
669 pub fn with_blob(mut self, rev: &str, path: &str, content: impl Into<Vec<u8>>) -> Self {
670 self.blobs.insert(
671 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
672 content.into(),
673 );
674 self
675 }
676
677 pub fn with_log(mut self, commits: Vec<CommitSummary>) -> Self {
678 self.commits = commits;
679 self
680 }
681
682 pub fn with_branch(self, name: &str) -> Self {
683 self.with_ref(name, RefKind::Branch)
684 }
685
686 pub fn with_tag(self, name: &str) -> Self {
687 self.with_ref(name, RefKind::Tag)
688 }
689
690 pub fn with_commit_count(mut self, count: u64) -> Self {
691 self.commit_count = Some(count);
692 self
693 }
694
695 pub fn with_latest_tag(mut self, name: &str, created_at: SystemTime) -> Self {
696 self.latest_tag = Some(TagSummary {
697 name: RefName::from_trusted(name),
698 created_at,
699 });
700 self
701 }
702
703 /// One row of the branches page. `is_default` is what git's `%(HEAD)` marks.
704 pub fn with_branch_row(
705 mut self,
706 name: &str,
707 is_default: bool,
708 committed_at: SystemTime,
709 ) -> Self {
710 self.branch_rows.push(BranchRow {
711 name: RefName::from_trusted(name),
712 is_default,
713 commit: ObjectId::from_trusted("2".repeat(40)),
714 summary: format!("work on {name}"),
715 committed_at,
716 });
717 self
718 }
719
720 /// One row of the tags page. An annotated tag carries a message; a lightweight one
721 /// has none of its own.
722 pub fn with_tag_row(mut self, name: &str, annotated: bool, created_at: SystemTime) -> Self {
723 self.tag_rows.push(TagRow {
724 name: RefName::from_trusted(name),
725 commit: ObjectId::from_trusted("3".repeat(40)),
726 message: annotated.then(|| format!("release {name}")),
727 annotated,
728 created_at,
729 });
730 self
731 }
732
733 /// Seeds what a search finds. Order is kept, because grouping by file depends on
734 /// git's ordering and a fake that reordered would hide that.
735 pub fn with_grep_hits(mut self, hits: Vec<GrepHit>) -> Self {
736 self.grep_hits = hits;
737 self
738 }
739
740 /// A repository whose grep is killed by the timeout.
741 pub fn with_slow_grep(mut self) -> Self {
742 self.slow_grep = true;
743 self
744 }
745
746 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
747 self.refs.push(GitRef {
748 name: RefName::from_trusted(name),
749 kind,
750 });
751 self
752 }
753}
754
755impl GitQuery for InMemoryGitQuery {
756 async fn default_branch(
757 &self,
758 _handle: &OrgName,
759 _name: &RepoName,
760 ) -> Result<Option<RefName>, GitQueryError> {
761 Ok(self.default_branch.clone())
762 }
763
764 async fn resolve(
765 &self,
766 _handle: &OrgName,
767 _name: &RepoName,
768 _rev: &RefName,
769 ) -> Result<Option<ObjectId>, GitQueryError> {
770 Ok(self
771 .default_branch
772 .as_ref()
773 .map(|_| ObjectId::from_trusted("0".repeat(40))))
774 }
775
776 async fn list_tree(
777 &self,
778 _handle: &OrgName,
779 _name: &RepoName,
780 rev: &RefName,
781 path: &RepoPath,
782 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
783 Ok(self.trees.get(&Self::key(rev, path)).cloned())
784 }
785
786 async fn read_blob(
787 &self,
788 _handle: &OrgName,
789 _name: &RepoName,
790 rev: &RefName,
791 path: &RepoPath,
792 max_bytes: u64,
793 ) -> Result<Option<Blob>, GitQueryError> {
794 Ok(self.blobs.get(&Self::key(rev, path)).map(|content| {
795 let size = content.len() as u64;
796
797 Blob {
798 id: ObjectId::from_trusted("1".repeat(40)),
799 size,
800 // The same cap the real adapter applies, so a test can exercise the
801 // too-large path without a megabyte of fixture.
802 content: (size <= max_bytes).then(|| content.clone()),
803 }
804 }))
805 }
806
807 async fn log(
808 &self,
809 _handle: &OrgName,
810 _name: &RepoName,
811 _rev: &RefName,
812 limit: usize,
813 ) -> Result<Vec<CommitSummary>, GitQueryError> {
814 Ok(self.commits.iter().take(limit).cloned().collect())
815 }
816
817 async fn list_refs(
818 &self,
819 _handle: &OrgName,
820 _name: &RepoName,
821 ) -> Result<Vec<GitRef>, GitQueryError> {
822 Ok(self.refs.clone())
823 }
824
825 async fn count_commits(
826 &self,
827 _handle: &OrgName,
828 _name: &RepoName,
829 _rev: &RefName,
830 ) -> Result<u64, GitQueryError> {
831 Ok(self.commit_count.unwrap_or(self.commits.len() as u64))
832 }
833
834 async fn latest_tag(
835 &self,
836 _handle: &OrgName,
837 _name: &RepoName,
838 ) -> Result<Option<TagSummary>, GitQueryError> {
839 Ok(self.latest_tag.clone())
840 }
841
842 async fn branches(
843 &self,
844 _handle: &OrgName,
845 _name: &RepoName,
846 ) -> Result<Vec<BranchRow>, GitQueryError> {
847 Ok(self.branch_rows.clone())
848 }
849
850 async fn tags(
851 &self,
852 _handle: &OrgName,
853 _name: &RepoName,
854 ) -> Result<Vec<TagRow>, GitQueryError> {
855 Ok(self.tag_rows.clone())
856 }
857
858 async fn grep(
859 &self,
860 _handle: &OrgName,
861 _name: &RepoName,
862 _commit: &ObjectId,
863 _query: &str,
864 limit: usize,
865 ) -> Result<Vec<GrepHit>, GitQueryError> {
866 if self.slow_grep {
867 return Err(GitQueryError::timed_out(std::time::Duration::from_secs(20)));
868 }
869
870 Ok(self.grep_hits.iter().take(limit).cloned().collect())
871 }
872}
873
874#[cfg(test)]
875mod tests {
876 use std::path::Path;
877
878 use tempfile::TempDir;
879
880 use tokio::io::AsyncReadExt;
881
882 use super::*;
883 use crate::application::port::ArchiveFormat;
884
885 /// The `TempDir` is returned alongside the storage because dropping it deletes the
886 /// data directory — binding it to `_` would remove the fixture mid-test.
887 fn storage() -> (TempDir, DiskGitStorage) {
888 let dir = TempDir::new().expect("temp dir");
889 let storage = DiskGitStorage::new(dir.path());
890 (dir, storage)
891 }
892
893 fn handle() -> OrgName {
894 OrgName::new("jamesgill").expect("valid handle")
895 }
896
897 fn repo_name(value: &str) -> RepoName {
898 RepoName::new(value).expect("valid repository name")
899 }
900
901 /// Asks git about a repository, so assertions test what git believes rather than
902 /// what the directory looks like.
903 fn git_says(path: &Path, args: &[&str]) -> String {
904 let output = std::process::Command::new("git")
905 .arg("-C")
906 .arg(path)
907 .args(args)
908 .output()
909 .expect("git should be on PATH");
910
911 assert!(
912 output.status.success(),
913 "git {args:?} failed: {}",
914 String::from_utf8_lossy(&output.stderr)
915 );
916
917 String::from_utf8_lossy(&output.stdout).trim().to_owned()
918 }
919
920 #[tokio::test]
921 async fn init_bare_creates_a_bare_repository() {
922 let (_dir, storage) = storage();
923
924 storage
925 .init_bare(&handle(), &repo_name("steid"))
926 .await
927 .expect("should create");
928
929 let path = storage.repo_path(&handle(), &repo_name("steid"));
930 assert!(path.is_dir(), "expected a repository at {path:?}");
931 assert_eq!(
932 git_says(&path, &["rev-parse", "--is-bare-repository"]),
933 "true"
934 );
935 }
936
937 #[tokio::test]
938 async fn a_new_repository_is_empty() {
939 // Empty, like GitHub: no initial commit and no branch yet.
940 let (_dir, storage) = storage();
941 storage
942 .init_bare(&handle(), &repo_name("steid"))
943 .await
944 .expect("should create");
945
946 let path = storage.repo_path(&handle(), &repo_name("steid"));
947
948 assert_eq!(git_says(&path, &["for-each-ref"]), "");
949 }
950
951 #[tokio::test]
952 async fn a_new_repository_defaults_to_main() {
953 // Pinned so the host's `init.defaultBranch` cannot decide this. It currently
954 // agrees on this machine, which is exactly why a drift would go unnoticed.
955 let (_dir, storage) = storage();
956 storage
957 .init_bare(&handle(), &repo_name("steid"))
958 .await
959 .expect("should create");
960
961 let path = storage.repo_path(&handle(), &repo_name("steid"));
962
963 assert_eq!(
964 git_says(&path, &["symbolic-ref", "HEAD"]),
965 "refs/heads/main"
966 );
967 }
968
969 #[tokio::test]
970 async fn no_sample_hooks_are_installed() {
971 // Pins `--template=`. A default init seeds sixteen `.sample` files.
972 let (_dir, storage) = storage();
973 storage
974 .init_bare(&handle(), &repo_name("steid"))
975 .await
976 .expect("should create");
977
978 let hooks = storage
979 .repo_path(&handle(), &repo_name("steid"))
980 .join("hooks");
981
982 let samples = std::fs::read_dir(&hooks)
983 .map(|entries| entries.count())
984 .unwrap_or(0);
985 assert_eq!(samples, 0, "expected no templated hooks in {hooks:?}");
986 }
987
988 #[tokio::test]
989 async fn init_bare_creates_the_handle_directory() {
990 let (dir, storage) = storage();
991 assert!(!dir.path().join("jamesgill").exists());
992
993 storage
994 .init_bare(&handle(), &repo_name("steid"))
995 .await
996 .expect("should create");
997
998 assert!(dir.path().join("jamesgill").is_dir());
999 }
1000
1001 #[tokio::test]
1002 async fn one_handle_can_own_several_repositories() {
1003 let (_dir, storage) = storage();
1004
1005 for name in ["steid", "foo.js", ".github"] {
1006 storage
1007 .init_bare(&handle(), &repo_name(name))
1008 .await
1009 .unwrap_or_else(|error| panic!("{name} should create: {error}"));
1010 }
1011
1012 for name in ["steid", "foo.js", ".github"] {
1013 assert!(storage.repo_path(&handle(), &repo_name(name)).is_dir());
1014 }
1015 }
1016
1017 #[tokio::test]
1018 async fn init_bare_refuses_a_repository_that_already_exists() {
1019 let (_dir, storage) = storage();
1020 storage
1021 .init_bare(&handle(), &repo_name("steid"))
1022 .await
1023 .expect("should create");
1024
1025 let error = storage
1026 .init_bare(&handle(), &repo_name("steid"))
1027 .await
1028 .expect_err("should refuse");
1029
1030 assert!(matches!(error, GitStorageError::AlreadyExists));
1031 }
1032
1033 #[tokio::test]
1034 async fn a_refused_init_leaves_the_existing_repository_alone() {
1035 // git would happily re-initialise in place. The point of refusing is that
1036 // whatever is already there is not touched.
1037 let (_dir, storage) = storage();
1038 storage
1039 .init_bare(&handle(), &repo_name("steid"))
1040 .await
1041 .expect("should create");
1042
1043 let path = storage.repo_path(&handle(), &repo_name("steid"));
1044 let marker = path.join("objects").join("marker");
1045 std::fs::write(&marker, b"existing data").expect("write marker");
1046
1047 let _ = storage.init_bare(&handle(), &repo_name("steid")).await;
1048
1049 assert_eq!(
1050 std::fs::read(&marker).expect("marker should survive"),
1051 b"existing data"
1052 );
1053 }
1054
1055 #[tokio::test]
1056 async fn repo_path_creates_nothing() {
1057 let (dir, storage) = storage();
1058
1059 let path = storage.repo_path(&handle(), &repo_name("never-created"));
1060
1061 assert!(!path.exists());
1062 assert_eq!(
1063 std::fs::read_dir(dir.path())
1064 .expect("data dir should exist")
1065 .count(),
1066 0,
1067 "repo_path must be pure"
1068 );
1069 }
1070
1071 #[tokio::test]
1072 async fn repo_path_lands_under_the_data_directory() {
1073 let (dir, storage) = storage();
1074
1075 let path = storage.repo_path(&handle(), &repo_name("steid"));
1076
1077 assert_eq!(path, dir.path().join("jamesgill").join("steid.git"));
1078 }
1079
1080 #[tokio::test]
1081 async fn remove_deletes_the_repository() {
1082 let (_dir, storage) = storage();
1083 storage
1084 .init_bare(&handle(), &repo_name("steid"))
1085 .await
1086 .expect("should create");
1087
1088 storage
1089 .remove(&handle(), &repo_name("steid"))
1090 .await
1091 .expect("should remove");
1092
1093 assert!(!storage.repo_path(&handle(), &repo_name("steid")).exists());
1094 }
1095
1096 #[tokio::test]
1097 async fn removing_what_is_not_there_succeeds() {
1098 // Compensation runs when a create failed, which may be before anything landed.
1099 let (_dir, storage) = storage();
1100
1101 storage
1102 .remove(&handle(), &repo_name("never-created"))
1103 .await
1104 .expect("should succeed with nothing to do");
1105 }
1106
1107 #[tokio::test]
1108 async fn a_compensated_create_can_be_retried() {
1109 // The whole point of `remove`: create, fail to record it, undo, try again.
1110 let (_dir, storage) = storage();
1111
1112 storage
1113 .init_bare(&handle(), &repo_name("steid"))
1114 .await
1115 .expect("should create");
1116 storage
1117 .remove(&handle(), &repo_name("steid"))
1118 .await
1119 .expect("should remove");
1120 storage
1121 .init_bare(&handle(), &repo_name("steid"))
1122 .await
1123 .expect("should create again");
1124 }
1125
1126 #[tokio::test]
1127 async fn removing_one_repository_leaves_its_neighbours() {
1128 let (_dir, storage) = storage();
1129 storage
1130 .init_bare(&handle(), &repo_name("steid"))
1131 .await
1132 .expect("should create");
1133 storage
1134 .init_bare(&handle(), &repo_name("keeper"))
1135 .await
1136 .expect("should create");
1137
1138 storage
1139 .remove(&handle(), &repo_name("steid"))
1140 .await
1141 .expect("should remove");
1142
1143 assert!(storage.repo_path(&handle(), &repo_name("keeper")).is_dir());
1144 }
1145
1146 #[tokio::test]
1147 async fn a_failing_git_invocation_carries_gits_own_message() {
1148 let error = run_git(["not-a-real-subcommand"])
1149 .await
1150 .expect_err("should fail");
1151
1152 let message = error.to_string();
1153 assert!(
1154 message.contains("not-a-real-subcommand"),
1155 "expected git's own words, got: {message}"
1156 );
1157 }
1158
1159 // --- GitHttpBackend --------------------------------------------------------
1160
1161 /// A data directory holding one bare repository at `acme/steid.git`.
1162 async fn backend() -> (TempDir, GitHttpBackend) {
1163 let dir = TempDir::new().expect("temp dir");
1164 let storage = DiskGitStorage::new(dir.path());
1165
1166 let acme = OrgName::new("acme").expect("valid handle");
1167 storage
1168 .init_bare(&acme, &repo_name("steid"))
1169 .await
1170 .expect("init bare");
1171
1172 let backend = GitHttpBackend::new(dir.path());
1173 (dir, backend)
1174 }
1175
1176 fn advertisement(path_info: &str) -> GitRequest {
1177 GitRequest {
1178 method: GitMethod::Get,
1179 path_info: path_info.to_owned(),
1180 query: "service=git-upload-pack".to_owned(),
1181 content_type: None,
1182 content_encoding: None,
1183 content_length: None,
1184 git_protocol: None,
1185 allow_receive_pack: false,
1186 body: Box::pin(tokio::io::empty()),
1187 }
1188 }
1189
1190 async fn drain(response: GitResponse) -> Vec<u8> {
1191 let mut body = response.body;
1192 let mut bytes = Vec::new();
1193 body.read_to_end(&mut bytes).await.expect("read body");
1194 bytes
1195 }
1196
1197 #[tokio::test]
1198 async fn the_backend_advertises_refs() {
1199 let (_dir, backend) = backend().await;
1200
1201 let response = backend
1202 .serve(advertisement("/acme/steid.git/info/refs"))
1203 .await
1204 .expect("should serve");
1205
1206 assert_eq!(response.status, 200);
1207 assert!(
1208 response
1209 .headers
1210 .iter()
1211 .any(|(name, value)| name == "Content-Type"
1212 && value == "application/x-git-upload-pack-advertisement"),
1213 "git sets its own content type and we forward it: {:?}",
1214 response.headers
1215 );
1216
1217 // The pkt-line the smart protocol opens with. Getting this from git rather than
1218 // writing it is the whole reason the backend is a subprocess.
1219 let body = drain(response).await;
1220 assert!(
1221 body.starts_with(b"001e# service=git-upload-pack\n"),
1222 "unexpected advertisement: {:?}",
1223 String::from_utf8_lossy(&body[..body.len().min(40)])
1224 );
1225 }
1226
1227 #[tokio::test]
1228 async fn a_missing_repository_is_reported_as_404_not_as_a_failure() {
1229 // Failure arrives in the CGI stream, not the exit code: git exits 0 here and
1230 // says 404 in a header. Keying off the exit code instead would answer 200.
1231 let (_dir, backend) = backend().await;
1232
1233 let response = backend
1234 .serve(advertisement("/acme/nothing-here.git/info/refs"))
1235 .await
1236 .expect("serving should not itself fail");
1237
1238 assert_eq!(response.status, 404);
1239 }
1240
1241 #[tokio::test]
1242 async fn the_status_header_is_translated_rather_than_forwarded() {
1243 let (_dir, backend) = backend().await;
1244
1245 let response = backend
1246 .serve(advertisement("/acme/nothing-here.git/info/refs"))
1247 .await
1248 .expect("should serve");
1249
1250 assert!(
1251 !response
1252 .headers
1253 .iter()
1254 .any(|(name, _)| name.eq_ignore_ascii_case("status")),
1255 "Status: is CGI's, and means nothing to an HTTP client: {:?}",
1256 response.headers
1257 );
1258 }
1259
1260 #[tokio::test]
1261 async fn the_protocol_version_reaches_upload_pack() {
1262 // Protocol v2 answers an advertisement with a capability list rather than refs.
1263 // If `HTTP_GIT_PROTOCOL` is dropped the exchange silently falls back to v0, which
1264 // still works — so nothing fails, it just quietly gets worse.
1265 let (_dir, backend) = backend().await;
1266
1267 let mut request = advertisement("/acme/steid.git/info/refs");
1268 request.git_protocol = Some("version=2".to_owned());
1269
1270 let body = drain(backend.serve(request).await.expect("should serve")).await;
1271
1272 assert!(
1273 String::from_utf8_lossy(&body).contains("version 2"),
1274 "expected a v2 capability advertisement: {:?}",
1275 String::from_utf8_lossy(&body[..body.len().min(80)])
1276 );
1277 }
1278
1279 #[tokio::test]
1280 async fn the_in_memory_protocol_records_what_it_was_asked() {
1281 let protocol = InMemoryGitProtocol::new();
1282
1283 protocol
1284 .serve(advertisement("/acme/steid.git/info/refs"))
1285 .await
1286 .expect("should serve");
1287
1288 assert_eq!(
1289 protocol.requests(),
1290 vec![RecordedGitRequest {
1291 method: GitMethod::Get,
1292 path_info: "/acme/steid.git/info/refs".to_owned(),
1293 query: "service=git-upload-pack".to_owned(),
1294 git_protocol: None,
1295 content_encoding: None,
1296 allow_receive_pack: false,
1297 }]
1298 );
1299 assert!(protocol.was_called());
1300 }
1301
1302 // --- git archive ---------------------------------------------------------------
1303
1304 /// A bare repository with one commit in it, packed the way a real one is.
1305 ///
1306 /// Built by pushing from a working copy rather than by writing objects directly,
1307 /// for the same reason `git_query`'s fixture is: it is what actually happens.
1308 fn archivable() -> (TempDir, DiskGitArchive, ObjectId) {
1309 let dir = TempDir::new().expect("temp dir");
1310 let storage = DiskGitStorage::new(dir.path());
1311 let repo = storage.repo_path(&handle(), &repo_name("steid"));
1312 let work = dir.path().join("work");
1313
1314 let git = |at: &Path, args: &[&str]| {
1315 let output = std::process::Command::new("git")
1316 .arg("-C")
1317 .arg(at)
1318 .args(args)
1319 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1320 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1321 .env("GIT_AUTHOR_NAME", "Ada Lovelace")
1322 .env("GIT_AUTHOR_EMAIL", "ada@example.com")
1323 .env("GIT_COMMITTER_NAME", "Ada Lovelace")
1324 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
1325 .output()
1326 .expect("git should be on PATH");
1327
1328 assert!(
1329 output.status.success(),
1330 "git {args:?} failed: {}",
1331 String::from_utf8_lossy(&output.stderr)
1332 );
1333
1334 String::from_utf8_lossy(&output.stdout).trim().to_owned()
1335 };
1336
1337 std::fs::create_dir_all(&work).expect("create work tree");
1338 git(
1339 dir.path(),
1340 &[
1341 "init",
1342 "--bare",
1343 "--quiet",
1344 "--template=",
1345 "--initial-branch=main",
1346 "--",
1347 repo.to_str().expect("utf-8 fixture path"),
1348 ],
1349 );
1350 git(&work, &["init", "--quiet", "-b", "main"]);
1351 std::fs::write(work.join("README.md"), b"hello\n").expect("write");
1352 git(&work, &["add", "-A"]);
1353 git(&work, &["commit", "--quiet", "-m", "first"]);
1354 git(
1355 &work,
1356 &["push", "--quiet", repo.to_str().expect("utf-8"), "main"],
1357 );
1358
1359 let head = git(&repo, &["rev-parse", "main"]);
1360 let archives = DiskGitArchive::new(dir.path());
1361
1362 (
1363 dir,
1364 archives,
1365 ObjectId::new(head).expect("a real object id"),
1366 )
1367 }
1368
1369 async fn packed(format: ArchiveFormat) -> Vec<u8> {
1370 let (dir, archives, commit) = archivable();
1371
1372 let mut stream = archives
1373 .archive(ArchiveRequest {
1374 handle: handle(),
1375 name: repo_name("steid"),
1376 format,
1377 commit,
1378 prefix: "steid-main/".to_owned(),
1379 })
1380 .await
1381 .expect("should pack");
1382
1383 let mut bytes = Vec::new();
1384 stream.read_to_end(&mut bytes).await.expect("should stream");
1385
1386 // Held until the bytes are read: dropping the fixture deletes the repository
1387 // git is still reading from.
1388 drop(dir);
1389
1390 bytes
1391 }
1392
1393 #[tokio::test]
1394 async fn a_tarball_carries_the_prefix_directory() {
1395 let bytes = packed(ArchiveFormat::TarGz).await;
1396
1397 // The gzip magic number, so the format flag is doing something rather than
1398 // silently producing an uncompressed tar.
1399 assert_eq!(&bytes[..2], &[0x1f, 0x8b], "expected gzip");
1400 assert!(bytes.len() > 100, "expected a real archive");
1401 }
1402
1403 #[tokio::test]
1404 async fn a_zip_is_a_zip() {
1405 let bytes = packed(ArchiveFormat::Zip).await;
1406
1407 assert_eq!(&bytes[..2], b"PK", "expected a zip");
1408 // The prefix is stored as part of every entry name, uncompressed in the
1409 // central directory, so it is readable in the raw bytes.
1410 assert!(
1411 bytes.windows(11).any(|window| window == b"steid-main/"),
1412 "expected the prefix directory in the archive"
1413 );
1414 }
1415}