steid

@jamesgill /

Rust33.4 KBCode·Blame·History·Raw
1//! The repository lifecycle on disk, and its in-memory counterpart.
2//!
3//! Creating, listing, sweeping and removing bare repositories under the data
4//! directory. Every command goes through [`run_git`](super::run_git), so the isolation
5//! from the host is decided once in [`super`] rather than here.
6
7use std::{
8 collections::{HashMap, HashSet},
9 ffi::OsStr,
10 io,
11 path::{Path, PathBuf},
12 process::Stdio,
13 sync::{Arc, Mutex},
14};
15
16use tokio::fs;
17
18use crate::{
19 application::port::{GitStorage, GitStorageError, RepoDir},
20 domain::{OrgName, RefName, RepoName},
21};
22
23use super::{count_spawn, git_command, run_git};
24
25/// Bare repositories on disk, laid out as `{data_dir}/{handle}/{name}.git`.
26#[derive(Debug, Clone)]
27pub struct DiskGitStorage {
28 data_dir: PathBuf,
29}
30
31impl DiskGitStorage {
32 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
33 Self {
34 data_dir: data_dir.into(),
35 }
36 }
37}
38
39impl GitStorage for DiskGitStorage {
40 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
41 let path = self.repo_path(handle, name);
42
43 // `git init` on an existing repository exits 0 and re-initialises in silence,
44 // so this check has to be ours. A directory with no matching record is an
45 // orphan from a create that died between the two writes; adopting it would
46 // resurface a private repository's objects under a fresh record.
47 if path.exists() {
48 return Err(GitStorageError::AlreadyExists);
49 }
50
51 // No `create_dir_all` for the parent: `git init` creates missing directories.
52 run_git([
53 OsStr::new("init"),
54 OsStr::new("--bare"),
55 OsStr::new("--quiet"),
56 // Skip the template directory, which otherwise seeds every repository with
57 // sixteen `.sample` hooks. Steid installs its own hooks later, and they
58 // would be noise to work around.
59 OsStr::new("--template="),
60 // Explicit, so the host's `init.defaultBranch` cannot decide what the
61 // default branch of a Steid repository is.
62 OsStr::new("--initial-branch=main"),
63 // `RepoName` already forbids a leading hyphen; this makes it impossible for
64 // a path to be read as a flag at the boundary where it costs nothing.
65 OsStr::new("--"),
66 path.as_os_str(),
67 ])
68 .await?;
69
70 // A second process, once per repository ever created — creation is not a hot
71 // path. `git init --bare` leaves `core.logAllRefUpdates` off, so a bare
72 // repository keeps no reflog at all and `REMOTE_USER` has nowhere to be
73 // recorded: `git reflog show main` answers nothing, and who moved a ref is
74 // unknowable even though Steid knew at the time.
75 //
76 // Set here rather than passed as `git -c … init`, because `-c` configures that
77 // one invocation and is not written into the repository it creates.
78 //
79 // Repositories created before this stay without one. `git config
80 // core.logAllRefUpdates true` in the bare repo is the whole of the fix.
81 run_git([
82 OsStr::new("--git-dir"),
83 path.as_os_str(),
84 OsStr::new("config"),
85 OsStr::new("core.logAllRefUpdates"),
86 OsStr::new("true"),
87 ])
88 .await
89 .map(|_| ())
90 }
91
92 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
93 let path = self.repo_path(handle, name);
94
95 // `tokio::fs` rather than `std::fs`: removing a repository with real history
96 // walks every loose object, which is long enough to stall a runtime worker.
97 match tokio::fs::remove_dir_all(&path).await {
98 Ok(()) => Ok(()),
99 // Compensation must not fail because there was nothing left to undo.
100 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
101 Err(error) => Err(GitStorageError::backend(error)),
102 }
103 }
104
105 async fn rename(
106 &self,
107 handle: &OrgName,
108 from: &RepoName,
109 to: &RepoName,
110 ) -> Result<(), GitStorageError> {
111 let source = self.repo_path(handle, from);
112 let destination = self.repo_path(handle, to);
113
114 // Checked rather than left to `rename`, which on Unix replaces an existing
115 // empty directory and refuses a non-empty one with a bare `ENOTEMPTY`. Neither
116 // is an answer a use case can explain, and the first one loses a repository.
117 if destination.exists() {
118 return Err(GitStorageError::AlreadyExists);
119 }
120
121 // A rename within one handle's directory, so both sides are on the same
122 // filesystem and this is a metadata update rather than a copy — which is why
123 // the record write can be compensated by moving it straight back.
124 tokio::fs::rename(&source, &destination)
125 .await
126 .map_err(GitStorageError::backend)
127 }
128
129 async fn set_default_branch(
130 &self,
131 handle: &OrgName,
132 name: &RepoName,
133 branch: &RefName,
134 ) -> Result<(), GitStorageError> {
135 let path = self.repo_path(handle, name);
136
137 // `symbolic-ref` writes `HEAD` without needing the branch to exist, which is
138 // exactly why the use case checks that first. The full `refs/heads/` spelling is
139 // built here rather than accepted from the caller, so nothing can point `HEAD`
140 // at a tag or at some other ref namespace.
141 run_git([
142 OsStr::new("--git-dir"),
143 path.as_os_str(),
144 OsStr::new("symbolic-ref"),
145 OsStr::new("HEAD"),
146 OsStr::new(&format!("refs/heads/{branch}")),
147 ])
148 .await
149 .map(|_| ())
150 }
151
152 async fn enable_reflogs(&self) -> Result<u64, GitStorageError> {
153 let mut fixed = 0;
154
155 // The data directory is two levels of our own making — `{data_dir}/{handle}/
156 // {name}.git` — so the walk is two `read_dir`s rather than a recursive one, and
157 // anything else that has found its way in there is skipped instead of being
158 // configured. A missing data directory is not an error: a fresh instance has not
159 // created it yet.
160 let Ok(mut handles) = fs::read_dir(&self.data_dir).await else {
161 return Ok(0);
162 };
163
164 while let Some(handle) = handles.next_entry().await.map_err(|error| {
165 GitStorageError::backend(format!("could not read data dir: {error}"))
166 })? {
167 let Ok(mut repos) = fs::read_dir(handle.path()).await else {
168 continue;
169 };
170
171 while let Some(repo) = repos.next_entry().await.map_err(|error| {
172 GitStorageError::backend(format!("could not read data dir: {error}"))
173 })? {
174 let path = repo.path();
175
176 if path.extension() != Some(OsStr::new("git")) {
177 continue;
178 }
179
180 if enable_reflog(&path).await? {
181 fixed += 1;
182 }
183 }
184 }
185
186 Ok(fixed)
187 }
188
189 async fn list_repo_dirs(&self) -> Result<Vec<RepoDir>, GitStorageError> {
190 let mut found = Vec::new();
191
192 // Two `read_dir`s rather than a recursive walk, for the reason `enable_reflogs`
193 // gives: `{data_dir}/{handle}/{name}.git` is a shape of our own making, and
194 // anything else in there is skipped rather than interpreted.
195 let Ok(mut handles) = fs::read_dir(&self.data_dir).await else {
196 return Ok(found);
197 };
198
199 while let Some(handle) = handles.next_entry().await.map_err(|error| {
200 GitStorageError::backend(format!("could not read data dir: {error}"))
201 })? {
202 let handle_path = handle.path();
203
204 let Some(handle_name) = file_name_of(&handle_path) else {
205 continue;
206 };
207
208 // Where quarantined directories live, so a sweep cannot find its own output
209 // and quarantine it again on every boot. A handle by this name cannot exist —
210 // the reserved-handle list refuses it, pinned by a test below — so nothing
211 // real is skipped by this.
212 if handle_name == QUARANTINE {
213 continue;
214 }
215
216 let Ok(mut repos) = fs::read_dir(&handle_path).await else {
217 continue;
218 };
219
220 while let Some(repo) = repos.next_entry().await.map_err(|error| {
221 GitStorageError::backend(format!("could not read data dir: {error}"))
222 })? {
223 let path = repo.path();
224
225 if path.extension() != Some(OsStr::new("git")) {
226 continue;
227 }
228
229 let Some(name) = path.file_stem().and_then(OsStr::to_str) else {
230 continue;
231 };
232
233 found.push(RepoDir {
234 handle: handle_name.to_owned(),
235 name: name.to_owned(),
236 path,
237 });
238 }
239 }
240
241 Ok(found)
242 }
243
244 async fn quarantine(&self, dir: &RepoDir, label: &str) -> Result<PathBuf, GitStorageError> {
245 let into = self.data_dir.join(QUARANTINE);
246
247 fs::create_dir_all(&into)
248 .await
249 .map_err(GitStorageError::backend)?;
250
251 let destination = into.join(label);
252
253 // Refused rather than overwritten, exactly as `rename` refuses: the thing being
254 // moved may be the only copy of somebody's objects, and this whole operation
255 // exists to avoid losing one.
256 if fs::metadata(&destination).await.is_ok() {
257 return Err(GitStorageError::AlreadyExists);
258 }
259
260 // Within the data directory, so this is a metadata update rather than a copy —
261 // the same reason a rename is safe to compensate.
262 fs::rename(&dir.path, &destination)
263 .await
264 .map_err(GitStorageError::backend)?;
265
266 Ok(destination)
267 }
268
269 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
270 // Nothing is sanitised here. `OrgName` and `RepoName` already made traversal
271 // impossible, and re-checking at the call site is how that responsibility gets
272 // diffused until nobody owns it.
273 self.data_dir
274 .join(handle.as_str())
275 .join(format!("{name}.git"))
276 }
277}
278
279/// The directory under the data directory that quarantined repositories are moved into.
280///
281/// A sibling of the handle directories rather than a hidden name, so an operator listing
282/// the data directory sees it. `OrgName` does not reserve it — a handle by this name would
283/// simply never be swept, which loses nothing and cannot lose data.
284const QUARANTINE: &str = "orphaned";
285
286/// The last component of a path, as UTF-8. `None` for a name that is neither.
287fn file_name_of(path: &Path) -> Option<&str> {
288 path.file_name().and_then(OsStr::to_str)
289}
290
291/// Turns the reflog on in one bare repository, reporting whether it had to.
292///
293/// **Reads before it writes**, which is what makes the boot sweep cheap enough to run
294/// unconditionally: `git config --get` is a local file read and answers 1 — not a fault —
295/// when the key is absent, so a repository that already has a reflog costs one short-lived
296/// process and no write at all. Only a repository that lacks it is written to, once ever.
297///
298/// A value that is present but *false* is left alone. Somebody turned it off on purpose,
299/// and a boot sweep that overrules a deliberate setting is worse than one that misses a
300/// repository.
301async fn enable_reflog(path: &Path) -> Result<bool, GitStorageError> {
302 let mut command = git_command();
303 command
304 .args([
305 OsStr::new("--git-dir"),
306 path.as_os_str(),
307 OsStr::new("config"),
308 OsStr::new("--get"),
309 OsStr::new("core.logAllRefUpdates"),
310 ])
311 .stdin(Stdio::null());
312 count_spawn(&command);
313
314 let existing = command
315 .output()
316 .await
317 .map_err(|error| GitStorageError::backend(format!("could not run git: {error}")))?;
318
319 // 1 is "no such key", which is the whole reason this runs. Anything else — a
320 // directory that is not a repository, a permission problem — is left to the caller
321 // to log, having read nothing into it.
322 match existing.status.code() {
323 Some(0) => return Ok(false),
324 Some(1) => {}
325 _ => {
326 return Err(GitStorageError::backend(format!(
327 "git config --get exited with {} in {}: {}",
328 existing.status,
329 path.display(),
330 String::from_utf8_lossy(&existing.stderr).trim()
331 )));
332 }
333 }
334
335 run_git([
336 OsStr::new("--git-dir"),
337 path.as_os_str(),
338 OsStr::new("config"),
339 OsStr::new("core.logAllRefUpdates"),
340 OsStr::new("true"),
341 ])
342 .await?;
343
344 Ok(true)
345}
346
347/// Bare repositories tracked in memory, for testing use cases without touching disk.
348///
349/// The counterpart to [`DiskGitStorage`], the way `StubHasher` is the counterpart to
350/// the real Argon2 hasher. It enforces the same `AlreadyExists` rule, because a use
351/// case that only passes against a permissive fake proves nothing about the real one.
352#[derive(Debug, Default, Clone)]
353pub struct InMemoryGitStorage {
354 created: Arc<Mutex<HashSet<PathBuf>>>,
355 /// Where each repository's `HEAD` points, for asserting on a default-branch change.
356 heads: Arc<Mutex<HashMap<PathBuf, RefName>>>,
357}
358
359impl InMemoryGitStorage {
360 pub fn new() -> Self {
361 Self::default()
362 }
363
364 /// Whether a repository exists, for assertions.
365 pub fn contains(&self, handle: &OrgName, name: &RepoName) -> bool {
366 self.created
367 .lock()
368 .expect("lock poisoned")
369 .contains(&self.repo_path(handle, name))
370 }
371
372 /// Which branch `HEAD` names, for assertions. `None` until something sets it.
373 pub fn head(&self, handle: &OrgName, name: &RepoName) -> Option<RefName> {
374 self.heads
375 .lock()
376 .expect("lock poisoned")
377 .get(&self.repo_path(handle, name))
378 .cloned()
379 }
380
381 /// How many repositories exist, for asserting that nothing was created.
382 pub fn len(&self) -> usize {
383 self.created.lock().expect("lock poisoned").len()
384 }
385
386 pub fn is_empty(&self) -> bool {
387 self.len() == 0
388 }
389}
390
391impl GitStorage for InMemoryGitStorage {
392 async fn init_bare(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
393 let mut created = self.created.lock().expect("lock poisoned");
394
395 if !created.insert(self.repo_path(handle, name)) {
396 return Err(GitStorageError::AlreadyExists);
397 }
398
399 Ok(())
400 }
401
402 async fn remove(&self, handle: &OrgName, name: &RepoName) -> Result<(), GitStorageError> {
403 self.created
404 .lock()
405 .expect("lock poisoned")
406 .remove(&self.repo_path(handle, name));
407
408 Ok(())
409 }
410
411 async fn rename(
412 &self,
413 handle: &OrgName,
414 from: &RepoName,
415 to: &RepoName,
416 ) -> Result<(), GitStorageError> {
417 let mut created = self.created.lock().expect("lock poisoned");
418 let destination = self.repo_path(handle, to);
419
420 // The same refusal `DiskGitStorage` makes, so a use case cannot pass here and
421 // clobber a directory there.
422 if created.contains(&destination) {
423 return Err(GitStorageError::AlreadyExists);
424 }
425
426 let source = self.repo_path(handle, from);
427 created.remove(&source);
428 created.insert(destination.clone());
429
430 // `HEAD` travels with the directory, as it does on disk.
431 let mut heads = self.heads.lock().expect("lock poisoned");
432 if let Some(head) = heads.remove(&source) {
433 heads.insert(destination, head);
434 }
435
436 Ok(())
437 }
438
439 async fn set_default_branch(
440 &self,
441 handle: &OrgName,
442 name: &RepoName,
443 branch: &RefName,
444 ) -> Result<(), GitStorageError> {
445 self.heads
446 .lock()
447 .expect("lock poisoned")
448 .insert(self.repo_path(handle, name), branch.clone());
449
450 Ok(())
451 }
452
453 /// Nothing to enable: an in-memory repository has no config file, and no test asserts
454 /// on a reflog it cannot have. Reported as zero fixed rather than as unsupported, so a
455 /// use case that sweeps on boot can be exercised against this at all.
456 async fn enable_reflogs(&self) -> Result<u64, GitStorageError> {
457 Ok(0)
458 }
459
460 async fn list_repo_dirs(&self) -> Result<Vec<RepoDir>, GitStorageError> {
461 let created = self.created.lock().expect("lock poisoned");
462
463 let mut found: Vec<RepoDir> = created
464 .iter()
465 .filter_map(|path| {
466 let name = path.file_stem().and_then(OsStr::to_str)?;
467 let handle = path.parent().and_then(|parent| file_name_of(parent))?;
468
469 Some(RepoDir {
470 handle: handle.to_owned(),
471 name: name.to_owned(),
472 path: path.clone(),
473 })
474 })
475 .collect();
476
477 // A `HashSet` behind it, so the order would otherwise vary between runs and make
478 // a sweep's log — and any test of it — non-deterministic.
479 found.sort_by(|a, b| (&a.handle, &a.name).cmp(&(&b.handle, &b.name)));
480
481 Ok(found)
482 }
483
484 /// Moves the directory to a path under `/in-memory/orphaned`, so `contains` stops
485 /// finding it under its old name and the name is free to reuse — which is the whole
486 /// of what the on-disk move buys.
487 async fn quarantine(&self, dir: &RepoDir, label: &str) -> Result<PathBuf, GitStorageError> {
488 let destination = PathBuf::from("/in-memory").join(QUARANTINE).join(label);
489
490 let mut created = self.created.lock().expect("lock poisoned");
491
492 if created.contains(&destination) {
493 return Err(GitStorageError::AlreadyExists);
494 }
495
496 created.remove(&dir.path);
497 created.insert(destination.clone());
498
499 Ok(destination)
500 }
501
502 fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf {
503 PathBuf::from("/in-memory")
504 .join(handle.as_str())
505 .join(format!("{name}.git"))
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use tempfile::TempDir;
512
513 use super::*;
514 use crate::infrastructure::git::fixture::{git_says, handle, repo_name, storage};
515
516 // --- Reconciling the data directory ----------------------------------------------
517
518 #[tokio::test]
519 async fn every_repository_directory_is_listed_and_the_quarantine_is_not() {
520 let (dir, storage) = storage();
521 let handle = handle();
522
523 for name in ["steid", "notes"] {
524 storage
525 .init_bare(&handle, &repo_name(name))
526 .await
527 .expect("init bare");
528 }
529
530 // Something that is not a repository, beside them.
531 fs::create_dir_all(dir.path().join(handle.as_str()).join("scratch"))
532 .await
533 .expect("make a stray directory");
534
535 let mut listed = storage.list_repo_dirs().await.expect("should list");
536 listed.sort_by(|a, b| a.name.cmp(&b.name));
537
538 assert_eq!(
539 listed
540 .iter()
541 .map(|found| (found.handle.as_str(), found.name.as_str()))
542 .collect::<Vec<_>>(),
543 vec![("jamesgill", "notes"), ("jamesgill", "steid")],
544 "only `.git` directories, and the extension is stripped"
545 );
546 assert!(listed[0].path.is_dir());
547 }
548
549 #[tokio::test]
550 async fn an_orphan_is_moved_aside_rather_than_deleted_and_frees_its_name() {
551 let (dir, storage) = storage();
552 let handle = handle();
553 let name = repo_name("half-made");
554
555 storage.init_bare(&handle, &name).await.expect("init bare");
556
557 // Something identifiable inside it, so "moved rather than deleted" is a claim
558 // about the contents rather than about the directory existing.
559 let marker = storage.repo_path(&handle, &name).join("HEAD");
560 assert!(marker.exists());
561
562 let found = storage.list_repo_dirs().await.expect("should list");
563 let moved = storage
564 .quarantine(&found[0], "1757000000-half-made")
565 .await
566 .expect("should move it aside");
567
568 assert_eq!(
569 moved,
570 dir.path().join("orphaned").join("1757000000-half-made")
571 );
572 assert!(moved.join("HEAD").exists(), "the objects came with it");
573 assert!(!storage.repo_path(&handle, &name).exists());
574
575 // The whole point: the name is usable again.
576 storage
577 .init_bare(&handle, &name)
578 .await
579 .expect("the name is free");
580
581 // And the sweep does not find its own output on the next boot.
582 assert_eq!(
583 storage
584 .list_repo_dirs()
585 .await
586 .expect("should list")
587 .iter()
588 .map(|found| found.name.clone())
589 .collect::<Vec<_>>(),
590 vec!["half-made".to_owned()]
591 );
592 }
593
594 #[test]
595 fn the_quarantine_directory_can_never_be_a_real_handle() {
596 // `list_repo_dirs` skips this name so a sweep cannot re-quarantine its own
597 // output. That skip is only safe because no owner can hold the handle; if the
598 // two ever drift apart, one person's repositories silently stop being swept.
599 assert!(
600 OrgName::new(QUARANTINE).is_err(),
601 "{QUARANTINE} must stay a reserved handle"
602 );
603 }
604
605 #[tokio::test]
606 async fn a_quarantine_never_overwrites_what_is_already_there() {
607 let (dir, storage) = storage();
608 let handle = handle();
609
610 storage
611 .init_bare(&handle, &repo_name("thing"))
612 .await
613 .expect("init bare");
614
615 fs::create_dir_all(dir.path().join("orphaned").join("1757000000-thing"))
616 .await
617 .expect("stage a collision");
618
619 let found = storage.list_repo_dirs().await.expect("should list");
620
621 assert!(matches!(
622 storage.quarantine(&found[0], "1757000000-thing").await,
623 Err(GitStorageError::AlreadyExists)
624 ));
625 assert!(
626 storage.repo_path(&handle, &repo_name("thing")).exists(),
627 "nothing was moved, so nothing was lost"
628 );
629 }
630
631 #[tokio::test]
632 async fn a_created_repository_keeps_a_reflog() {
633 // Without this, `REMOTE_USER` has nowhere to land: a bare repository logs no ref
634 // updates by default, so who moved a branch is unknowable even though Steid knew.
635 let dir = TempDir::new().expect("temp dir");
636 let storage = DiskGitStorage::new(dir.path());
637
638 storage
639 .init_bare(&handle(), &repo_name("steid"))
640 .await
641 .expect("should create");
642
643 let config = std::fs::read_to_string(
644 storage
645 .repo_path(&handle(), &repo_name("steid"))
646 .join("config"),
647 )
648 .expect("read config");
649
650 assert!(
651 config.to_lowercase().contains("logallrefupdates = true"),
652 "a bare repository needs reflogs turned on: {config}"
653 );
654 }
655
656 #[tokio::test]
657 async fn the_boot_sweep_gives_an_older_repository_a_reflog() {
658 // The state every repository created before 2026-09-06 is in. Reproduced by
659 // unsetting the key rather than by hand-building a repository, so the test is
660 // about the sweep and not about how `git init` used to behave.
661 let (_dir, storage) = storage();
662 let path = storage.repo_path(&handle(), &repo_name("steid"));
663
664 storage
665 .init_bare(&handle(), &repo_name("steid"))
666 .await
667 .expect("should create");
668
669 git_command()
670 .args([
671 OsStr::new("--git-dir"),
672 path.as_os_str(),
673 OsStr::new("config"),
674 OsStr::new("--unset"),
675 OsStr::new("core.logAllRefUpdates"),
676 ])
677 .output()
678 .await
679 .expect("unset");
680
681 assert_eq!(storage.enable_reflogs().await.expect("should sweep"), 1);
682
683 let config = std::fs::read_to_string(path.join("config")).expect("read config");
684 assert!(
685 config.to_lowercase().contains("logallrefupdates = true"),
686 "the sweep should have turned reflogs on: {config}"
687 );
688
689 // Idempotent, which is what makes running it on every boot cheap: nothing is
690 // written the second time, and the count says so.
691 assert_eq!(storage.enable_reflogs().await.expect("should sweep"), 0);
692 }
693
694 #[tokio::test]
695 async fn the_boot_sweep_leaves_a_deliberate_false_alone() {
696 // Somebody turned it off on purpose. A sweep that overrules a chosen setting is
697 // worse than one that misses a repository.
698 let (_dir, storage) = storage();
699 let path = storage.repo_path(&handle(), &repo_name("steid"));
700
701 storage
702 .init_bare(&handle(), &repo_name("steid"))
703 .await
704 .expect("should create");
705
706 run_git([
707 OsStr::new("--git-dir"),
708 path.as_os_str(),
709 OsStr::new("config"),
710 OsStr::new("core.logAllRefUpdates"),
711 OsStr::new("false"),
712 ])
713 .await
714 .expect("set false");
715
716 assert_eq!(storage.enable_reflogs().await.expect("should sweep"), 0);
717
718 let config = std::fs::read_to_string(path.join("config")).expect("read config");
719 assert!(config.to_lowercase().contains("logallrefupdates = false"));
720 }
721
722 #[tokio::test]
723 async fn the_boot_sweep_ignores_what_is_not_a_repository() {
724 // A fresh instance has no data directory at all, and a stray file under one is not
725 // something to run `git config` against.
726 let dir = TempDir::new().expect("temp dir");
727 let missing = DiskGitStorage::new(dir.path().join("not-created-yet"));
728
729 assert_eq!(missing.enable_reflogs().await.expect("should sweep"), 0);
730
731 let (_dir, storage) = storage();
732 std::fs::create_dir_all(dir.path().join("jamesgill")).expect("mkdir");
733 std::fs::write(dir.path().join("jamesgill").join("notes.txt"), b"hello").expect("write");
734
735 assert_eq!(storage.enable_reflogs().await.expect("should sweep"), 0);
736 }
737
738 #[tokio::test]
739 async fn init_bare_creates_a_bare_repository() {
740 let (_dir, storage) = storage();
741
742 storage
743 .init_bare(&handle(), &repo_name("steid"))
744 .await
745 .expect("should create");
746
747 let path = storage.repo_path(&handle(), &repo_name("steid"));
748 assert!(path.is_dir(), "expected a repository at {path:?}");
749 assert_eq!(
750 git_says(&path, &["rev-parse", "--is-bare-repository"]),
751 "true"
752 );
753 }
754
755 #[tokio::test]
756 async fn a_new_repository_is_empty() {
757 // Empty, like GitHub: no initial commit and no branch yet.
758 let (_dir, storage) = storage();
759 storage
760 .init_bare(&handle(), &repo_name("steid"))
761 .await
762 .expect("should create");
763
764 let path = storage.repo_path(&handle(), &repo_name("steid"));
765
766 assert_eq!(git_says(&path, &["for-each-ref"]), "");
767 }
768
769 #[tokio::test]
770 async fn a_new_repository_defaults_to_main() {
771 // Pinned so the host's `init.defaultBranch` cannot decide this. It currently
772 // agrees on this machine, which is exactly why a drift would go unnoticed.
773 let (_dir, storage) = storage();
774 storage
775 .init_bare(&handle(), &repo_name("steid"))
776 .await
777 .expect("should create");
778
779 let path = storage.repo_path(&handle(), &repo_name("steid"));
780
781 assert_eq!(
782 git_says(&path, &["symbolic-ref", "HEAD"]),
783 "refs/heads/main"
784 );
785 }
786
787 #[tokio::test]
788 async fn no_sample_hooks_are_installed() {
789 // Pins `--template=`. A default init seeds sixteen `.sample` files.
790 let (_dir, storage) = storage();
791 storage
792 .init_bare(&handle(), &repo_name("steid"))
793 .await
794 .expect("should create");
795
796 let hooks = storage
797 .repo_path(&handle(), &repo_name("steid"))
798 .join("hooks");
799
800 let samples = std::fs::read_dir(&hooks)
801 .map(|entries| entries.count())
802 .unwrap_or(0);
803 assert_eq!(samples, 0, "expected no templated hooks in {hooks:?}");
804 }
805
806 #[tokio::test]
807 async fn init_bare_creates_the_handle_directory() {
808 let (dir, storage) = storage();
809 assert!(!dir.path().join("jamesgill").exists());
810
811 storage
812 .init_bare(&handle(), &repo_name("steid"))
813 .await
814 .expect("should create");
815
816 assert!(dir.path().join("jamesgill").is_dir());
817 }
818
819 #[tokio::test]
820 async fn one_handle_can_own_several_repositories() {
821 let (_dir, storage) = storage();
822
823 for name in ["steid", "foo.js", ".github"] {
824 storage
825 .init_bare(&handle(), &repo_name(name))
826 .await
827 .unwrap_or_else(|error| panic!("{name} should create: {error}"));
828 }
829
830 for name in ["steid", "foo.js", ".github"] {
831 assert!(storage.repo_path(&handle(), &repo_name(name)).is_dir());
832 }
833 }
834
835 #[tokio::test]
836 async fn init_bare_refuses_a_repository_that_already_exists() {
837 let (_dir, storage) = storage();
838 storage
839 .init_bare(&handle(), &repo_name("steid"))
840 .await
841 .expect("should create");
842
843 let error = storage
844 .init_bare(&handle(), &repo_name("steid"))
845 .await
846 .expect_err("should refuse");
847
848 assert!(matches!(error, GitStorageError::AlreadyExists));
849 }
850
851 #[tokio::test]
852 async fn a_refused_init_leaves_the_existing_repository_alone() {
853 // git would happily re-initialise in place. The point of refusing is that
854 // whatever is already there is not touched.
855 let (_dir, storage) = storage();
856 storage
857 .init_bare(&handle(), &repo_name("steid"))
858 .await
859 .expect("should create");
860
861 let path = storage.repo_path(&handle(), &repo_name("steid"));
862 let marker = path.join("objects").join("marker");
863 std::fs::write(&marker, b"existing data").expect("write marker");
864
865 let _ = storage.init_bare(&handle(), &repo_name("steid")).await;
866
867 assert_eq!(
868 std::fs::read(&marker).expect("marker should survive"),
869 b"existing data"
870 );
871 }
872
873 #[tokio::test]
874 async fn repo_path_creates_nothing() {
875 let (dir, storage) = storage();
876
877 let path = storage.repo_path(&handle(), &repo_name("never-created"));
878
879 assert!(!path.exists());
880 assert_eq!(
881 std::fs::read_dir(dir.path())
882 .expect("data dir should exist")
883 .count(),
884 0,
885 "repo_path must be pure"
886 );
887 }
888
889 #[tokio::test]
890 async fn repo_path_lands_under_the_data_directory() {
891 let (dir, storage) = storage();
892
893 let path = storage.repo_path(&handle(), &repo_name("steid"));
894
895 assert_eq!(path, dir.path().join("jamesgill").join("steid.git"));
896 }
897
898 #[tokio::test]
899 async fn remove_deletes_the_repository() {
900 let (_dir, storage) = storage();
901 storage
902 .init_bare(&handle(), &repo_name("steid"))
903 .await
904 .expect("should create");
905
906 storage
907 .remove(&handle(), &repo_name("steid"))
908 .await
909 .expect("should remove");
910
911 assert!(!storage.repo_path(&handle(), &repo_name("steid")).exists());
912 }
913
914 #[tokio::test]
915 async fn removing_what_is_not_there_succeeds() {
916 // Compensation runs when a create failed, which may be before anything landed.
917 let (_dir, storage) = storage();
918
919 storage
920 .remove(&handle(), &repo_name("never-created"))
921 .await
922 .expect("should succeed with nothing to do");
923 }
924
925 #[tokio::test]
926 async fn a_compensated_create_can_be_retried() {
927 // The whole point of `remove`: create, fail to record it, undo, try again.
928 let (_dir, storage) = storage();
929
930 storage
931 .init_bare(&handle(), &repo_name("steid"))
932 .await
933 .expect("should create");
934 storage
935 .remove(&handle(), &repo_name("steid"))
936 .await
937 .expect("should remove");
938 storage
939 .init_bare(&handle(), &repo_name("steid"))
940 .await
941 .expect("should create again");
942 }
943
944 #[tokio::test]
945 async fn removing_one_repository_leaves_its_neighbours() {
946 let (_dir, storage) = storage();
947 storage
948 .init_bare(&handle(), &repo_name("steid"))
949 .await
950 .expect("should create");
951 storage
952 .init_bare(&handle(), &repo_name("keeper"))
953 .await
954 .expect("should create");
955
956 storage
957 .remove(&handle(), &repo_name("steid"))
958 .await
959 .expect("should remove");
960
961 assert!(storage.repo_path(&handle(), &repo_name("keeper")).is_dir());
962 }
963
964 #[tokio::test]
965 async fn a_failing_git_invocation_carries_gits_own_message() {
966 let error = run_git(["not-a-real-subcommand"])
967 .await
968 .expect_err("should fail");
969
970 let message = error.to_string();
971 assert!(
972 message.contains("not-a-real-subcommand"),
973 "expected git's own words, got: {message}"
974 );
975 }
976}