# 0006 — Drive git through the binary, behind narrow ports

**Status:** accepted · **Date:** 2026-08-13

## Context

Milestone 3 is the first time Steid has to touch git at all: creating a repository
means a bare repo on disk as well as a row in SQLite. It will not be the last. The
ladder needs at least three families of git operation, and they arrive one milestone
apart:

| Family | Shape | Consumer |
|---|---|---|
| Lifecycle — `init_bare`, `remove`, `repo_path` | fallible, fire-and-forget | `create_repo` (M3) |
| Protocol — `upload_pack`, `receive_pack` | bidirectional streaming | `serve_clone` / `serve_push` (M4) |
| Query — refs, tree, blob, log | returns domain objects | read models (M5) |

Two questions fall out, and answering only the first is what left attempt #2 with git
invocation details spread across the code.

**What runs git?** [0001](0001-git-over-http-not-ssh.md) already commits to delegating
the smart HTTP protocol to `git http-backend`, so the `git` binary is a runtime
dependency from Milestone 4 whatever happens at Milestone 3.

**What shape does the application see?** `architecture.md` names `GitStorage` and
`GitProtocolServer` as separate ports, but nothing had been built, so whether that
survived contact was untested.

There is also a quieter pressure. Invoking git safely is not one decision but half a
dozen — ambient configuration, redirected object storage, inherited stdin, argument
injection, blocking the async runtime, what a non-zero exit means. Each is invisible
when wrong. Getting them right in `init_bare` and forgetting them in the Milestone 4
`http-backend` spawn would produce no failure, just an inconsistent posture.

## Decision

Steid shells out to the `git` binary, exposed to the application layer as **several
narrow ports** rather than one git service, with **a single infrastructure-side
invoker** that owns how git is actually run.

`GitStorage` (`init_bare`, `remove`, `repo_path`) lands now, in
`application/port.rs`. `GitProtocolServer` and a query port follow when their use
cases exist, not before. All of their adapters live in `infrastructure/git.rs` and go
through `run_git`, which sets the isolation once.

## Alternatives considered

- **`gix` (pure-Rust git).** Faster — microseconds against the measured ~13ms of a
  `git init --bare` fork/exec — and no `PATH` dependency. Rejected on consistency, not
  speed: [0001](0001-git-over-http-not-ssh.md) already requires the binary for the
  protocol, so this would mean two implementations holding assumptions about the same
  on-disk format, and the faster one is on the path that runs once per repository
  creation rather than once per request. Worth revisiting at Milestone 5, where
  browsing is read-only, hot, and the place `gix` is strongest.
- **One `GitService` port.** A single trait covering all three families. Rejected: the
  shapes do not unify — one streams, one queries — and every test fake would have to
  implement the whole surface to exercise a use case that needs two methods. Ports
  live where they are consumed.
- **No shared invoker; each adapter spawns its own `Command`.** Rejected because the
  isolation flags are exactly the kind of thing that drifts silently between call
  sites. There is one caller today, which makes this look premature; the recipe is one
  private function rather than a public abstraction precisely so it is not.
- **A domain abstraction over git objects now.** Rejected as premature. Neither
  creation nor the protocol needs the domain to understand a commit — pack data is
  opaque bytes in transit. Milestone 5 is where `ObjectId`, `RefName` and `TreeEntry`
  become real domain value objects, and it should start with them rather than an
  adapter returning `String`s.

## Consequences

- **Makes easy:** adding the Milestone 4 and 5 ports without re-deciding how git is
  invoked; testing `create_repo` against a fake `GitStorage` that never touches disk.
- **Makes hard:** anything wanting git operations *not* expressible as a subprocess.
  Also every operation pays a fork/exec — fine at once per repository creation,
  something Milestone 5 should measure before browsing does it per page view.
- **New dependencies:** `git` on `PATH` is now required by the **test suite** as well
  as at runtime, since `DiskGitStorage`'s tests run real `git init`. `tokio` gains the
  `process` and `fs` features.
- **Known hole:** the repository row and its directory cannot share a transaction. The
  compensating transaction in `architecture.md` is what `GitStorage::remove` exists
  for, and it still leaves an orphaned directory if the process dies between the two
  writes.
- **Reversibility:** the port shape is cheap to change while there is one adapter and
  one caller. Swapping the binary for `gix` behind an unchanged `GitStorage` is
  contained; doing it after `GitProtocolServer` exists is not, because the protocol
  adapter is the one that genuinely cannot be rewritten in `gix` today.

## Amendment — 2026-08-28, at the start of Milestone 4a

The table above sketched the protocol family as `upload_pack` / `receive_pack`, mirroring
attempt #2's SSH ports. **That shape is wrong for HTTP and is not what gets built.**

[0001](0001-git-over-http-not-ssh.md) delegates the protocol to `git http-backend`,
which is a CGI: it takes a request method, a path, a query string and an environment,
and writes headers followed by a body. Semantic per-operation methods would mean
re-deriving that CGI shape at the call site, so `GitProtocolServer` is CGI-shaped
instead — one request in, one streaming response out.

The direct alternative — spawning `git upload-pack --stateless-rpc` per route, which
*would* give the semantic ports named above — was measured and rejected before this was
written. A real client gzip-compresses its POST body once a repository has a nontrivial
number of refs, on protocol v0 and v2 alike, so the direct route means owning request
inflation and `Git-Protocol` forwarding. It passes against a one-ref test repository and
fails on the first real one. Recorded in `current.md` under Milestone 4a.

What survives unchanged is the part that mattered: several narrow ports rather than one
git service, and one infrastructure-side invoker owning how git is actually run.
Authorization still happens in the use case, before the subprocess exists — the CGI
shape moves *how* git is called, not *who decides* it may be.
