| 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 | |
| 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. |
| 87 | |
| 88 | ## Amendment — 2026-08-28, at the start of Milestone 4a |
| 89 | |
| 90 | The table above sketched the protocol family as `upload_pack` / `receive_pack`, mirroring |
| 91 | attempt #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`, |
| 94 | which is a CGI: it takes a request method, a path, a query string and an environment, |
| 95 | and writes headers followed by a body. Semantic per-operation methods would mean |
| 96 | re-deriving that CGI shape at the call site, so `GitProtocolServer` is CGI-shaped |
| 97 | instead — one request in, one streaming response out. |
| 98 | |
| 99 | The 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 |
| 101 | written. A real client gzip-compresses its POST body once a repository has a nontrivial |
| 102 | number of refs, on protocol v0 and v2 alike, so the direct route means owning request |
| 103 | inflation and `Git-Protocol` forwarding. It passes against a one-ref test repository and |
| 104 | fails on the first real one. Recorded in `current.md` under Milestone 4a. |
| 105 | |
| 106 | What survives unchanged is the part that mattered: several narrow ports rather than one |
| 107 | git service, and one infrastructure-side invoker owning how git is actually run. |
| 108 | Authorization still happens in the use case, before the subprocess exists — the CGI |
| 109 | shape moves *how* git is called, not *who decides* it may be. |