steid

@jamesgill /

steid/plans/decisions/0006-git-binary-behind-narrow-ports.md
4.9 KBCode·Blame·Raw
1# 0006 — Drive git through the binary, behind narrow ports
2
3**Status:** accepted · **Date:** 2026-08-13
4
5## Context
6
7Milestone 3 is the first time Steid has to touch git at all: creating a repository
8means a bare repo on disk as well as a row in SQLite. It will not be the last. The
9ladder needs at least three families of git operation, and they arrive one milestone
10apart:
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
18Two questions fall out, and answering only the first is what left attempt #2 with git
19invocation details spread across the code.
20
21**What runs git?** [0001]0001-git-over-http-not-ssh.md already commits to delegating
22the smart HTTP protocol to `git http-backend`, so the `git` binary is a runtime
23dependency 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
27survived contact was untested.
28
29There is also a quieter pressure. Invoking git safely is not one decision but half a
30dozen — ambient configuration, redirected object storage, inherited stdin, argument
31injection, blocking the async runtime, what a non-zero exit means. Each is invisible
32when 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
37Steid shells out to the `git` binary, exposed to the application layer as **several
38narrow ports** rather than one git service, with **a single infrastructure-side
39invoker** 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
43cases exist, not before. All of their adapters live in `infrastructure/git.rs` and go
44through `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.