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