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::HashSet,
9 ffi::OsStr,
10 io,
11 path::PathBuf,
12 process::{Output, Stdio},
13 sync::{Arc, Mutex},
14};
15
16use tokio::process::Command;
17
18use crate::{
19 application::port::{GitStorage, GitStorageError},
20 domain::{OrgName, RepoName},
21};
22
23/// Environment variables that redirect where git reads and writes data.
24///
25/// Steid's own environment must not reach into a repository's layout. These are set
26/// whenever a process is spawned from inside a git hook, which is exactly the shape
27/// Milestone 5 will have, and the failure is silent — objects land somewhere else and
28/// the repository looks empty.
29const REDIRECTING_VARS: &[&str] = &[
30 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
31 "GIT_DIR",
32 "GIT_INDEX_FILE",
33 "GIT_OBJECT_DIRECTORY",
34 "GIT_WORK_TREE",
35];
36
37/// Bare repositories on disk, laid out as `{data_dir}/{handle}/{name}.git`.
38#[derive(Debug, Clone)]
39pub struct DiskGitStorage {
40 data_dir: PathBuf,
41}
42
43impl DiskGitStorage {
44 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
45 Self {
46 data_dir: data_dir.into(),
47 }
48 }
49}
50
51impl GitStorage for DiskGitStorage {
52 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
53 let path = self.repo_path(handle, name);
54
55 // `git init` on an existing repository exits 0 and re-initialises in silence,
56 // so this check has to be ours. A directory with no matching record is an
57 // orphan from a create that died between the two writes; adopting it would
58 // resurface a private repository's objects under a fresh record.
59 if path.exists() {
60 return Err(GitStorageError::AlreadyExists);
61 }
62
63 // No `create_dir_all` for the parent: `git init` creates missing directories.
64 run_git([
65 OsStr::new("init"),
66 OsStr::new("--bare"),
67 OsStr::new("--quiet"),
68 // Skip the template directory, which otherwise seeds every repository with
69 // sixteen `.sample` hooks. Steid installs its own hooks later, and they
70 // would be noise to work around.
71 OsStr::new("--template="),
72 // Explicit, so the host's `init.defaultBranch` cannot decide what the
73 // default branch of a Steid repository is.
74 OsStr::new("--initial-branch=main"),
75 // `RepoName` already forbids a leading hyphen; this makes it impossible for
76 // a path to be read as a flag at the boundary where it costs nothing.
77 OsStr::new("--"),
78 path.as_os_str(),
79 ])
80 .await
81 .map(|_| ())
82 }
83
84 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
85 let path = self.repo_path(handle, name);
86
87 // `tokio::fs` rather than `std::fs`: removing a repository with real history
88 // walks every loose object, which is long enough to stall a runtime worker.
89 match tokio::fs::remove_dir_all(&path).await {
90 Ok(()) => Ok(()),
91 // Compensation must not fail because there was nothing left to undo.
92 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
93 Err(error) => Err(GitStorageError::backend(error)),
94 }
95 }
96
97 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
98 // Nothing is sanitised here. `OrgName` and `RepoName` already made traversal
99 // impossible, and re-checking at the call site is how that responsibility gets
100 // diffused until nobody owns it.
101 self.data_dir
102 .join(handle.as_str())
103 .join(format!("{name}.git"))
104 }
105}
106
107/// Runs `git` and fails on a non-zero exit.
108///
109/// The single place that decides how Steid invokes git, so every call site gets the
110/// same isolation from the host: no ambient configuration, no redirected object
111/// storage, no inherited stdin. Never depends on the working directory — callers pass
112/// absolute paths.
113async fn run_git<I, S>(args: I) -> Result<Output, GitStorageError>
114where
115 I: IntoIterator<Item = S>,
116 S: AsRef<OsStr>,
117{
118 let mut command = Command::new("git");
119 command
120 .args(args)
121 // Host configuration must not leak into repositories Steid creates, for the
122 // same reason `--initial-branch` is passed explicitly.
123 .env("GIT_CONFIG_GLOBAL", "/dev/null")
124 .env("GIT_CONFIG_SYSTEM", "/dev/null")
125 .stdin(Stdio::null());
126
127 for variable in REDIRECTING_VARS {
128 command.env_remove(variable);
129 }
130
131 // `output()` pipes stdout and stderr and waits without blocking the runtime.
132 let output = command
133 .output()
134 .await
135 .map_err(|error| GitStorageError::backend(format!("could not run git: {error}")))?;
136
137 if !output.status.success() {
138 // Carry git's own words. "command failed" sends the next person to read this
139 // code instead of reading the error.
140 return Err(GitStorageError::backend(format!(
141 "git exited with {}: {}",
142 output.status,
143 String::from_utf8_lossy(&output.stderr).trim()
144 )));
145 }
146
147 Ok(output)
148}
149
150/// Bare repositories tracked in memory, for testing use cases without touching disk.
151///
152/// The counterpart to [`DiskGitStorage`], the way `StubHasher` is the counterpart to
153/// the real Argon2 hasher. It enforces the same `AlreadyExists` rule, because a use
154/// case that only passes against a permissive fake proves nothing about the real one.
155#[derive(Debug, Default, Clone)]
156pub struct InMemoryGitStorage {
157 created: Arc<Mutex<HashSet<PathBuf>>>,
158}
159
160impl InMemoryGitStorage {
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 /// Whether a repository exists, for assertions.
166 pub fn contains(&self, handle: &OrgName, name: &RepoName) -> bool {
167 self.created
168 .lock()
169 .expect("lock poisoned")
170 .contains(&self.repo_path(handle, name))
171 }
172
173 /// How many repositories exist, for asserting that nothing was created.
174 pub fn len(&self) -> usize {
175 self.created.lock().expect("lock poisoned").len()
176 }
177
178 pub fn is_empty(&self) -> bool {
179 self.len() == 0
180 }
181}
182
183impl GitStorage for InMemoryGitStorage {
184 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
185 let mut created = self.created.lock().expect("lock poisoned");
186
187 if !created.insert(self.repo_path(handle, name)) {
188 return Err(GitStorageError::AlreadyExists);
189 }
190
191 Ok(())
192 }
193
194 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
195 self.created
196 .lock()
197 .expect("lock poisoned")
198 .remove(&self.repo_path(handle, name));
199
200 Ok(())
201 }
202
203 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
204 PathBuf::from("/in-memory")
205 .join(handle.as_str())
206 .join(format!("{name}.git"))
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use std::path::Path;
213
214 use tempfile::TempDir;
215
216 use super::*;
217
218 /// The `TempDir` is returned alongside the storage because dropping it deletes the
219 /// data directory — binding it to `_` would remove the fixture mid-test.
220 fn storage() -> (TempDir, DiskGitStorage) {
221 let dir = TempDir::new().expect("temp dir");
222 let storage = DiskGitStorage::new(dir.path());
223 (dir, storage)
224 }
225
226 fn handle() -> OrgName {
227 OrgName::new("jamesgill").expect("valid handle")
228 }
229
230 fn repo_name(value: &str) -> RepoName {
231 RepoName::new(value).expect("valid repository name")
232 }
233
234 /// Asks git about a repository, so assertions test what git believes rather than
235 /// what the directory looks like.
236 fn git_says(path: &Path, args: &[&str]) -> String {
237 let output = std::process::Command::new("git")
238 .arg("-C")
239 .arg(path)
240 .args(args)
241 .output()
242 .expect("git should be on PATH");
243
244 assert!(
245 output.status.success(),
246 "git {args:?} failed: {}",
247 String::from_utf8_lossy(&output.stderr)
248 );
249
250 String::from_utf8_lossy(&output.stdout).trim().to_owned()
251 }
252
253 #[tokio::test]
254 async fn init_bare_creates_a_bare_repository() {
255 let (_dir, storage) = storage();
256
257 storage
258 .init_bare(&handle(), &repo_name("steid"))
259 .await
260 .expect("should create");
261
262 let path = storage.repo_path(&handle(), &repo_name("steid"));
263 assert!(path.is_dir(), "expected a repository at {path:?}");
264 assert_eq!(
265 git_says(&path, &["rev-parse", "--is-bare-repository"]),
266 "true"
267 );
268 }
269
270 #[tokio::test]
271 async fn a_new_repository_is_empty() {
272 // Empty, like GitHub: no initial commit and no branch yet.
273 let (_dir, storage) = storage();
274 storage
275 .init_bare(&handle(), &repo_name("steid"))
276 .await
277 .expect("should create");
278
279 let path = storage.repo_path(&handle(), &repo_name("steid"));
280
281 assert_eq!(git_says(&path, &["for-each-ref"]), "");
282 }
283
284 #[tokio::test]
285 async fn a_new_repository_defaults_to_main() {
286 // Pinned so the host's `init.defaultBranch` cannot decide this. It currently
287 // agrees on this machine, which is exactly why a drift would go unnoticed.
288 let (_dir, storage) = storage();
289 storage
290 .init_bare(&handle(), &repo_name("steid"))
291 .await
292 .expect("should create");
293
294 let path = storage.repo_path(&handle(), &repo_name("steid"));
295
296 assert_eq!(
297 git_says(&path, &["symbolic-ref", "HEAD"]),
298 "refs/heads/main"
299 );
300 }
301
302 #[tokio::test]
303 async fn no_sample_hooks_are_installed() {
304 // Pins `--template=`. A default init seeds sixteen `.sample` files.
305 let (_dir, storage) = storage();
306 storage
307 .init_bare(&handle(), &repo_name("steid"))
308 .await
309 .expect("should create");
310
311 let hooks = storage
312 .repo_path(&handle(), &repo_name("steid"))
313 .join("hooks");
314
315 let samples = std::fs::read_dir(&hooks)
316 .map(|entries| entries.count())
317 .unwrap_or(0);
318 assert_eq!(samples, 0, "expected no templated hooks in {hooks:?}");
319 }
320
321 #[tokio::test]
322 async fn init_bare_creates_the_handle_directory() {
323 let (dir, storage) = storage();
324 assert!(!dir.path().join("jamesgill").exists());
325
326 storage
327 .init_bare(&handle(), &repo_name("steid"))
328 .await
329 .expect("should create");
330
331 assert!(dir.path().join("jamesgill").is_dir());
332 }
333
334 #[tokio::test]
335 async fn one_handle_can_own_several_repositories() {
336 let (_dir, storage) = storage();
337
338 for name in ["steid", "foo.js", ".github"] {
339 storage
340 .init_bare(&handle(), &repo_name(name))
341 .await
342 .unwrap_or_else(|error| panic!("{name} should create: {error}"));
343 }
344
345 for name in ["steid", "foo.js", ".github"] {
346 assert!(storage.repo_path(&handle(), &repo_name(name)).is_dir());
347 }
348 }
349
350 #[tokio::test]
351 async fn init_bare_refuses_a_repository_that_already_exists() {
352 let (_dir, storage) = storage();
353 storage
354 .init_bare(&handle(), &repo_name("steid"))
355 .await
356 .expect("should create");
357
358 let error = storage
359 .init_bare(&handle(), &repo_name("steid"))
360 .await
361 .expect_err("should refuse");
362
363 assert!(matches!(error, GitStorageError::AlreadyExists));
364 }
365
366 #[tokio::test]
367 async fn a_refused_init_leaves_the_existing_repository_alone() {
368 // git would happily re-initialise in place. The point of refusing is that
369 // whatever is already there is not touched.
370 let (_dir, storage) = storage();
371 storage
372 .init_bare(&handle(), &repo_name("steid"))
373 .await
374 .expect("should create");
375
376 let path = storage.repo_path(&handle(), &repo_name("steid"));
377 let marker = path.join("objects").join("marker");
378 std::fs::write(&marker, b"existing data").expect("write marker");
379
380 let _ = storage.init_bare(&handle(), &repo_name("steid")).await;
381
382 assert_eq!(
383 std::fs::read(&marker).expect("marker should survive"),
384 b"existing data"
385 );
386 }
387
388 #[tokio::test]
389 async fn repo_path_creates_nothing() {
390 let (dir, storage) = storage();
391
392 let path = storage.repo_path(&handle(), &repo_name("never-created"));
393
394 assert!(!path.exists());
395 assert_eq!(
396 std::fs::read_dir(dir.path())
397 .expect("data dir should exist")
398 .count(),
399 0,
400 "repo_path must be pure"
401 );
402 }
403
404 #[tokio::test]
405 async fn repo_path_lands_under_the_data_directory() {
406 let (dir, storage) = storage();
407
408 let path = storage.repo_path(&handle(), &repo_name("steid"));
409
410 assert_eq!(path, dir.path().join("jamesgill").join("steid.git"));
411 }
412
413 #[tokio::test]
414 async fn remove_deletes_the_repository() {
415 let (_dir, storage) = storage();
416 storage
417 .init_bare(&handle(), &repo_name("steid"))
418 .await
419 .expect("should create");
420
421 storage
422 .remove(&handle(), &repo_name("steid"))
423 .await
424 .expect("should remove");
425
426 assert!(!storage.repo_path(&handle(), &repo_name("steid")).exists());
427 }
428
429 #[tokio::test]
430 async fn removing_what_is_not_there_succeeds() {
431 // Compensation runs when a create failed, which may be before anything landed.
432 let (_dir, storage) = storage();
433
434 storage
435 .remove(&handle(), &repo_name("never-created"))
436 .await
437 .expect("should succeed with nothing to do");
438 }
439
440 #[tokio::test]
441 async fn a_compensated_create_can_be_retried() {
442 // The whole point of `remove`: create, fail to record it, undo, try again.
443 let (_dir, storage) = storage();
444
445 storage
446 .init_bare(&handle(), &repo_name("steid"))
447 .await
448 .expect("should create");
449 storage
450 .remove(&handle(), &repo_name("steid"))
451 .await
452 .expect("should remove");
453 storage
454 .init_bare(&handle(), &repo_name("steid"))
455 .await
456 .expect("should create again");
457 }
458
459 #[tokio::test]
460 async fn removing_one_repository_leaves_its_neighbours() {
461 let (_dir, storage) = storage();
462 storage
463 .init_bare(&handle(), &repo_name("steid"))
464 .await
465 .expect("should create");
466 storage
467 .init_bare(&handle(), &repo_name("keeper"))
468 .await
469 .expect("should create");
470
471 storage
472 .remove(&handle(), &repo_name("steid"))
473 .await
474 .expect("should remove");
475
476 assert!(storage.repo_path(&handle(), &repo_name("keeper")).is_dir());
477 }
478
479 #[tokio::test]
480 async fn a_failing_git_invocation_carries_gits_own_message() {
481 let error = run_git(["not-a-real-subcommand"])
482 .await
483 .expect_err("should fail");
484
485 let message = error.to_string();
486 assert!(
487 message.contains("not-a-real-subcommand"),
488 "expected git's own words, got: {message}"
489 );
490 }
491}