@jpgilldev / steid

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};
15
16use tokio::{
17 io::{AsyncBufReadExt, AsyncReadExt, BufReader},
18 process::{ChildStdout, Command},
19};
20
21use crate::{
22 application::port::{
23 Blob, GitMethod, GitProtocolError, GitProtocolServer, GitQuery, GitQueryError, GitRequest,
24 GitResponse, GitStorage, GitStorageError,
25 },
26 domain::{
27 CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath, TreeEntry,
28 },
29};
30
31/// The most CGI headers `git http-backend` will ever emit, with room to spare.
32///
33/// A guard rather than a real expectation: the header block is read before anything is
34/// streamed, and an unbounded read of a subprocess's stdout is a hang waiting to happen.
35const MAX_CGI_HEADERS: usize = 64;
36
37/// Environment variables that redirect where git reads and writes data.
38///
39/// Steid's own environment must not reach into a repository's layout. These are set
40/// whenever a process is spawned from inside a git hook, which is exactly the shape
41/// Milestone 5 will have, and the failure is silent — objects land somewhere else and
42/// the repository looks empty.
43const REDIRECTING_VARS: &[&str] = &[
44 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
45 "GIT_DIR",
46 "GIT_INDEX_FILE",
47 "GIT_OBJECT_DIRECTORY",
48 "GIT_WORK_TREE",
49];
50
51/// Bare repositories on disk, laid out as `{data_dir}/{handle}/{name}.git`.
52#[derive(Debug, Clone)]
53pub struct DiskGitStorage {
54 data_dir: PathBuf,
55}
56
57impl DiskGitStorage {
58 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
59 Self {
60 data_dir: data_dir.into(),
61 }
62 }
63}
64
65impl GitStorage for DiskGitStorage {
66 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
67 let path = self.repo_path(handle, name);
68
69 // `git init` on an existing repository exits 0 and re-initialises in silence,
70 // so this check has to be ours. A directory with no matching record is an
71 // orphan from a create that died between the two writes; adopting it would
72 // resurface a private repository's objects under a fresh record.
73 if path.exists() {
74 return Err(GitStorageError::AlreadyExists);
75 }
76
77 // No `create_dir_all` for the parent: `git init` creates missing directories.
78 run_git([
79 OsStr::new("init"),
80 OsStr::new("--bare"),
81 OsStr::new("--quiet"),
82 // Skip the template directory, which otherwise seeds every repository with
83 // sixteen `.sample` hooks. Steid installs its own hooks later, and they
84 // would be noise to work around.
85 OsStr::new("--template="),
86 // Explicit, so the host's `init.defaultBranch` cannot decide what the
87 // default branch of a Steid repository is.
88 OsStr::new("--initial-branch=main"),
89 // `RepoName` already forbids a leading hyphen; this makes it impossible for
90 // a path to be read as a flag at the boundary where it costs nothing.
91 OsStr::new("--"),
92 path.as_os_str(),
93 ])
94 .await
95 .map(|_| ())
96 }
97
98 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
99 let path = self.repo_path(handle, name);
100
101 // `tokio::fs` rather than `std::fs`: removing a repository with real history
102 // walks every loose object, which is long enough to stall a runtime worker.
103 match tokio::fs::remove_dir_all(&path).await {
104 Ok(()) => Ok(()),
105 // Compensation must not fail because there was nothing left to undo.
106 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
107 Err(error) => Err(GitStorageError::backend(error)),
108 }
109 }
110
111 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
112 // Nothing is sanitised here. `OrgName` and `RepoName` already made traversal
113 // impossible, and re-checking at the call site is how that responsibility gets
114 // diffused until nobody owns it.
115 self.data_dir
116 .join(handle.as_str())
117 .join(format!("{name}.git"))
118 }
119}
120
121/// A `git` command isolated from the host.
122///
123/// The one place that decides what git inherits: no ambient configuration, no
124/// redirected object storage. Both the lifecycle commands and the protocol backend
125/// build on this, which is the point — 0006 exists because these flags are exactly what
126/// drifts silently between call sites.
127pub(crate) fn git_command() -> Command {
128 let mut command = Command::new("git");
129
130 // Host configuration must not leak into repositories Steid creates, for the same
131 // reason `--initial-branch` is passed explicitly.
132 command
133 .env("GIT_CONFIG_GLOBAL", "/dev/null")
134 .env("GIT_CONFIG_SYSTEM", "/dev/null");
135
136 for variable in REDIRECTING_VARS {
137 command.env_remove(variable);
138 }
139
140 command
141}
142
143/// Runs `git` and fails on a non-zero exit.
144///
145/// The single place that decides how Steid invokes git, so every call site gets the
146/// same isolation from the host: no ambient configuration, no redirected object
147/// storage, no inherited stdin. Never depends on the working directory — callers pass
148/// absolute paths.
149async fn run_git<I, S>(args: I) -> Result<Output, GitStorageError>
150where
151 I: IntoIterator<Item = S>,
152 S: AsRef<OsStr>,
153{
154 let mut command = git_command();
155 command.args(args).stdin(Stdio::null());
156
157 // `output()` pipes stdout and stderr and waits without blocking the runtime.
158 let output = command
159 .output()
160 .await
161 .map_err(|error| GitStorageError::backend(format!("could not run git: {error}")))?;
162
163 if !output.status.success() {
164 // Carry git's own words. "command failed" sends the next person to read this
165 // code instead of reading the error.
166 return Err(GitStorageError::backend(format!(
167 "git exited with {}: {}",
168 output.status,
169 String::from_utf8_lossy(&output.stderr).trim()
170 )));
171 }
172
173 Ok(output)
174}
175
176/// Bare repositories tracked in memory, for testing use cases without touching disk.
177///
178/// The counterpart to [`DiskGitStorage`], the way `StubHasher` is the counterpart to
179/// the real Argon2 hasher. It enforces the same `AlreadyExists` rule, because a use
180/// case that only passes against a permissive fake proves nothing about the real one.
181#[derive(Debug, Default, Clone)]
182pub struct InMemoryGitStorage {
183 created: Arc<Mutex<HashSet<PathBuf>>>,
184}
185
186impl InMemoryGitStorage {
187 pub fn new() -> Self {
188 Self::default()
189 }
190
191 /// Whether a repository exists, for assertions.
192 pub fn contains(&self, handle: &OrgName, name: &RepoName) -> bool {
193 self.created
194 .lock()
195 .expect("lock poisoned")
196 .contains(&self.repo_path(handle, name))
197 }
198
199 /// How many repositories exist, for asserting that nothing was created.
200 pub fn len(&self) -> usize {
201 self.created.lock().expect("lock poisoned").len()
202 }
203
204 pub fn is_empty(&self) -> bool {
205 self.len() == 0
206 }
207}
208
209impl GitStorage for InMemoryGitStorage {
210 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
211 let mut created = self.created.lock().expect("lock poisoned");
212
213 if !created.insert(self.repo_path(handle, name)) {
214 return Err(GitStorageError::AlreadyExists);
215 }
216
217 Ok(())
218 }
219
220 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
221 self.created
222 .lock()
223 .expect("lock poisoned")
224 .remove(&self.repo_path(handle, name));
225
226 Ok(())
227 }
228
229 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
230 PathBuf::from("/in-memory")
231 .join(handle.as_str())
232 .join(format!("{name}.git"))
233 }
234}
235
236/// The git smart-HTTP protocol, served by `git http-backend`.
237///
238/// The binary is a CGI: it takes an environment and a request body on stdin, and writes
239/// CRLF-terminated headers, a blank line, then the response body. Its contract was
240/// probed rather than assumed — see `plans/progress.md` under Milestone 4a.
241#[derive(Debug, Clone)]
242pub struct GitHttpBackend {
243 data_dir: PathBuf,
244}
245
246impl GitHttpBackend {
247 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
248 Self {
249 data_dir: data_dir.into(),
250 }
251 }
252}
253
254impl GitProtocolServer for GitHttpBackend {
255 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
256 let mut command = git_command();
257
258 // Before the subcommand: `git -c … http-backend`. Pushes are refused by the
259 // backend unless this says otherwise, and it is set only for a request the use
260 // case already authorized — so a bug in Steid's rules meets git's refusal rather
261 // than an open door.
262 if request.allow_receive_pack {
263 command.arg("-c").arg("http.receivepack=true");
264 }
265
266 command
267 .arg("http-backend")
268 .env("GIT_PROJECT_ROOT", &self.data_dir)
269 // Steid decides visibility from the `repositories` table, in the use case.
270 // Without this, git applies its own rule and refuses everything lacking a
271 // `git-daemon-export-ok` marker file — a second source of truth for the same
272 // question, free to drift from the first.
273 .env("GIT_HTTP_EXPORT_ALL", "1")
274 .env("PATH_INFO", &request.path_info)
275 .env("QUERY_STRING", &request.query)
276 .env("REQUEST_METHOD", request.method.as_str())
277 .stdin(Stdio::piped())
278 .stdout(Stdio::piped())
279 .stderr(Stdio::piped());
280
281 // CGI gives only Content-Type and Content-Length unprefixed names; every other
282 // request header arrives `HTTP_`-prefixed. Passing `CONTENT_ENCODING` instead of
283 // `HTTP_CONTENT_ENCODING` makes the backend hand a still-gzipped body to
284 // upload-pack, and the client reports `expected 'packfile'` with nothing naming
285 // the cause. Measured, not guessed.
286 for (variable, value) in [
287 ("CONTENT_TYPE", &request.content_type),
288 ("CONTENT_LENGTH", &request.content_length),
289 ("HTTP_CONTENT_ENCODING", &request.content_encoding),
290 ("HTTP_GIT_PROTOCOL", &request.git_protocol),
291 ] {
292 if let Some(value) = value {
293 command.env(variable, value);
294 }
295 }
296
297 let mut child = command
298 .spawn()
299 .map_err(|error| GitProtocolError::new(format!("could not run git: {error}")))?;
300
301 let mut stdin = child.stdin.take().expect("stdin was piped");
302 let stdout = child.stdout.take().expect("stdout was piped");
303 let mut stderr = child.stderr.take().expect("stderr was piped");
304 let mut body = request.body;
305
306 // The request body streams in while the response streams out; a push is far too
307 // large to buffer, and a fetch would otherwise wait for a body it already has.
308 // Dropping stdin closes the pipe, which is what tells the backend the request is
309 // complete — an error here is the client having gone away, which the backend
310 // then sees as EOF.
311 tokio::spawn(async move {
312 let _ = tokio::io::copy(&mut body, &mut stdin).await;
313 });
314
315 // Reaps the child and surfaces its complaint. This cannot gate the response: a
316 // protocol failure exits non-zero *after* a complete, successful-looking header
317 // block has already been written, so by the time the status is known it has been
318 // sent. Draining stderr is not optional either — an unread pipe fills and blocks
319 // the backend mid-transfer.
320 tokio::spawn(async move {
321 let mut complaint = String::new();
322 let _ = stderr.read_to_string(&mut complaint).await;
323
324 match child.wait().await {
325 Ok(status) if status.success() => {}
326 Ok(status) => eprintln!(
327 "steid: git http-backend exited with {status}: {}",
328 complaint.trim()
329 ),
330 Err(error) => eprintln!("steid: could not wait for git http-backend: {error}"),
331 }
332 });
333
334 // `BufReader` keeps whatever it read past the header block, and handing the
335 // reader itself back as the body is what makes that safe — the first bytes of
336 // the pack are already buffered inside it.
337 let mut reader = BufReader::new(stdout);
338 let (status, headers) = read_cgi_headers(&mut reader).await?;
339
340 Ok(GitResponse {
341 status,
342 headers,
343 body: Box::pin(reader),
344 })
345 }
346}
347
348/// Reads the CGI header block, stopping at the blank line that ends it.
349///
350/// `Status:` is git's way of reporting failure and appears only then, so its absence
351/// means 200. It is translated into the response status rather than forwarded as a
352/// header, which would be meaningless to a client.
353async fn read_cgi_headers(
354 reader: &mut BufReader<ChildStdout>,
355) -> Result<(u16, Vec<(String, String)>), GitProtocolError> {
356 let mut status = 200;
357 let mut headers = Vec::new();
358 let mut line = Vec::new();
359
360 loop {
361 line.clear();
362
363 let read = reader
364 .read_until(b'\n', &mut line)
365 .await
366 .map_err(|error| GitProtocolError::new(format!("reading git's headers: {error}")))?;
367
368 if read == 0 {
369 return Err(GitProtocolError::new(
370 "git http-backend produced no headers before closing",
371 ));
372 }
373
374 // Tolerates a bare LF as well as the CRLF actually observed: a header reader
375 // that hangs on an unexpected line ending is a bad way to find out.
376 let text = String::from_utf8_lossy(&line);
377 let text = text.trim_end_matches(['\r', '\n']);
378
379 if text.is_empty() {
380 return Ok((status, headers));
381 }
382
383 let Some((name, value)) = text.split_once(": ") else {
384 return Err(GitProtocolError::new(format!(
385 "git http-backend wrote an unparseable header: {text:?}"
386 )));
387 };
388
389 if name.eq_ignore_ascii_case("status") {
390 status = value
391 .split_whitespace()
392 .next()
393 .and_then(|code| code.parse().ok())
394 .ok_or_else(|| {
395 GitProtocolError::new(format!("git http-backend wrote a bad status: {value:?}"))
396 })?;
397 } else {
398 headers.push((name.to_owned(), value.to_owned()));
399 }
400
401 if headers.len() > MAX_CGI_HEADERS {
402 return Err(GitProtocolError::new(
403 "git http-backend wrote more headers than a CGI response can plausibly have",
404 ));
405 }
406 }
407}
408
409/// What a [`InMemoryGitProtocol`] was asked for, minus the body.
410///
411/// The body is a stream and comparing it would mean draining it; every rule worth
412/// asserting on lives in the metadata anyway.
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct RecordedGitRequest {
415 pub method: GitMethod,
416 pub path_info: String,
417 pub query: String,
418 pub git_protocol: Option<String>,
419 pub content_encoding: Option<String>,
420 pub allow_receive_pack: bool,
421}
422
423/// A git protocol that records what it was asked and never runs git.
424///
425/// The counterpart to [`GitHttpBackend`]. What it is really for is proving a negative:
426/// that a use case refused *before* reaching the protocol. `was_called` is how a test
427/// says "and no bytes flowed".
428#[derive(Debug, Default, Clone)]
429pub struct InMemoryGitProtocol {
430 requests: Arc<Mutex<Vec<RecordedGitRequest>>>,
431}
432
433impl InMemoryGitProtocol {
434 pub fn new() -> Self {
435 Self::default()
436 }
437
438 pub fn requests(&self) -> Vec<RecordedGitRequest> {
439 self.requests.lock().expect("lock poisoned").clone()
440 }
441
442 /// Whether the protocol was reached at all.
443 pub fn was_called(&self) -> bool {
444 !self.requests.lock().expect("lock poisoned").is_empty()
445 }
446}
447
448impl GitProtocolServer for InMemoryGitProtocol {
449 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
450 self.requests
451 .lock()
452 .expect("lock poisoned")
453 .push(RecordedGitRequest {
454 method: request.method,
455 path_info: request.path_info,
456 query: request.query,
457 git_protocol: request.git_protocol,
458 content_encoding: request.content_encoding,
459 allow_receive_pack: request.allow_receive_pack,
460 });
461
462 Ok(GitResponse {
463 status: 200,
464 headers: vec![(
465 "Content-Type".to_owned(),
466 "application/x-git-upload-pack-advertisement".to_owned(),
467 )],
468 body: Box::pin(std::io::Cursor::new(b"0000".to_vec())),
469 })
470 }
471}
472
473/// A repository's contents, held in memory, for testing use cases and pages.
474///
475/// The counterpart to `DiskGitQuery`. Seeded with exactly what a test needs rather than
476/// pretending to be a git implementation: it answers the questions the port asks and
477/// knows nothing about how a real repository stores them.
478#[derive(Debug, Default, Clone)]
479pub struct InMemoryGitQuery {
480 default_branch: Option<RefName>,
481 /// Keyed `rev\0path`, because a tree only means anything at a revision.
482 trees: HashMap<String, Vec<TreeEntry>>,
483 blobs: HashMap<String, Vec<u8>>,
484 commits: Vec<CommitSummary>,
485 /// Branches and tags, in whatever order a test seeded them — the real adapter makes
486 /// no ordering promise either.
487 refs: Vec<GitRef>,
488}
489
490impl InMemoryGitQuery {
491 /// An empty repository: no default branch, so nothing has been pushed.
492 pub fn empty() -> Self {
493 Self::default()
494 }
495
496 /// A repository whose default branch is `main`.
497 pub fn new() -> Self {
498 Self {
499 default_branch: Some(RefName::from_trusted("main")),
500 ..Self::default()
501 }
502 }
503
504 fn key(rev: &RefName, path: &RepoPath) -> String {
505 format!("{}\0{}", rev.as_str(), path.as_str())
506 }
507
508 pub fn with_tree(mut self, rev: &str, path: &str, entries: Vec<TreeEntry>) -> Self {
509 self.trees.insert(
510 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
511 entries,
512 );
513 self
514 }
515
516 pub fn with_blob(mut self, rev: &str, path: &str, content: impl Into<Vec<u8>>) -> Self {
517 self.blobs.insert(
518 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
519 content.into(),
520 );
521 self
522 }
523
524 pub fn with_log(mut self, commits: Vec<CommitSummary>) -> Self {
525 self.commits = commits;
526 self
527 }
528
529 pub fn with_branch(self, name: &str) -> Self {
530 self.with_ref(name, RefKind::Branch)
531 }
532
533 pub fn with_tag(self, name: &str) -> Self {
534 self.with_ref(name, RefKind::Tag)
535 }
536
537 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
538 self.refs.push(GitRef {
539 name: RefName::from_trusted(name),
540 kind,
541 });
542 self
543 }
544}
545
546impl GitQuery for InMemoryGitQuery {
547 async fn default_branch(
548 &self,
549 _handle: &OrgName,
550 _name: &RepoName,
551 ) -> Result<Option<RefName>, GitQueryError> {
552 Ok(self.default_branch.clone())
553 }
554
555 async fn resolve(
556 &self,
557 _handle: &OrgName,
558 _name: &RepoName,
559 _rev: &RefName,
560 ) -> Result<Option<ObjectId>, GitQueryError> {
561 Ok(self
562 .default_branch
563 .as_ref()
564 .map(|_| ObjectId::from_trusted("0".repeat(40))))
565 }
566
567 async fn list_tree(
568 &self,
569 _handle: &OrgName,
570 _name: &RepoName,
571 rev: &RefName,
572 path: &RepoPath,
573 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
574 Ok(self.trees.get(&Self::key(rev, path)).cloned())
575 }
576
577 async fn read_blob(
578 &self,
579 _handle: &OrgName,
580 _name: &RepoName,
581 rev: &RefName,
582 path: &RepoPath,
583 max_bytes: u64,
584 ) -> Result<Option<Blob>, GitQueryError> {
585 Ok(self.blobs.get(&Self::key(rev, path)).map(|content| {
586 let size = content.len() as u64;
587
588 Blob {
589 id: ObjectId::from_trusted("1".repeat(40)),
590 size,
591 // The same cap the real adapter applies, so a test can exercise the
592 // too-large path without a megabyte of fixture.
593 content: (size <= max_bytes).then(|| content.clone()),
594 }
595 }))
596 }
597
598 async fn log(
599 &self,
600 _handle: &OrgName,
601 _name: &RepoName,
602 _rev: &RefName,
603 limit: usize,
604 ) -> Result<Vec<CommitSummary>, GitQueryError> {
605 Ok(self.commits.iter().take(limit).cloned().collect())
606 }
607
608 async fn list_refs(
609 &self,
610 _handle: &OrgName,
611 _name: &RepoName,
612 ) -> Result<Vec<GitRef>, GitQueryError> {
613 Ok(self.refs.clone())
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use std::path::Path;
620
621 use tempfile::TempDir;
622
623 use super::*;
624
625 /// The `TempDir` is returned alongside the storage because dropping it deletes the
626 /// data directory — binding it to `_` would remove the fixture mid-test.
627 fn storage() -> (TempDir, DiskGitStorage) {
628 let dir = TempDir::new().expect("temp dir");
629 let storage = DiskGitStorage::new(dir.path());
630 (dir, storage)
631 }
632
633 fn handle() -> OrgName {
634 OrgName::new("jamesgill").expect("valid handle")
635 }
636
637 fn repo_name(value: &str) -> RepoName {
638 RepoName::new(value).expect("valid repository name")
639 }
640
641 /// Asks git about a repository, so assertions test what git believes rather than
642 /// what the directory looks like.
643 fn git_says(path: &Path, args: &[&str]) -> String {
644 let output = std::process::Command::new("git")
645 .arg("-C")
646 .arg(path)
647 .args(args)
648 .output()
649 .expect("git should be on PATH");
650
651 assert!(
652 output.status.success(),
653 "git {args:?} failed: {}",
654 String::from_utf8_lossy(&output.stderr)
655 );
656
657 String::from_utf8_lossy(&output.stdout).trim().to_owned()
658 }
659
660 #[tokio::test]
661 async fn init_bare_creates_a_bare_repository() {
662 let (_dir, storage) = storage();
663
664 storage
665 .init_bare(&handle(), &repo_name("steid"))
666 .await
667 .expect("should create");
668
669 let path = storage.repo_path(&handle(), &repo_name("steid"));
670 assert!(path.is_dir(), "expected a repository at {path:?}");
671 assert_eq!(
672 git_says(&path, &["rev-parse", "--is-bare-repository"]),
673 "true"
674 );
675 }
676
677 #[tokio::test]
678 async fn a_new_repository_is_empty() {
679 // Empty, like GitHub: no initial commit and no branch yet.
680 let (_dir, storage) = storage();
681 storage
682 .init_bare(&handle(), &repo_name("steid"))
683 .await
684 .expect("should create");
685
686 let path = storage.repo_path(&handle(), &repo_name("steid"));
687
688 assert_eq!(git_says(&path, &["for-each-ref"]), "");
689 }
690
691 #[tokio::test]
692 async fn a_new_repository_defaults_to_main() {
693 // Pinned so the host's `init.defaultBranch` cannot decide this. It currently
694 // agrees on this machine, which is exactly why a drift would go unnoticed.
695 let (_dir, storage) = storage();
696 storage
697 .init_bare(&handle(), &repo_name("steid"))
698 .await
699 .expect("should create");
700
701 let path = storage.repo_path(&handle(), &repo_name("steid"));
702
703 assert_eq!(
704 git_says(&path, &["symbolic-ref", "HEAD"]),
705 "refs/heads/main"
706 );
707 }
708
709 #[tokio::test]
710 async fn no_sample_hooks_are_installed() {
711 // Pins `--template=`. A default init seeds sixteen `.sample` files.
712 let (_dir, storage) = storage();
713 storage
714 .init_bare(&handle(), &repo_name("steid"))
715 .await
716 .expect("should create");
717
718 let hooks = storage
719 .repo_path(&handle(), &repo_name("steid"))
720 .join("hooks");
721
722 let samples = std::fs::read_dir(&hooks)
723 .map(|entries| entries.count())
724 .unwrap_or(0);
725 assert_eq!(samples, 0, "expected no templated hooks in {hooks:?}");
726 }
727
728 #[tokio::test]
729 async fn init_bare_creates_the_handle_directory() {
730 let (dir, storage) = storage();
731 assert!(!dir.path().join("jamesgill").exists());
732
733 storage
734 .init_bare(&handle(), &repo_name("steid"))
735 .await
736 .expect("should create");
737
738 assert!(dir.path().join("jamesgill").is_dir());
739 }
740
741 #[tokio::test]
742 async fn one_handle_can_own_several_repositories() {
743 let (_dir, storage) = storage();
744
745 for name in ["steid", "foo.js", ".github"] {
746 storage
747 .init_bare(&handle(), &repo_name(name))
748 .await
749 .unwrap_or_else(|error| panic!("{name} should create: {error}"));
750 }
751
752 for name in ["steid", "foo.js", ".github"] {
753 assert!(storage.repo_path(&handle(), &repo_name(name)).is_dir());
754 }
755 }
756
757 #[tokio::test]
758 async fn init_bare_refuses_a_repository_that_already_exists() {
759 let (_dir, storage) = storage();
760 storage
761 .init_bare(&handle(), &repo_name("steid"))
762 .await
763 .expect("should create");
764
765 let error = storage
766 .init_bare(&handle(), &repo_name("steid"))
767 .await
768 .expect_err("should refuse");
769
770 assert!(matches!(error, GitStorageError::AlreadyExists));
771 }
772
773 #[tokio::test]
774 async fn a_refused_init_leaves_the_existing_repository_alone() {
775 // git would happily re-initialise in place. The point of refusing is that
776 // whatever is already there is not touched.
777 let (_dir, storage) = storage();
778 storage
779 .init_bare(&handle(), &repo_name("steid"))
780 .await
781 .expect("should create");
782
783 let path = storage.repo_path(&handle(), &repo_name("steid"));
784 let marker = path.join("objects").join("marker");
785 std::fs::write(&marker, b"existing data").expect("write marker");
786
787 let _ = storage.init_bare(&handle(), &repo_name("steid")).await;
788
789 assert_eq!(
790 std::fs::read(&marker).expect("marker should survive"),
791 b"existing data"
792 );
793 }
794
795 #[tokio::test]
796 async fn repo_path_creates_nothing() {
797 let (dir, storage) = storage();
798
799 let path = storage.repo_path(&handle(), &repo_name("never-created"));
800
801 assert!(!path.exists());
802 assert_eq!(
803 std::fs::read_dir(dir.path())
804 .expect("data dir should exist")
805 .count(),
806 0,
807 "repo_path must be pure"
808 );
809 }
810
811 #[tokio::test]
812 async fn repo_path_lands_under_the_data_directory() {
813 let (dir, storage) = storage();
814
815 let path = storage.repo_path(&handle(), &repo_name("steid"));
816
817 assert_eq!(path, dir.path().join("jamesgill").join("steid.git"));
818 }
819
820 #[tokio::test]
821 async fn remove_deletes_the_repository() {
822 let (_dir, storage) = storage();
823 storage
824 .init_bare(&handle(), &repo_name("steid"))
825 .await
826 .expect("should create");
827
828 storage
829 .remove(&handle(), &repo_name("steid"))
830 .await
831 .expect("should remove");
832
833 assert!(!storage.repo_path(&handle(), &repo_name("steid")).exists());
834 }
835
836 #[tokio::test]
837 async fn removing_what_is_not_there_succeeds() {
838 // Compensation runs when a create failed, which may be before anything landed.
839 let (_dir, storage) = storage();
840
841 storage
842 .remove(&handle(), &repo_name("never-created"))
843 .await
844 .expect("should succeed with nothing to do");
845 }
846
847 #[tokio::test]
848 async fn a_compensated_create_can_be_retried() {
849 // The whole point of `remove`: create, fail to record it, undo, try again.
850 let (_dir, storage) = storage();
851
852 storage
853 .init_bare(&handle(), &repo_name("steid"))
854 .await
855 .expect("should create");
856 storage
857 .remove(&handle(), &repo_name("steid"))
858 .await
859 .expect("should remove");
860 storage
861 .init_bare(&handle(), &repo_name("steid"))
862 .await
863 .expect("should create again");
864 }
865
866 #[tokio::test]
867 async fn removing_one_repository_leaves_its_neighbours() {
868 let (_dir, storage) = storage();
869 storage
870 .init_bare(&handle(), &repo_name("steid"))
871 .await
872 .expect("should create");
873 storage
874 .init_bare(&handle(), &repo_name("keeper"))
875 .await
876 .expect("should create");
877
878 storage
879 .remove(&handle(), &repo_name("steid"))
880 .await
881 .expect("should remove");
882
883 assert!(storage.repo_path(&handle(), &repo_name("keeper")).is_dir());
884 }
885
886 #[tokio::test]
887 async fn a_failing_git_invocation_carries_gits_own_message() {
888 let error = run_git(["not-a-real-subcommand"])
889 .await
890 .expect_err("should fail");
891
892 let message = error.to_string();
893 assert!(
894 message.contains("not-a-real-subcommand"),
895 "expected git's own words, got: {message}"
896 );
897 }
898
899 // --- GitHttpBackend --------------------------------------------------------
900
901 /// A data directory holding one bare repository at `acme/steid.git`.
902 async fn backend() -> (TempDir, GitHttpBackend) {
903 let dir = TempDir::new().expect("temp dir");
904 let storage = DiskGitStorage::new(dir.path());
905
906 let acme = OrgName::new("acme").expect("valid handle");
907 storage
908 .init_bare(&acme, &repo_name("steid"))
909 .await
910 .expect("init bare");
911
912 let backend = GitHttpBackend::new(dir.path());
913 (dir, backend)
914 }
915
916 fn advertisement(path_info: &str) -> GitRequest {
917 GitRequest {
918 method: GitMethod::Get,
919 path_info: path_info.to_owned(),
920 query: "service=git-upload-pack".to_owned(),
921 content_type: None,
922 content_encoding: None,
923 content_length: None,
924 git_protocol: None,
925 allow_receive_pack: false,
926 body: Box::pin(tokio::io::empty()),
927 }
928 }
929
930 async fn drain(response: GitResponse) -> Vec<u8> {
931 let mut body = response.body;
932 let mut bytes = Vec::new();
933 body.read_to_end(&mut bytes).await.expect("read body");
934 bytes
935 }
936
937 #[tokio::test]
938 async fn the_backend_advertises_refs() {
939 let (_dir, backend) = backend().await;
940
941 let response = backend
942 .serve(advertisement("/acme/steid.git/info/refs"))
943 .await
944 .expect("should serve");
945
946 assert_eq!(response.status, 200);
947 assert!(
948 response
949 .headers
950 .iter()
951 .any(|(name, value)| name == "Content-Type"
952 && value == "application/x-git-upload-pack-advertisement"),
953 "git sets its own content type and we forward it: {:?}",
954 response.headers
955 );
956
957 // The pkt-line the smart protocol opens with. Getting this from git rather than
958 // writing it is the whole reason the backend is a subprocess.
959 let body = drain(response).await;
960 assert!(
961 body.starts_with(b"001e# service=git-upload-pack\n"),
962 "unexpected advertisement: {:?}",
963 String::from_utf8_lossy(&body[..body.len().min(40)])
964 );
965 }
966
967 #[tokio::test]
968 async fn a_missing_repository_is_reported_as_404_not_as_a_failure() {
969 // Failure arrives in the CGI stream, not the exit code: git exits 0 here and
970 // says 404 in a header. Keying off the exit code instead would answer 200.
971 let (_dir, backend) = backend().await;
972
973 let response = backend
974 .serve(advertisement("/acme/nothing-here.git/info/refs"))
975 .await
976 .expect("serving should not itself fail");
977
978 assert_eq!(response.status, 404);
979 }
980
981 #[tokio::test]
982 async fn the_status_header_is_translated_rather_than_forwarded() {
983 let (_dir, backend) = backend().await;
984
985 let response = backend
986 .serve(advertisement("/acme/nothing-here.git/info/refs"))
987 .await
988 .expect("should serve");
989
990 assert!(
991 !response
992 .headers
993 .iter()
994 .any(|(name, _)| name.eq_ignore_ascii_case("status")),
995 "Status: is CGI's, and means nothing to an HTTP client: {:?}",
996 response.headers
997 );
998 }
999
1000 #[tokio::test]
1001 async fn the_protocol_version_reaches_upload_pack() {
1002 // Protocol v2 answers an advertisement with a capability list rather than refs.
1003 // If `HTTP_GIT_PROTOCOL` is dropped the exchange silently falls back to v0, which
1004 // still works — so nothing fails, it just quietly gets worse.
1005 let (_dir, backend) = backend().await;
1006
1007 let mut request = advertisement("/acme/steid.git/info/refs");
1008 request.git_protocol = Some("version=2".to_owned());
1009
1010 let body = drain(backend.serve(request).await.expect("should serve")).await;
1011
1012 assert!(
1013 String::from_utf8_lossy(&body).contains("version 2"),
1014 "expected a v2 capability advertisement: {:?}",
1015 String::from_utf8_lossy(&body[..body.len().min(80)])
1016 );
1017 }
1018
1019 #[tokio::test]
1020 async fn the_in_memory_protocol_records_what_it_was_asked() {
1021 let protocol = InMemoryGitProtocol::new();
1022
1023 protocol
1024 .serve(advertisement("/acme/steid.git/info/refs"))
1025 .await
1026 .expect("should serve");
1027
1028 assert_eq!(
1029 protocol.requests(),
1030 vec![RecordedGitRequest {
1031 method: GitMethod::Get,
1032 path_info: "/acme/steid.git/info/refs".to_owned(),
1033 query: "service=git-upload-pack".to_owned(),
1034 git_protocol: None,
1035 content_encoding: None,
1036 allow_receive_pack: false,
1037 }]
1038 );
1039 assert!(protocol.was_called());
1040 }
1041}