steid

@jamesgill /

feat: GitStorage port and DiskGitStorage

Repositories exist as directories as well as rows from here. `GitStorage` is the
lifecycle half of that — `init_bare`, `remove`, `repo_path` — with `DiskGitStorage`
shelling out to the git binary behind it.

Deliberately narrow rather than one git service: serving the protocol and browsing a
tree are streaming and querying, which do not unify, and a fake for a use case needing
two methods should not have to implement fifteen. Those ports arrive with their use
cases. What is shared instead is `run_git`, one private function owning *how* git is
invoked, so Milestone 4's `http-backend` spawn cannot quietly disagree about isolation
— ambient config neutered, redirecting `GIT_*` vars removed, stdin closed, stderr
carried into the error. Recorded as ADR 0006, including why `gix` lost on consistency
rather than on speed.

Three things came from probing git rather than assuming:

- `git init` on an existing repo exits 0 and re-initialises silently, so `AlreadyExists`
  has to be our own check. Refusing matters — a directory with no row is an orphan, and
  adopting it would resurface a private repo's objects under a fresh record.
- `git init` creates parent directories itself, so the planned `create_dir_all` went.
- `--template=` drops a new bare repo from 18 files to 2. The ~2ms saved is not the
  reason; the sixteen `.sample` hooks would be noise once Steid installs its own.

`--initial-branch=main` is explicit so the host's `init.defaultBranch` cannot decide it.
This machine already agrees, which is why the test exists.

`tokio::process`/`tokio::fs` throughout. `init_bare` is not hot at ~13ms once per repo,
but `remove_dir_all` over real history would stall a runtime worker; the performance
that matters is Milestone 4's per-request spawn.

`tempfile` as a dev-dependency, so `git` is now a test dependency too. Also settles
`Repository::description`, which was added unrequested: kept, because this milestone's
own "Done when" puts repositories on the profile.

158 tests, clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 24 days agoparent5a2b113Browse files02eb2e40ecdce5c151c4c339d2a7166093f9cc37

13 files changed+742 −23

