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 BranchRow, CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
29 TagRow, TagSummary, 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 /// The branches and tags pages' richer rows, seeded separately from `refs`: the two
495 /// answer different questions and a test usually wants only one of them.
496 branch_rows: Vec<BranchRow>,
497 tag_rows: Vec<TagRow>,
498}
499
500impl InMemoryGitQuery {
501 /// An empty repository: no default branch, so nothing has been pushed.
502 pub fn empty() -> Self {
503 Self::default()
504 }
505
506 /// A repository whose default branch is `main`.
507 pub fn new() -> Self {
508 Self {
509 default_branch: Some(RefName::from_trusted("main")),
510 ..Self::default()
511 }
512 }
513
514 fn key(rev: &RefName, path: &RepoPath) -> String {
515 format!("{}\0{}", rev.as_str(), path.as_str())
516 }
517
518 pub fn with_tree(mut self, rev: &str, path: &str, entries: Vec<TreeEntry>) -> Self {
519 self.trees.insert(
520 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
521 entries,
522 );
523 self
524 }
525
526 pub fn with_blob(mut self, rev: &str, path: &str, content: impl Into<Vec<u8>>) -> Self {
527 self.blobs.insert(
528 Self::key(&RefName::from_trusted(rev), &RepoPath::from_trusted(path)),
529 content.into(),
530 );
531 self
532 }
533
534 pub fn with_log(mut self, commits: Vec<CommitSummary>) -> Self {
535 self.commits = commits;
536 self
537 }
538
539 pub fn with_branch(self, name: &str) -> Self {
540 self.with_ref(name, RefKind::Branch)
541 }
542
543 pub fn with_tag(self, name: &str) -> Self {
544 self.with_ref(name, RefKind::Tag)
545 }
546
547 pub fn with_commit_count(mut self, count: u64) -> Self {
548 self.commit_count = Some(count);
549 self
550 }
551
552 pub fn with_latest_tag(mut self, name: &str, created_at: SystemTime) -> Self {
553 self.latest_tag = Some(TagSummary {
554 name: RefName::from_trusted(name),
555 created_at,
556 });
557 self
558 }
559
560 /// One row of the branches page. `is_default` is what git's `%(HEAD)` marks.
561 pub fn with_branch_row(
562 mut self,
563 name: &str,
564 is_default: bool,
565 committed_at: SystemTime,
566 ) -> Self {
567 self.branch_rows.push(BranchRow {
568 name: RefName::from_trusted(name),
569 is_default,
570 commit: ObjectId::from_trusted("2".repeat(40)),
571 summary: format!("work on {name}"),
572 committed_at,
573 });
574 self
575 }
576
577 /// One row of the tags page. An annotated tag carries a message; a lightweight one
578 /// has none of its own.
579 pub fn with_tag_row(mut self, name: &str, annotated: bool, created_at: SystemTime) -> Self {
580 self.tag_rows.push(TagRow {
581 name: RefName::from_trusted(name),
582 commit: ObjectId::from_trusted("3".repeat(40)),
583 message: annotated.then(|| format!("release {name}")),
584 annotated,
585 created_at,
586 });
587 self
588 }
589
590 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
591 self.refs.push(GitRef {
592 name: RefName::from_trusted(name),
593 kind,
594 });
595 self
596 }
597}
598
599impl GitQuery for InMemoryGitQuery {
600 async fn default_branch(
601 &self,
602 _handle: &OrgName,
603 _name: &RepoName,
604 ) -> Result<Option<RefName>, GitQueryError> {
605 Ok(self.default_branch.clone())
606 }
607
608 async fn resolve(
609 &self,
610 _handle: &OrgName,
611 _name: &RepoName,
612 _rev: &RefName,
613 ) -> Result<Option<ObjectId>, GitQueryError> {
614 Ok(self
615 .default_branch
616 .as_ref()
617 .map(|_| ObjectId::from_trusted("0".repeat(40))))
618 }
619
620 async fn list_tree(
621 &self,
622 _handle: &OrgName,
623 _name: &RepoName,
624 rev: &RefName,
625 path: &RepoPath,
626 ) -> Result<Option<Vec<TreeEntry>>, GitQueryError> {
627 Ok(self.trees.get(&Self::key(rev, path)).cloned())
628 }
629
630 async fn read_blob(
631 &self,
632 _handle: &OrgName,
633 _name: &RepoName,
634 rev: &RefName,
635 path: &RepoPath,
636 max_bytes: u64,
637 ) -> Result<Option<Blob>, GitQueryError> {
638 Ok(self.blobs.get(&Self::key(rev, path)).map(|content| {
639 let size = content.len() as u64;
640
641 Blob {
642 id: ObjectId::from_trusted("1".repeat(40)),
643 size,
644 // The same cap the real adapter applies, so a test can exercise the
645 // too-large path without a megabyte of fixture.
646 content: (size <= max_bytes).then(|| content.clone()),
647 }
648 }))
649 }
650
651 async fn log(
652 &self,
653 _handle: &OrgName,
654 _name: &RepoName,
655 _rev: &RefName,
656 limit: usize,
657 ) -> Result<Vec<CommitSummary>, GitQueryError> {
658 Ok(self.commits.iter().take(limit).cloned().collect())
659 }
660
661 async fn list_refs(
662 &self,
663 _handle: &OrgName,
664 _name: &RepoName,
665 ) -> Result<Vec<GitRef>, GitQueryError> {
666 Ok(self.refs.clone())
667 }
668
669 async fn count_commits(
670 &self,
671 _handle: &OrgName,
672 _name: &RepoName,
673 _rev: &RefName,
674 ) -> Result<u64, GitQueryError> {
675 Ok(self.commit_count.unwrap_or(self.commits.len() as u64))
676 }
677
678 async fn latest_tag(
679 &self,
680 _handle: &OrgName,
681 _name: &RepoName,
682 ) -> Result<Option<TagSummary>, GitQueryError> {
683 Ok(self.latest_tag.clone())
684 }
685
686 async fn branches(
687 &self,
688 _handle: &OrgName,
689 _name: &RepoName,
690 ) -> Result<Vec<BranchRow>, GitQueryError> {
691 Ok(self.branch_rows.clone())
692 }
693
694 async fn tags(
695 &self,
696 _handle: &OrgName,
697 _name: &RepoName,
698 ) -> Result<Vec<TagRow>, GitQueryError> {
699 Ok(self.tag_rows.clone())
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use std::path::Path;
706
707 use tempfile::TempDir;
708
709 use super::*;
710
711 /// The `TempDir` is returned alongside the storage because dropping it deletes the
712 /// data directory — binding it to `_` would remove the fixture mid-test.
713 fn storage() -> (TempDir, DiskGitStorage) {
714 let dir = TempDir::new().expect("temp dir");
715 let storage = DiskGitStorage::new(dir.path());
716 (dir, storage)
717 }
718
719 fn handle() -> OrgName {
720 OrgName::new("jamesgill").expect("valid handle")
721 }
722
723 fn repo_name(value: &str) -> RepoName {
724 RepoName::new(value).expect("valid repository name")
725 }
726
727 /// Asks git about a repository, so assertions test what git believes rather than
728 /// what the directory looks like.
729 fn git_says(path: &Path, args: &[&str]) -> String {
730 let output = std::process::Command::new("git")
731 .arg("-C")
732 .arg(path)
733 .args(args)
734 .output()
735 .expect("git should be on PATH");
736
737 assert!(
738 output.status.success(),
739 "git {args:?} failed: {}",
740 String::from_utf8_lossy(&output.stderr)
741 );
742
743 String::from_utf8_lossy(&output.stdout).trim().to_owned()
744 }
745
746 #[tokio::test]
747 async fn init_bare_creates_a_bare_repository() {
748 let (_dir, storage) = storage();
749
750 storage
751 .init_bare(&handle(), &repo_name("steid"))
752 .await
753 .expect("should create");
754
755 let path = storage.repo_path(&handle(), &repo_name("steid"));
756 assert!(path.is_dir(), "expected a repository at {path:?}");
757 assert_eq!(
758 git_says(&path, &["rev-parse", "--is-bare-repository"]),
759 "true"
760 );
761 }
762
763 #[tokio::test]
764 async fn a_new_repository_is_empty() {
765 // Empty, like GitHub: no initial commit and no branch yet.
766 let (_dir, storage) = storage();
767 storage
768 .init_bare(&handle(), &repo_name("steid"))
769 .await
770 .expect("should create");
771
772 let path = storage.repo_path(&handle(), &repo_name("steid"));
773
774 assert_eq!(git_says(&path, &["for-each-ref"]), "");
775 }
776
777 #[tokio::test]
778 async fn a_new_repository_defaults_to_main() {
779 // Pinned so the host's `init.defaultBranch` cannot decide this. It currently
780 // agrees on this machine, which is exactly why a drift would go unnoticed.
781 let (_dir, storage) = storage();
782 storage
783 .init_bare(&handle(), &repo_name("steid"))
784 .await
785 .expect("should create");
786
787 let path = storage.repo_path(&handle(), &repo_name("steid"));
788
789 assert_eq!(
790 git_says(&path, &["symbolic-ref", "HEAD"]),
791 "refs/heads/main"
792 );
793 }
794
795 #[tokio::test]
796 async fn no_sample_hooks_are_installed() {
797 // Pins `--template=`. A default init seeds sixteen `.sample` files.
798 let (_dir, storage) = storage();
799 storage
800 .init_bare(&handle(), &repo_name("steid"))
801 .await
802 .expect("should create");
803
804 let hooks = storage
805 .repo_path(&handle(), &repo_name("steid"))
806 .join("hooks");
807
808 let samples = std::fs::read_dir(&hooks)
809 .map(|entries| entries.count())
810 .unwrap_or(0);
811 assert_eq!(samples, 0, "expected no templated hooks in {hooks:?}");
812 }
813
814 #[tokio::test]
815 async fn init_bare_creates_the_handle_directory() {
816 let (dir, storage) = storage();
817 assert!(!dir.path().join("jamesgill").exists());
818
819 storage
820 .init_bare(&handle(), &repo_name("steid"))
821 .await
822 .expect("should create");
823
824 assert!(dir.path().join("jamesgill").is_dir());
825 }
826
827 #[tokio::test]
828 async fn one_handle_can_own_several_repositories() {
829 let (_dir, storage) = storage();
830
831 for name in ["steid", "foo.js", ".github"] {
832 storage
833 .init_bare(&handle(), &repo_name(name))
834 .await
835 .unwrap_or_else(|error| panic!("{name} should create: {error}"));
836 }
837
838 for name in ["steid", "foo.js", ".github"] {
839 assert!(storage.repo_path(&handle(), &repo_name(name)).is_dir());
840 }
841 }
842
843 #[tokio::test]
844 async fn init_bare_refuses_a_repository_that_already_exists() {
845 let (_dir, storage) = storage();
846 storage
847 .init_bare(&handle(), &repo_name("steid"))
848 .await
849 .expect("should create");
850
851 let error = storage
852 .init_bare(&handle(), &repo_name("steid"))
853 .await
854 .expect_err("should refuse");
855
856 assert!(matches!(error, GitStorageError::AlreadyExists));
857 }
858
859 #[tokio::test]
860 async fn a_refused_init_leaves_the_existing_repository_alone() {
861 // git would happily re-initialise in place. The point of refusing is that
862 // whatever is already there is not touched.
863 let (_dir, storage) = storage();
864 storage
865 .init_bare(&handle(), &repo_name("steid"))
866 .await
867 .expect("should create");
868
869 let path = storage.repo_path(&handle(), &repo_name("steid"));
870 let marker = path.join("objects").join("marker");
871 std::fs::write(&marker, b"existing data").expect("write marker");
872
873 let _ = storage.init_bare(&handle(), &repo_name("steid")).await;
874
875 assert_eq!(
876 std::fs::read(&marker).expect("marker should survive"),
877 b"existing data"
878 );
879 }
880
881 #[tokio::test]
882 async fn repo_path_creates_nothing() {
883 let (dir, storage) = storage();
884
885 let path = storage.repo_path(&handle(), &repo_name("never-created"));
886
887 assert!(!path.exists());
888 assert_eq!(
889 std::fs::read_dir(dir.path())
890 .expect("data dir should exist")
891 .count(),
892 0,
893 "repo_path must be pure"
894 );
895 }
896
897 #[tokio::test]
898 async fn repo_path_lands_under_the_data_directory() {
899 let (dir, storage) = storage();
900
901 let path = storage.repo_path(&handle(), &repo_name("steid"));
902
903 assert_eq!(path, dir.path().join("jamesgill").join("steid.git"));
904 }
905
906 #[tokio::test]
907 async fn remove_deletes_the_repository() {
908 let (_dir, storage) = storage();
909 storage
910 .init_bare(&handle(), &repo_name("steid"))
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("steid")).exists());
920 }
921
922 #[tokio::test]
923 async fn removing_what_is_not_there_succeeds() {
924 // Compensation runs when a create failed, which may be before anything landed.
925 let (_dir, storage) = storage();
926
927 storage
928 .remove(&handle(), &repo_name("never-created"))
929 .await
930 .expect("should succeed with nothing to do");
931 }
932
933 #[tokio::test]
934 async fn a_compensated_create_can_be_retried() {
935 // The whole point of `remove`: create, fail to record it, undo, try again.
936 let (_dir, storage) = storage();
937
938 storage
939 .init_bare(&handle(), &repo_name("steid"))
940 .await
941 .expect("should create");
942 storage
943 .remove(&handle(), &repo_name("steid"))
944 .await
945 .expect("should remove");
946 storage
947 .init_bare(&handle(), &repo_name("steid"))
948 .await
949 .expect("should create again");
950 }
951
952 #[tokio::test]
953 async fn removing_one_repository_leaves_its_neighbours() {
954 let (_dir, storage) = storage();
955 storage
956 .init_bare(&handle(), &repo_name("steid"))
957 .await
958 .expect("should create");
959 storage
960 .init_bare(&handle(), &repo_name("keeper"))
961 .await
962 .expect("should create");
963
964 storage
965 .remove(&handle(), &repo_name("steid"))
966 .await
967 .expect("should remove");
968
969 assert!(storage.repo_path(&handle(), &repo_name("keeper")).is_dir());
970 }
971
972 #[tokio::test]
973 async fn a_failing_git_invocation_carries_gits_own_message() {
974 let error = run_git(["not-a-real-subcommand"])
975 .await
976 .expect_err("should fail");
977
978 let message = error.to_string();
979 assert!(
980 message.contains("not-a-real-subcommand"),
981 "expected git's own words, got: {message}"
982 );
983 }
984
985 // --- GitHttpBackend --------------------------------------------------------
986
987 /// A data directory holding one bare repository at `acme/steid.git`.
988 async fn backend() -> (TempDir, GitHttpBackend) {
989 let dir = TempDir::new().expect("temp dir");
990 let storage = DiskGitStorage::new(dir.path());
991
992 let acme = OrgName::new("acme").expect("valid handle");
993 storage
994 .init_bare(&acme, &repo_name("steid"))
995 .await
996 .expect("init bare");
997
998 let backend = GitHttpBackend::new(dir.path());
999 (dir, backend)
1000 }
1001
1002 fn advertisement(path_info: &str) -> GitRequest {
1003 GitRequest {
1004 method: GitMethod::Get,
1005 path_info: path_info.to_owned(),
1006 query: "service=git-upload-pack".to_owned(),
1007 content_type: None,
1008 content_encoding: None,
1009 content_length: None,
1010 git_protocol: None,
1011 allow_receive_pack: false,
1012 body: Box::pin(tokio::io::empty()),
1013 }
1014 }
1015
1016 async fn drain(response: GitResponse) -> Vec<u8> {
1017 let mut body = response.body;
1018 let mut bytes = Vec::new();
1019 body.read_to_end(&mut bytes).await.expect("read body");
1020 bytes
1021 }
1022
1023 #[tokio::test]
1024 async fn the_backend_advertises_refs() {
1025 let (_dir, backend) = backend().await;
1026
1027 let response = backend
1028 .serve(advertisement("/acme/steid.git/info/refs"))
1029 .await
1030 .expect("should serve");
1031
1032 assert_eq!(response.status, 200);
1033 assert!(
1034 response
1035 .headers
1036 .iter()
1037 .any(|(name, value)| name == "Content-Type"
1038 && value == "application/x-git-upload-pack-advertisement"),
1039 "git sets its own content type and we forward it: {:?}",
1040 response.headers
1041 );
1042
1043 // The pkt-line the smart protocol opens with. Getting this from git rather than
1044 // writing it is the whole reason the backend is a subprocess.
1045 let body = drain(response).await;
1046 assert!(
1047 body.starts_with(b"001e# service=git-upload-pack\n"),
1048 "unexpected advertisement: {:?}",
1049 String::from_utf8_lossy(&body[..body.len().min(40)])
1050 );
1051 }
1052
1053 #[tokio::test]
1054 async fn a_missing_repository_is_reported_as_404_not_as_a_failure() {
1055 // Failure arrives in the CGI stream, not the exit code: git exits 0 here and
1056 // says 404 in a header. Keying off the exit code instead would answer 200.
1057 let (_dir, backend) = backend().await;
1058
1059 let response = backend
1060 .serve(advertisement("/acme/nothing-here.git/info/refs"))
1061 .await
1062 .expect("serving should not itself fail");
1063
1064 assert_eq!(response.status, 404);
1065 }
1066
1067 #[tokio::test]
1068 async fn the_status_header_is_translated_rather_than_forwarded() {
1069 let (_dir, backend) = backend().await;
1070
1071 let response = backend
1072 .serve(advertisement("/acme/nothing-here.git/info/refs"))
1073 .await
1074 .expect("should serve");
1075
1076 assert!(
1077 !response
1078 .headers
1079 .iter()
1080 .any(|(name, _)| name.eq_ignore_ascii_case("status")),
1081 "Status: is CGI's, and means nothing to an HTTP client: {:?}",
1082 response.headers
1083 );
1084 }
1085
1086 #[tokio::test]
1087 async fn the_protocol_version_reaches_upload_pack() {
1088 // Protocol v2 answers an advertisement with a capability list rather than refs.
1089 // If `HTTP_GIT_PROTOCOL` is dropped the exchange silently falls back to v0, which
1090 // still works — so nothing fails, it just quietly gets worse.
1091 let (_dir, backend) = backend().await;
1092
1093 let mut request = advertisement("/acme/steid.git/info/refs");
1094 request.git_protocol = Some("version=2".to_owned());
1095
1096 let body = drain(backend.serve(request).await.expect("should serve")).await;
1097
1098 assert!(
1099 String::from_utf8_lossy(&body).contains("version 2"),
1100 "expected a v2 capability advertisement: {:?}",
1101 String::from_utf8_lossy(&body[..body.len().min(80)])
1102 );
1103 }
1104
1105 #[tokio::test]
1106 async fn the_in_memory_protocol_records_what_it_was_asked() {
1107 let protocol = InMemoryGitProtocol::new();
1108
1109 protocol
1110 .serve(advertisement("/acme/steid.git/info/refs"))
1111 .await
1112 .expect("should serve");
1113
1114 assert_eq!(
1115 protocol.requests(),
1116 vec![RecordedGitRequest {
1117 method: GitMethod::Get,
1118 path_info: "/acme/steid.git/info/refs".to_owned(),
1119 query: "service=git-upload-pack".to_owned(),
1120 git_protocol: None,
1121 content_encoding: None,
1122 allow_receive_pack: false,
1123 }]
1124 );
1125 assert!(protocol.was_called());
1126 }
1127}