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