steid

@jamesgill /

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