Cargo.lock+33 −0View file
@@ -1098,6 +1098,12 @@ dependencies = [
10981098 "vcpkg",
10991099 ]
11001100
1101+[[package]]
1102+name = "linux-raw-sys"
1103+version = "0.12.1"
1104+source = "registry+https://github.com/rust-lang/crates.io-index"
1105+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1106+
11011107 [[package]]
11021108 name = "litemap"
11031109 version = "0.8.2"
@@ -1462,6 +1468,19 @@ dependencies = [
14621468 "windows-sys 0.52.0",
14631469 ]
14641470
1471+[[package]]
1472+name = "rustix"
1473+version = "1.1.4"
1474+source = "registry+https://github.com/rust-lang/crates.io-index"
1475+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
1476+dependencies = [
1477+ "bitflags",
1478+ "errno",
1479+ "libc",
1480+ "linux-raw-sys",
1481+ "windows-sys 0.61.2",
1482+]
1483+
14651484 [[package]]
14661485 name = "rustls"
14671486 version = "0.23.43"
@@ -1876,6 +1895,7 @@ dependencies = [
18761895 "serde",
18771896 "sqlx",
18781897 "subtle",
1898+ "tempfile",
18791899 "tokio",
18801900 "topcoat",
18811901 "uuid",
@@ -1937,6 +1957,19 @@ dependencies = [
19371957 "syn 2.0.119",
19381958 ]
19391959
1960+[[package]]
1961+name = "tempfile"
1962+version = "3.27.0"
1963+source = "registry+https://github.com/rust-lang/crates.io-index"
1964+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1965+dependencies = [
1966+ "fastrand",
1967+ "getrandom 0.4.3",
1968+ "once_cell",
1969+ "rustix",
1970+ "windows-sys 0.61.2",
1971+]
1972+
19401973 [[package]]
19411974 name = "thiserror"
19421975 version = "2.0.19"
Cargo.toml+4 −1View file
@@ -11,9 +11,12 @@ rand = "0.10.2"
1111 serde = { version = "1.0.229", features = ["derive"] }
1212 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
1313 subtle = "2.6.1"
14tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] }
14+tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "process", "fs"] }
1515 topcoat = { version = "0.5.0", features = ["tailwind", "ui"] }
1616 uuid = { version = "1.24.0", features = ["v4"] }
1717
1818 [build-dependencies]
1919 topcoat = { version = "0.5.0", default-features = false, features = ["tailwind"] }
20+
21+[dev-dependencies]
22+tempfile = "3.27.0"
plans/ROADMAP.md+1 −0View file
@@ -35,6 +35,7 @@ Starting intent, not settled decisions. Each one gets a record in
3535 | Web framework | Topcoat (tokio-rs) | — |
3636 | Git transport | smart HTTP via `git http-backend` | [0001](decisions/0001-git-over-http-not-ssh.md) |
3737 | Git auth | personal access tokens over HTTP Basic | [0001](decisions/0001-git-over-http-not-ssh.md) |
38+| Git operations | the `git` binary, behind narrow ports | [0006](decisions/0006-git-binary-behind-narrow-ports.md) |
3839 | Crate layout | single crate | — |
3940 | Ownership | personal org owns repos | — |
4041 | URLs | root handles, grouped routes | [0004](decisions/0004-root-handles-grouped-routes.md) |
plans/architecture.md+4 −0View file
@@ -63,6 +63,10 @@ Repository traits in `domain/repository/`. Service ports the application needs
6363 (`GitStorage`, `GitProtocolServer`, `PasswordHasher`) in `application/port.rs`. Use
6464 cases take `&impl Port`, not a boxed trait object.
6565
66+Git is deliberately several narrow ports rather than one service, and how the binary is
67+actually invoked lives in one place in `infrastructure/git.rs` —
68+[0006](decisions/0006-git-binary-behind-narrow-ports.md).
69+
6670 ## DB-plus-filesystem writes
6771
6872 Creating a repo writes to two places that can't share a transaction: the
plans/current.md+25 −8View file
@@ -20,8 +20,8 @@ problem twice as interesting, so not in the first pass.
2020 - [x] Domain: `RepoRepository` port — `find_by_id`, `find_by_org_and_name`,
2121 `list_by_org`, `save`
2222 - [x] Infrastructure: in-memory + SQLite implementations, migration
23- [ ] Application: `GitStorage` port — `init_bare`, `repo_path`
24- [ ] Infrastructure: `DiskGitStorage`, shelling out to `git init --bare`
23+- [x] Application: `GitStorage` port — `init_bare`, `remove`, `repo_path`
24+- [x] Infrastructure: `DiskGitStorage`, shelling out to `git init --bare`
2525 - [ ] Application: `create_repo` use case — owner only, validates, creates record and
2626 bare repo
2727 - [ ] Application: `list_repos` / `view_repo` read models — visibility-aware
@@ -45,8 +45,10 @@ milestone 4.
4545 `/{handle}/repos/` can collide.
4646 - **Visibility defaults to public**, matching a portfolio-first product.
4747 - **Repositories carry an optional description**, capped at 300 characters — a sentence
48 for the profile listing, not a README. *Added without being asked for; remove if it
49 is not wanted.*
48+ for the profile listing, not a README. Kept deliberately: portfolio-first is the
49+ tie-break, and this milestone's own "Done when" puts repositories on the profile, so
50+ the consumer is inside the milestone rather than hypothetical. The per-repo analogue
51+ of `Organization::bio`.
5052 - **`list_by_org` returns every repository regardless of visibility.** Filtering is an
5153 authorization decision and belongs to the use case, so the page and `/api` cannot end
5254 up applying different rules. The cost is that a private repo is briefly in memory
@@ -54,8 +56,8 @@ milestone 4.
5456
5557 ### Open
5658
57- **Test fixtures for `DiskGitStorage`:** `tempfile` as a dev-dependency, or write under
58 `target/`? Leaning `tempfile` — self-cleaning and parallel-safe. Unanswered.
59+Nothing open. `GitStorage`'s shape and how git is invoked are recorded in
60+[0006](decisions/0006-git-binary-behind-narrow-ports.md).
5961
6062 ### Watch for
6163
@@ -70,7 +72,18 @@ milestone 4.
7072 - **Visibility is an authorization decision**, so it belongs in the use case. A private
7173 repo must be absent from listings, not merely unlinked — and `/api` must agree with
7274 the page.
73- **`git` becomes a runtime dependency** from this milestone. The runbook should say so.
75+- **`git` is a dependency of the test suite too**, not only of the runtime —
76+ `DiskGitStorage`'s tests run real `git init`. A machine without `git` fails
77+ `cargo test`, not just the app.
78+- **A handle rename is a directory move.** The layout is keyed by handle for
79+ legibility, so whenever renaming arrives it has to move the tree; it cannot be a row
80+ update. Nothing renames handles today.
81+- **Bare repos created on macOS carry `ignorecase = true`** in their config, because
82+ git probes the filesystem at init. Correct where it was created, wrong if the data
83+ directory is ever moved to Linux. A migration gotcha, not a bug.
84+- **An orphaned directory blocks re-creating that name.** `init_bare` refuses rather
85+ than adopting what is already there, and repo deletion is out of scope this
86+ milestone, so clearing one is a manual `rm` for now.
7487
7588 ### Carried over — small, unblocked
7689
@@ -90,7 +103,11 @@ Ordered. Pull from the top.
90103 1. **Milestone 4 — Git over HTTP.** `git http-backend` subprocess, PATs over HTTP
91104 Basic. See [0001](decisions/0001-git-over-http-not-ssh.md). The `body_limit` cap will
92105 reject large pushes until raised.
932. **Milestone 5 — Repo browsing.** Tree, blob, commit log.
106+2. **Milestone 5 — Repo browsing.** Tree, blob, commit log. **Start with domain value
107+ objects** — `ObjectId`, `RefName`, `TreeEntry` — before any adapter. A query port
108+ returning `String`s is an anaemic pass-through that pushes validation into the page.
109+ Also the point to measure fork/exec cost per page view, and to reconsider `gix` for
110+ the read path ([0006](decisions/0006-git-binary-behind-narrow-ports.md)).
94111 3. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
95112 whether writing or projects/showcases is the better first portfolio feature.
96113
plans/decisions/0006-git-binary-behind-narrow-ports.md+86 −0View file
@@ -0,0 +1,86 @@
1+# 0006 — Drive git through the binary, behind narrow ports
2+
3+**Status:** accepted · **Date:** 2026-08-13
4+
5+## Context
6+
7+Milestone 3 is the first time Steid has to touch git at all: creating a repository
8+means a bare repo on disk as well as a row in SQLite. It will not be the last. The
9+ladder needs at least three families of git operation, and they arrive one milestone
10+apart:
11+
12+| Family | Shape | Consumer |
13+|---|---|---|
14+| Lifecycle — `init_bare`, `remove`, `repo_path` | fallible, fire-and-forget | `create_repo` (M3) |
15+| Protocol — `upload_pack`, `receive_pack` | bidirectional streaming | `serve_clone` / `serve_push` (M4) |
16+| Query — refs, tree, blob, log | returns domain objects | read models (M5) |
17+
18+Two questions fall out, and answering only the first is what left attempt #2 with git
19+invocation details spread across the code.
20+
21+**What runs git?** [0001](0001-git-over-http-not-ssh.md) already commits to delegating
22+the smart HTTP protocol to `git http-backend`, so the `git` binary is a runtime
23+dependency from Milestone 4 whatever happens at Milestone 3.
24+
25+**What shape does the application see?** `architecture.md` names `GitStorage` and
26+`GitProtocolServer` as separate ports, but nothing had been built, so whether that
27+survived contact was untested.
28+
29+There is also a quieter pressure. Invoking git safely is not one decision but half a
30+dozen — ambient configuration, redirected object storage, inherited stdin, argument
31+injection, blocking the async runtime, what a non-zero exit means. Each is invisible
32+when wrong. Getting them right in `init_bare` and forgetting them in the Milestone 4
33+`http-backend` spawn would produce no failure, just an inconsistent posture.
34+
35+## Decision
36+
37+Steid shells out to the `git` binary, exposed to the application layer as **several
38+narrow ports** rather than one git service, with **a single infrastructure-side
39+invoker** that owns how git is actually run.
40+
41+`GitStorage` (`init_bare`, `remove`, `repo_path`) lands now, in
42+`application/port.rs`. `GitProtocolServer` and a query port follow when their use
43+cases exist, not before. All of their adapters live in `infrastructure/git.rs` and go
44+through `run_git`, which sets the isolation once.
45+
46+## Alternatives considered
47+
48+- **`gix` (pure-Rust git).** Faster — microseconds against the measured ~13ms of a
49+ `git init --bare` fork/exec — and no `PATH` dependency. Rejected on consistency, not
50+ speed: [0001](0001-git-over-http-not-ssh.md) already requires the binary for the
51+ protocol, so this would mean two implementations holding assumptions about the same
52+ on-disk format, and the faster one is on the path that runs once per repository
53+ creation rather than once per request. Worth revisiting at Milestone 5, where
54+ browsing is read-only, hot, and the place `gix` is strongest.
55+- **One `GitService` port.** A single trait covering all three families. Rejected: the
56+ shapes do not unify — one streams, one queries — and every test fake would have to
57+ implement the whole surface to exercise a use case that needs two methods. Ports
58+ live where they are consumed.
59+- **No shared invoker; each adapter spawns its own `Command`.** Rejected because the
60+ isolation flags are exactly the kind of thing that drifts silently between call
61+ sites. There is one caller today, which makes this look premature; the recipe is one
62+ private function rather than a public abstraction precisely so it is not.
63+- **A domain abstraction over git objects now.** Rejected as premature. Neither
64+ creation nor the protocol needs the domain to understand a commit — pack data is
65+ opaque bytes in transit. Milestone 5 is where `ObjectId`, `RefName` and `TreeEntry`
66+ become real domain value objects, and it should start with them rather than an
67+ adapter returning `String`s.
68+
69+## Consequences
70+
71+- **Makes easy:** adding the Milestone 4 and 5 ports without re-deciding how git is
72+ invoked; testing `create_repo` against a fake `GitStorage` that never touches disk.
73+- **Makes hard:** anything wanting git operations *not* expressible as a subprocess.
74+ Also every operation pays a fork/exec — fine at once per repository creation,
75+ something Milestone 5 should measure before browsing does it per page view.
76+- **New dependencies:** `git` on `PATH` is now required by the **test suite** as well
77+ as at runtime, since `DiskGitStorage`'s tests run real `git init`. `tokio` gains the
78+ `process` and `fs` features.
79+- **Known hole:** the repository row and its directory cannot share a transaction. The
80+ compensating transaction in `architecture.md` is what `GitStorage::remove` exists
81+ for, and it still leaves an orphaned directory if the process dies between the two
82+ writes.
83+- **Reversibility:** the port shape is cheap to change while there is one adapter and
84+ one caller. Swapping the binary for `gix` behind an unchanged `GitStorage` is
85+ contained; doing it after `GitProtocolServer` exists is not, because the protocol
86+ adapter is the one that genuinely cannot be rewritten in `gix` today.
plans/progress.md+45 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5117 tests. Active milestone in [current.md](current.md).
5+158 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -97,6 +97,50 @@ URLs settled as root handles with grouped application routes
9797 in rather than depended on ([0005](decisions/0005-tailwind-and-copied-components.md)).
9898 Components reference theme tokens, never raw colours.
9999
100+### Milestone 3 — Repo model · in progress
101+
102+Domain, both persistence adapters, and now `GitStorage` with `DiskGitStorage` behind
103+it. How git is invoked is recorded in
104+[0006](decisions/0006-git-binary-behind-narrow-ports.md).
105+
106+#### Decisions worth remembering
107+
108+- **`git init` on an existing repository exits 0 and re-initialises in silence.**
109+ Measured, not assumed. So `AlreadyExists` has to be our own `path.exists()` check —
110+ there is no exit code to key off. Refusing rather than adopting matters because a
111+ directory with no matching row is an orphan from a crashed create, and re-initialising
112+ it would resurface a private repository's objects under a fresh record.
113+- **`git init` creates missing parent directories itself**, so there is no
114+ `create_dir_all` before it. This was in the plan and the probe removed it.
115+- **`--template=` takes a new bare repo from 18 files to 2.** The default seeds sixteen
116+ `.sample` hooks. Timed at 15.2ms against 13.1ms across 20 runs — so the ~2ms is not
117+ the reason; Steid installs its own hooks later and the samples would be noise to work
118+ around.
119+- **`--initial-branch=main` is explicit** so the host's `init.defaultBranch` cannot
120+ decide it. This machine's git already says `main`, which is precisely why a drift
121+ would go unnoticed — hence the test.
122+- **`GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` are pointed at `/dev/null`**, and the
123+ five `GIT_*` variables that redirect object storage are removed from the child
124+ environment. `GIT_DIR` was checked and does *not* override an explicit path argument,
125+ but `GIT_OBJECT_DIRECTORY` does redirect where objects land, and the failure is
126+ silent — the repository just looks empty.
127+- **One `run_git` owns the invocation.** With a single caller this looks premature; it
128+ is a private function rather than a public abstraction for that reason. The point is
129+ that Milestone 4's `http-backend` spawn cannot quietly disagree about isolation.
130+- **`tokio::process` and `tokio::fs`, never the `std` equivalents.** `init_bare` is not
131+ hot — ~13ms, once per repository — but `remove_dir_all` on a repo with real history
132+ walks every loose object and would stall a runtime worker. The performance that
133+ matters is Milestone 4's per-request spawn, not this.
134+- **`tempfile` for test fixtures, not `target/`.** Parallel-safe by construction and
135+ self-cleaning on panic. Debris under `target/` would be actively harmful here, since
136+ `init_bare` refuses a path that already exists.
137+- **`Repository::description` stays.** Added unrequested and flagged; kept on review
138+ because this milestone's own "Done when" puts repositories on the profile, which
139+ makes it a consumer inside the milestone rather than speculation. Worth noting the
140+ window that closed: the repositories migration had not yet been applied to the dev
141+ database, so removing the column would have been a free in-place edit rather than a
142+ second migration.
143+
100144 ---
101145
102146 ## Reference: what attempt #2 proved
plans/runbook.md+12 −4View file
@@ -15,7 +15,9 @@ attempt and has **not** been re-verified against Topcoat — treat it as a sketc
1515 topcoat` silently resolves to an empty `topcoat v0.0.0` placeholder rather than
1616 failing. Verified on 1.97.1.
1717 - `git` on `PATH` (from Milestone 3 — `git init --bare` creates repos, and from
18 Milestone 4 `git http-backend` serves the protocol)
18+ Milestone 4 `git http-backend` serves the protocol). **`cargo test` needs it too**:
19+ `DiskGitStorage`'s tests run real `git init`, so a machine without `git` fails the
20+ suite, not just the app.
1921
2022 ## Dev setup
2123
@@ -41,7 +43,7 @@ Live now:
4143
4244 ```
4345 STEID_DATABASE_URL=sqlite:steid.db?mode=rwc # default
44STEID_DATA_DIR=./data # default; bare repos, used from M3
46+STEID_DATA_DIR=./data # default; bare repos, read from M3
4547 STEID_INSECURE_COOKIES=false # default; see below
4648 ```
4749
@@ -96,8 +98,14 @@ Sign in at `/auth/login` with the **email**, not the handle.
9698
9799 ## Repo layout on disk (Milestone 3)
98100
99Bare repos at `{STEID_DATA_DIR}/{handle}/{name}.git`. Created empty — no initial
100commit, like GitHub.
101+Bare repos at `{STEID_DATA_DIR}/{handle}/{name}.git`, created with no template (so no
102+`.sample` hooks) and `HEAD` pinned to `refs/heads/main` regardless of the host's
103+`init.defaultBranch`. See [0006](decisions/0006-git-binary-behind-narrow-ports.md).
104+
105+Created empty — no initial commit and no branch, like GitHub.
106+
107+Creating one refuses rather than reusing a directory that already exists, so an orphan
108+left by a create that died mid-way blocks that name until it is removed by hand.
101109
102110 ## Git transport (Milestone 4)
103111
src/application/config.rs+4 −4View file
@@ -11,11 +11,11 @@ pub struct AppConfig {
1111 #[serde(default = "default_database_url")]
1212 pub database_url: String,
1313
14 /// Root directory for bare git repositories, laid out as `{data_dir}/{org}/{repo}.git`.
14+ /// Root directory for bare git repositories, laid out as
15+ /// `{data_dir}/{handle}/{name}.git`.
1516 ///
16 /// Unread until Milestone 2 introduces `GitStorage`; carried now so the config
17 /// surface matches the documented environment.
18 #[allow(dead_code)]
17+ /// Keyed by handle rather than `OrgId` so the directory is legible to anyone
18+ /// debugging it; the cost is that renaming a handle becomes a directory move.
1919 #[serde(default = "default_data_dir")]
2020 pub data_dir: PathBuf,
2121
src/application/error.rs+15 −4View file
@@ -1,12 +1,13 @@
11 use crate::domain::{DomainError, repository::RepositoryError};
22
3use super::port::PasswordError;
3+use super::port::{GitStorageError, PasswordError};
44
55 /// What a use case can fail with.
66 ///
7/// Keeps the three failure sources distinct: a broken rule, broken storage, and a
8/// broken hash are different problems with different responses, and flattening them
9/// makes a storage outage indistinguishable from a validation error.
7+/// Keeps the failure sources distinct: a broken rule, broken storage, a broken hash,
8+/// and a filesystem that would not cooperate are different problems with different
9+/// responses, and flattening them makes a storage outage indistinguishable from a
10+/// validation error.
1011 #[derive(Debug)]
1112 pub enum Error {
1213 /// A domain rule was violated.
@@ -15,6 +16,8 @@ pub enum Error {
1516 Repository(RepositoryError),
1617 /// Hashing or verification failed for a reason other than a wrong password.
1718 Password(PasswordError),
19+ /// A bare repository could not be created or removed on disk.
20+ GitStorage(GitStorageError),
1821 }
1922
2023 impl From<DomainError> for Error {
@@ -35,12 +38,19 @@ impl From<PasswordError> for Error {
3538 }
3639 }
3740
41+impl From<GitStorageError> for Error {
42+ fn from(error: GitStorageError) -> Self {
43+ Self::GitStorage(error)
44+ }
45+}
46+
3847 impl std::fmt::Display for Error {
3948 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4049 match self {
4150 Self::Domain(error) => write!(f, "{error}"),
4251 Self::Repository(error) => write!(f, "{error}"),
4352 Self::Password(error) => write!(f, "{error}"),
53+ Self::GitStorage(error) => write!(f, "{error}"),
4454 }
4555 }
4656 }
@@ -51,6 +61,7 @@ impl std::error::Error for Error {
5161 Self::Domain(error) => Some(error),
5262 Self::Repository(error) => Some(error),
5363 Self::Password(error) => Some(error),
64+ Self::GitStorage(error) => Some(error),
5465 }
5566 }
5667 }
src/application/port.rs+83 −1View file
@@ -3,7 +3,9 @@
33 //! Repository ports live in `domain::repository`; these are the non-persistence
44 //! collaborators. Adapters live in `infrastructure`.
55
6use crate::domain::PasswordHash;
6+use std::path::PathBuf;
7+
8+use crate::domain::{OrgName, PasswordHash, RepoName};
79
810 /// Hashes and verifies passwords.
911 ///
@@ -39,3 +41,83 @@ impl std::fmt::Display for PasswordError {
3941 }
4042
4143 impl std::error::Error for PasswordError {}
44+
45+/// Where bare git repositories live on disk.
46+///
47+/// Laid out as `{data_dir}/{handle}/{name}.git`. Keyed by handle rather than
48+/// [`OrgId`](crate::domain::OrgId) so the data directory is legible to anyone who has
49+/// to debug it; the cost is that renaming a handle becomes a directory move rather
50+/// than a row update.
51+///
52+/// Deliberately narrow. Serving the git protocol and browsing a tree are separate
53+/// concerns with separate shapes — streaming and querying — and get their own ports as
54+/// their use cases arrive, rather than accreting here. See
55+/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md).
56+pub trait GitStorage: Send + Sync {
57+ /// Creates an empty bare repository.
58+ ///
59+ /// Empty means empty: no initial commit, no branch, matching what GitHub does for
60+ /// a repository created without a README.
61+ ///
62+ /// Fails with [`GitStorageError::AlreadyExists`] rather than adopting whatever is
63+ /// already there.
64+ fn init_bare(
65+ &self,
66+ handle: &OrgName,
67+ name: &RepoName,
68+ ) -> impl Future<Output = Result<(), GitStorageError>> + Send;
69+
70+ /// Removes a bare repository, succeeding if there was nothing to remove.
71+ ///
72+ /// This exists to compensate a failed record insert — a repository row and its
73+ /// directory cannot share a transaction — not as a user-facing delete. Deleting a
74+ /// repository properly is a separate use case with its own authorization.
75+ fn remove(
76+ &self,
77+ handle: &OrgName,
78+ name: &RepoName,
79+ ) -> impl Future<Output = Result<(), GitStorageError>> + Send;
80+
81+ /// Where a repository lives.
82+ ///
83+ /// Pure, and says nothing about whether anything exists there. Milestone 4 hands
84+ /// this to `git http-backend`.
85+ fn repo_path(&self, handle: &OrgName, name: &RepoName) -> PathBuf;
86+}
87+
88+/// A repository could not be created or removed on disk.
89+#[derive(Debug)]
90+pub enum GitStorageError {
91+ /// Something already occupies the repository's path.
92+ ///
93+ /// Kept distinct from a general failure because it is the one case a use case can
94+ /// explain to a user, and because with no matching record it means an orphaned
95+ /// directory left by a crashed create.
96+ AlreadyExists,
97+ /// The filesystem or the `git` binary failed.
98+ Backend(Box<dyn std::error::Error + Send + Sync>),
99+}
100+
101+impl GitStorageError {
102+ pub fn backend(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
103+ Self::Backend(error.into())
104+ }
105+}
106+
107+impl std::fmt::Display for GitStorageError {
108+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109+ match self {
110+ Self::AlreadyExists => f.write_str("a repository already exists at that path"),
111+ Self::Backend(error) => write!(f, "git storage failure: {error}"),
112+ }
113+ }
114+}
115+
116+impl std::error::Error for GitStorageError {
117+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
118+ match self {
119+ Self::AlreadyExists => None,
120+ Self::Backend(error) => Some(&**error),
121+ }
122+ }
123+}
src/infrastructure/git.rs+429 −0View file
@@ -0,0 +1,429 @@
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+
7+use std::{
8+ ffi::OsStr,
9+ io,
10+ path::PathBuf,
11+ process::{Output, Stdio},
12+};
13+
14+use tokio::process::Command;
15+
16+use 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.
27+const 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)]
37+pub struct DiskGitStorage {
38+ data_dir: PathBuf,
39+}
40+
41+impl DiskGitStorage {
42+ pub fn new(data_dir: impl Into<PathBuf>) -> Self {
43+ Self {
44+ data_dir: data_dir.into(),
45+ }
46+ }
47+}
48+
49+impl 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.
111+async fn run_git<I, S>(args: I) -> Result<Output, GitStorageError>
112+where
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)]
149+mod 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+}
src/infrastructure/mod.rs+1 −0View file
@@ -1,4 +1,5 @@
11 pub mod database;
2+pub mod git;
23 pub mod password;
34 pub mod repository;
45 pub mod web;