steid

@jamesgill /

steid/plans/decisions/0006-git-binary-behind-narrow-ports.md
6.3 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.
87
88## Amendment — 2026-08-28, at the start of Milestone 4a
89
90The table above sketched the protocol family as `upload_pack` / `receive_pack`, mirroring
91attempt #2's SSH ports. **That shape is wrong for HTTP and is not what gets built.**
92
93[0001]0001-git-over-http-not-ssh.md delegates the protocol to `git http-backend`,
94which is a CGI: it takes a request method, a path, a query string and an environment,
95and writes headers followed by a body. Semantic per-operation methods would mean
96re-deriving that CGI shape at the call site, so `GitProtocolServer` is CGI-shaped
97instead — one request in, one streaming response out.
98
99The direct alternative — spawning `git upload-pack --stateless-rpc` per route, which
100*would* give the semantic ports named above — was measured and rejected before this was
101written. A real client gzip-compresses its POST body once a repository has a nontrivial
102number of refs, on protocol v0 and v2 alike, so the direct route means owning request
103inflation and `Git-Protocol` forwarding. It passes against a one-ref test repository and
104fails on the first real one. Recorded in `current.md` under Milestone 4a.
105
106What survives unchanged is the part that mattered: several narrow ports rather than one
107git service, and one infrastructure-side invoker owning how git is actually run.
108Authorization still happens in the use case, before the subprocess exists — the CGI
109shape moves *how* git is called, not *who decides* it may be.