# 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.

## Amendment — 2026-08-29, the Milestone 5 read path

0006 deferred one question to this point: whether `gix` should take over the read path,
"where browsing is read-only, hot, and the place `gix` is strongest". Answered here, with
the measurement it asked for.

**The read path stays on the `git` binary, one process per query, for now.**

### What was measured

On a 201-commit repository, 50 runs averaged per command: `rev-parse` 11.2ms, `ls-tree`
11.7ms, `cat-file` 11.6ms, `log -20` 11.8ms, `for-each-ref` 14.4ms. **The cost is
starting the process, not answering the question** — every command lands in the same
band regardless of the work it does, matching the ~13ms `git init --bare` from Milestone
3. A three-call tree page is therefore ~35ms of pure overhead.

### Why not `gix`

Checked against gitoxide's own crate status rather than recalled. The read path is
comprehensively covered — `gix-ref`, `gix-odb`, `gix-pack`, `gix-object`, `gix-traverse`
and `gix-revwalk` all do what browsing needs. Capability is not the obstacle.

**Server-side protocol support is.** `gix-protocol` offers `upload-pack` /
`receive-pack` plumbing *for in-process transports* only — not a network server. Since
[0001](0001-git-over-http-not-ssh.md) commits to `git http-backend`, the binary is a
permanent runtime dependency, so adopting `gix` would not replace an implementation. It
would **add** one: two libraries reading the same bytes on disk, forever, needing to
agree. That is the cost this ADR priced originally, now confirmed rather than assumed —
and the price of avoiding it is 35ms a page for one user.

### Why not fork gitoxide and write the server side

Considered and rejected. A fork only pays off if it lets Steid delete the binary, and
that requires server-side `receive-pack`: pack verification and fsck, quarantine, atomic
ref updates, hooks, report-status — executed against untrusted packs from the internet,
a surface with a real CVE history. Months of work, no user-visible capability, and
[ROADMAP.md](../ROADMAP.md#why-this-order) names disappearing into protocol work as what
ended both previous attempts. If the work is wanted for its own sake it belongs upstream
in gitoxide, whose stated goal already includes server support, on its own timeline —
not on Steid's critical path.

### The upgrade path, which is the point

Staged, and each stage keeps `GitQuery` unchanged:

1. **One-shot commands.** ~35ms a page. Where Milestone 5 starts.
2. **A `cat-file --batch` process held open per repository.** Deletes the fork cost for
   object reads; what Gitaly and Gitea both do. Projected at ~1–3ms a page — projected,
   not measured, and to be benchmarked before it is claimed.
3. **`gix-object` decoding bytes fetched by stage 2**, if tree and commit parsing ever
   need to happen in-process. Worth distinguishing from adopting `gix`: a decoder for a
   documented format is not a second implementation of repository *access* — no ref
   resolution, no pack access, no protocol. Full `gix` and the fork stay rejected;
   neither is on this ladder.

**One thing must be got right now for stage 2 to stay cheap.** A batch adapter owns live
subprocesses, so it has to be a shared handle in app context rather than constructed per
request as the SQLite adapters are. Built per request, the pool would spawn and kill a
process per request — the fork cost back, plus a pool that never pools. The port gives us
the seam; it does not give us the lifetime, and the lifetime is a composition-root
decision that has to be made before the first adapter is wired.

### Reopening conditions

Evidence, not intuition:

- A per-file last-commit column becomes a must-have — ~230ms for a twenty-file directory
  at one fork per entry. Go to stage 2 first, not to `gix`.
- Browsing takes real public traffic, making the read path hot rather than occasional.
- gitoxide ships usable server-side `upload-pack` and `receive-pack`, at which point it
  stops being additive and becomes a candidate to replace the binary outright.
