zuka
zuka/README.md

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/README.md
MDREADME.md24.2 KBFormattedDownload
1{% hero %}
2# zuka
3
4Git hosting your agent can actually drive.
5
6One small Rust binary — bare repositories, SSH and HTTP git, a REST API, MCP,
7and CI. No database, no Docker, no drama.
8
9[See it running](https://zuka.worklyn.com) [Five-minute tour](#the-idea) [Quickstart](#quickstart)
10{% /hero %}
11
12{% callout type="note" title="Live, and building itself" %}
13zuka runs at `https://zuka.worklyn.com` and is self-hosting: every push to its
14own source triggers its own CI, which builds the binary that deploys the server
15that ran the CI. It has already caught five real bugs in itself this way.
16The gate: `cargo fmt --check` · `clippy -D warnings` · `cargo test` (301) ·
17`deno task test:live` (69 tests against a real server, with real `git`).
18{% /callout %}
19
20Named for the sound of it rather than the meaning: two syllables, a rare opening
21consonant, one plausible spelling after hearing it once. It sits near Swahili
22*kuzuka* — to surface, to emerge — without claiming to be it.
23
24---
25
26## The idea
27
28**Agents already know git. What they can't do is the GitHub part.**
29
30Claude, Cursor and the rest run `git clone`, `commit` and `push` perfectly well.
31What stops a non-developer shipping with them is everything bolted on top:
32creating the repository, orgs and teams, deploy keys, permission matrices, pull
33requests, review, Actions YAML.
34
35So the split is strict:
36
37{% cards %}
38{% card title="git does the git" %}
39Code moves over the wire protocol, HTTP or SSH. Nothing proprietary.
40{% /card %}
41{% card title="the API does the rest" %}
42Create a repo, register a key, read a file, check CI — over REST and MCP.
43{% /card %}
44{% /cards %}
45
46**Nothing in the API writes to a repository.** No commit endpoint, no file
47endpoint, no ref-moving endpoint. A second write path would mean two homes for
48the fast-forward rule, two concurrency models and a merge story — git already
49has all three. The write path is `git push`.
50
51What's deliberately absent is the point: no organisations, no teams, no pull
52requests, no review, no issues. The whole model is accounts, repositories,
53refs, keys and runs.
54
55---
56
57## Your README is the website
58
59Open a repository in a browser and you don't get a wall of filenames — you get
60the README, rendered as a page, the way [Markdoc](https://markdoc.dev/) turns
61Stripe's doc files into stripe.com/docs. The file listing is one tab away, not
62the thing a visitor has to squint at first. This page you're reading *is* that
63website.
64
65The viewer is built for people who have never used a code host: the tabs say
66**Overview · Files · History · Branches** instead of tree, log and refs; every
67tab remembers which branch you were on; history reads like a chat feed with a
68coloured initial for each author; statuses say *Added*, *Changed*, *Removed*.
69A wrong link gets a sentence and a way home, not machine JSON.
70
71Plain markdown already works. For readmes that want to be front pages, there's
72a small dialect — a subset of Markdoc's syntax. A tag sits alone on its own
73line; everything between tags is ordinary markdown:
74
75```
76{% hero %}
77# Big friendly title
78One-line pitch under it.
79
80[Primary button](docs/start.md) [Second button](#below)
81{% /hero %}
82
83{% callout type="tip" title="Good to know" %}
84Callouts come as note, tip, warn and danger.
85{% /callout %}
86
87{% cards %}
88{% card title="Left" %}
89Feature one.
90{% /card %}
91{% card title="Right" %}
92Feature two.
93{% /card %}
94{% /cards %}
95
96{% details summary="The long table nobody reads twice" %}
97…hidden until tapped, no JavaScript involved…
98{% /details %}
99```
100
101The rules, all of them:
102
103- A tag line starts at column zero. Indented or fenced tags are shown, not run —
104 which is how this README quotes them.
105- Unknown tags vanish and their content stays, so a document written for a
106 richer engine degrades to its words, never to tag soup.
107- An unclosed tag is closed for you at the end. A typo can't eat the page.
108- Relative links work: `[guide](docs/guide.md)` opens that file rendered as a
109 page, `![shot](docs/shot.png)` serves the image, `[src](src/)` opens the
110 listing. A folder of markdown becomes a small site with zero configuration.
111- Headings get anchor ids, so `[jump](#the-idea)` works — this page uses them.
112- Any `.md` file renders as a document; add `?plain=1` to see the raw text.
113
114And because a README is written by anyone who can push — on a public
115repository, a stranger — the whole surface stays paranoid: raw HTML is dropped,
116link schemes pass an allow-list, every interpolated value is escaped, and the
117page ships a CSP with **no script source at all**. The dialect adds wrappers,
118not execution.
119
120---
121
122## Install onto a host
123
124```sh
125sudo zuka setup --standalone --dry-run # prints every file it would write
126sudo zuka setup --standalone
127```
128
129It writes `/etc/zuka.env` and two systemd units — the service and maintenance
130separately, because a long repack must not stall requests — then prints the
131remaining commands rather than running them. Creating users and enabling units
132are steps someone may reasonably want to do differently.
133
134**`--domain` is optional, and does one thing:** it adds a Caddyfile so Caddy
135obtains and renews TLS for that name, and sets the public URLs advertised in
136clone URLs. Without it you get a service on `127.0.0.1:8790` with no TLS, which
137is right behind an existing proxy or for a local trial.
138
139```sh
140sudo zuka setup --standalone --domain git.example.com
141```
142
143**`--isolated`** installs the multi-tenant shape: a control plane that holds no
144repositories and gives every account its own Incus container. Code, CI and
145objects for one account never touch another's filesystem. `--domain` is
146optional here too and means exactly the same thing — the control plane is the
147only public listener, so it is the only thing a certificate is for.
148
149```sh
150sudo zuka setup --isolated
151curl -X POST localhost:8790/v1/accounts/alice # returns immediately
152```
153
154Set a public URL if you have one: a tenant is only reachable *through* the
155control plane, so left to itself it advertises its own container address and
156hands callers a clone URL they cannot use.
157
158Provisioning never blocks a request. The account records intent and a
159reconciler drives it to `ready`; a request arriving early gets `429` with a
160`Retry-After`. Tenants are stopped when idle and started again on demand.
161
162{% callout type="warn" title="Static linking is not optional in --isolated" %}
163The tenant binary is pushed into a container whose C library is not the host's:
164
165```sh
166cargo build --release --target x86_64-unknown-linux-musl
167```
168{% /callout %}
169
170## Quickstart
171
172Needs `git` ≥ 2.41 on the host.
173
174```sh
175cargo build --release
176export ZUKA_DATA_DIR=/tmp/zuka && mkdir -p "$ZUKA_DATA_DIR"
177
178TOKEN=$(./target/release/zuka token alice --scopes admin) # shown once
179./target/release/zuka
180# [boot] listening on http://127.0.0.1:8790
181# [ssh] listening on ssh://127.0.0.1:2222
182```
183
184**Create and push**
185
186```sh
187curl -X POST localhost:8790/v1/repos \
188 -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
189 -d '{"name":"site","visibility":"public"}'
190
191git clone "http://x-access-token:$TOKEN@127.0.0.1:8790/alice/site.git"
192cd site && echo hello > README.md
193git add . && git commit -m "first" && git push origin HEAD:refs/heads/main
194```
195
196The git username is ignored; the password is your token. `visibility` defaults
197to `private`; a public repository is clonable and browsable by anyone, writable
198by no one anonymous. Open `http://127.0.0.1:8790/alice/site` and there's your
199website.
200
201**Use SSH instead**
202
203```sh
204./target/release/zuka key alice ~/.ssh/id_ed25519.pub --title laptop
205git clone ssh://git@127.0.0.1:2222/alice/site.git
206```
207
208`--read-only` makes a key clone-only; `--repos a,b` confines it.
209
210**Read without cloning**
211
212```sh
213A="Authorization: Bearer $TOKEN"; R=localhost:8790/v1/repos/alice/site
214curl -H "$A" $R/refs
215curl -H "$A" "$R/tree/src?ref=refs/heads/main"
216curl -H "$A" "$R/raw/README.md"
217curl -H "$A" "$R/search?q=TODO"
218curl -H "$A" "$R/blame/src/main.rs"
219curl -H "$A" "$R/compare?base=<sha>&head=<sha>"
220```
221
222On `/v1` the ref is always a **query parameter**, never a path segment —
223`feature/login` and `src/main.rs` both contain slashes, so `/raw/{ref}/{path}`
224has no unique parse. The browser URLs accept the ambiguity for the sake of
225readable links; the API does not.
226
227**Turn on CI**
228
229```sh
230ZUKA_CI_ENABLED=1 ./target/release/zuka
231```
232
233```toml
234# .zuka.toml, in the repository
235[run]
236steps = ["cargo fmt --check", "cargo test"]
237timeout_secs = 600
238branches = ["refs/heads/main"] # optional; omit to run everywhere
239```
240
241Push, then `curl -H "$A" $R/runs` and `$R/runs/{id}/logs`.
242
243---
244
245## For agents — MCP
246
247```
248POST /mcp JSON-RPC 2.0: initialize · ping · tools/list · tools/call
249```
250
251The intended session: **`repo_create` → `key_add` → plain
252`git clone`/`commit`/`push` → `run_list`**. There is no tool that writes to a
253repository, and `initialize` says so, so a model doesn't go hunting for one.
254
255A tool failure that's the caller's fault comes back as a tool result with
256`isError: true`, not a JSON-RPC error — the model should see it and adapt, not
257have the call look broken.
258
259{% details summary="The 18 tools" %}
260
261**Administration** — the part git can't do
262| Tool | |
263|---|---|
264| `repo_create` | Create a repository. Returns both clone URLs. |
265| `repo_list` `repo_get` `repo_delete` | |
266| `key_add` `key_list` `key_remove` | Register an SSH key so the agent can then use git. |
267
268**Discovery** — read without cloning
269| Tool | |
270|---|---|
271| `ref_list` | Branches, tags, notes with tip commits. |
272| `tree_list` | List a directory. |
273| `file_read` | Read a text file. |
274| `search` | Fixed-string search across the tree. |
275| `commit_log` `commit_get` | History; optionally path-filtered. |
276| `compare` | Diff from the merge base. |
277| `blame` | Who last changed each line. |
278
279**CI**
280| Tool | |
281|---|---|
282| `run_list` `run_get` `run_logs` | Did the build pass, and why not. |
283
284{% /details %}
285
286---
287
288## API
289
290Full contract: [`openapi.json`](openapi.json), also served at `/openapi.json`.
291
292| | |
293|---|---|
294| `GET /healthz` | Mode, version, disk headroom, CI state. `503` below the reserve. |
295| `GET /openapi.json` | This service's OpenAPI 3.1 document. |
296| `GET /v1/account` | Identity, scopes, storage usage and limits. |
297| `GET POST /v1/repos` | List, create (optionally `import_url`, `visibility`). |
298| `GET PATCH DELETE /v1/repos/{account}/{repo}` | Read, update settings, soft-delete. |
299| `GET .../refs?type=` | Branches, tags, notes. |
300| `GET .../tree/{path}?ref=` | Directory listing. |
301| `GET .../raw/{path}?ref=` | File bytes. |
302| `GET .../commits?ref=&path=&limit=` | Log. |
303| `GET .../commits/{sha}` | One commit and its changed paths. |
304| `GET .../compare?base=&head=` | Diff from the merge base. |
305| `GET .../search?q=&path=&ref=` | Content search. |
306| `GET .../blame/{path}?ref=` | Line attribution. |
307| `GET POST .../runs` · `GET PATCH .../runs/{id}` · `GET .../runs/{id}/logs` | CI. |
308| `GET POST /v1/keys` · `DELETE /v1/keys/{id}` | SSH keys. |
309| `POST /mcp` | MCP JSON-RPC. |
310| `/{account}/{repo}.git/*` | Git smart HTTP. Protocol v2. |
311| `ssh://git@host/{account}/{repo}.git` | Git over SSH, plus `git archive --remote`. |
312
313The browser lives on the same origin, read-only, no credential required for
314public repositories: `/{account}/{repo}` (the clone URL minus `.git`), plus
315`/tree/{ref}/{path}`, `/blob/{ref}/{path}` (`?plain=1` for raw text),
316`/raw/{ref}/{path}` (download), `/commits/{ref}` (`?from={sha}` pages older),
317`/commit/{sha}` and `/refs`. `/` redirects to the repository named by
318`ZUKA_HOME_REPO`.
319
320Errors on `/v1` and `/mcp` are RFC 9457 `application/problem+json` with a
321stable `type` URI — discriminate on `type`, never on `detail`. The git paths
322answer in `text/plain`, because git prints the body straight at the user. The
323browser pages answer a missing page in HTML, because a person tapped that link.
324
325Lists return `{items, truncated}`. There is no cursor, and none is claimed.
326
327### CLI
328
329```
330zuka serve
331zuka setup --standalone|--isolated install onto this host [--domain d] [--dry-run]
332zuka jobs maintenance daemon (own service unit)
333zuka gc | sweep | fsck [--repair] one-shot maintenance
334zuka token <account> [--scopes s] [--repos r] [--days n] [--no-expiry]
335zuka key <account> <path.pub> [--title t] [--read-only] [--repos r]
336zuka version
337```
338
339Scopes: `repo:read`, `repo:write`, `admin`, `ci`. Tokens expire in 90 days
340unless you pass `--days` or opt out with `--no-expiry`, which warns.
341Credentials added while the server runs take effect without a restart.
342
343`fsck` exits non-zero on an orphan so a timer notices. It reports a repository
344with no metadata but never deletes one — repair only removes records that
345describe nothing.
346
347---
348
349## Configuration
350
351Everything has a working default; nothing is required.
352
353{% details summary="All settings" %}
354
355| Variable | Default | |
356|---|---|---|
357| `ZUKA_BIND` | `127.0.0.1:8790` | HTTP listener. |
358| `ZUKA_SSH_BIND` | `127.0.0.1:2222` | SSH listener; `off` disables. |
359| `ZUKA_DATA_DIR` | `/var/lib/zuka` | Everything on disk. |
360| `ZUKA_PUBLIC_URL` / `_SSH` | derived | Advertised clone URLs. |
361| `ZUKA_HOME_REPO` | — | `account/repo` featured at `/` in the browser. |
362| `ZUKA_PROBLEM_BASE` | `https://zuka.dev/problems` | Point error `type` URIs at your own docs. |
363| **Limits** | | |
364| `ZUKA_MAX_REPOS_PER_ACCOUNT` | `100` | `0` disables. |
365| `ZUKA_MAX_REPO_MB` / `_ACCOUNT_MB` | `2048` / `10240` | `0` disables. |
366| `ZUKA_RATE_PER_MINUTE` | `600` | Per credential, on `/v1` and `/mcp`. `0` disables. |
367| `ZUKA_MAX_PACK_MB` | `512` | Largest push. Also sets `receive.maxInputSize`. |
368| `ZUKA_MAX_BLOB_MB` | `32` | Largest blob served inline. |
369| `ZUKA_MAX_BODY_MB` | `10` | Largest JSON body. |
370| `ZUKA_PAGE_LIMIT` / `_MAX_PAGE_LIMIT` | `50` / `200` | |
371| `ZUKA_MAX_CONCURRENT_GIT` | `8` | Concurrent git subprocesses. |
372| `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes return `507`. |
373| **CI** | | |
374| `ZUKA_CI_ENABLED` | `0` | Off by default; see below. |
375| `ZUKA_CI_TIMEOUT_SECS` | `600` | Ceiling. A spec may lower it, never raise it. |
376| `ZUKA_CI_MAX_CONCURRENT` | `2` | |
377| `ZUKA_CI_LOG_MB` | `8` | Output cap per run. |
378| `ZUKA_CI_MEMORY_MB` | `2048` | `RLIMIT_AS`. `0` disables. |
379| `ZUKA_CI_FILE_MB` | `2048` | `RLIMIT_FSIZE`. Independent of the memory limit. |
380| `ZUKA_CI_MAX_PROCESSES` | `0` | `RLIMIT_NPROC` — per-UID, so only meaningful with a runner uid. |
381| `ZUKA_CI_KEEP_RUNS` | `50` | Runs retained per repository. |
382| **Maintenance** | | |
383| `ZUKA_GC_INTERVAL_SECS` | `21600` | |
384| `ZUKA_GC_PRUNE_GRACE` | `2.weeks.ago` | Objects younger than this are never pruned. |
385| `ZUKA_DELETED_RETENTION_DAYS` | `7` | |
386| **Import** | | |
387| `ZUKA_IMPORT_ENABLED` | `1` | |
388| `ZUKA_IMPORT_TIMEOUT_SECS` | `300` | |
389| `ZUKA_IMPORT_ALLOW_PRIVATE` | `0` | Permit private/loopback sources. Only on a trusted network. |
390| **Multi-tenant** | | |
391| `ZUKA_MODE` | `standalone` | `standalone` · `control` · `tenant` |
392| `ZUKA_OPERATOR` | — | Account allowed to manage accounts. Unset ⇒ refused for all. |
393| `ZUKA_INCUS_IMAGE` | `images:debian/12` | Image for a new tenant. |
394| `ZUKA_INCUS_NETWORK` | `incusbr0` | Network a tenant joins. |
395| `ZUKA_TENANT_BINARY` | this binary | Pushed into each container; must be static. |
396| `ZUKA_TENANT_PORT` | `8790` | Port a tenant listens on inside its container. |
397| `ZUKA_CONTROL_PUBLIC_KEYS` | — | Tenant only. Comma-separated; more than one allows key rotation. |
398| `ZUKA_PROXY_TIMEOUT_SECS` | `300` | Ceiling on one proxied request. |
399
400{% /details %}
401
402`ZUKA_ME_URL` is parsed and then **refuses to boot** — hosted auth isn't
403implemented, and accepting a security-relevant setting while ignoring it is
404worse than not accepting it.
405
406### Maintenance
407
408Run `zuka jobs` as a second service unit — a long repack must not stall request
409handling.
410
411```ini
412[Unit]
413Description=zuka maintenance
414After=zuka.service
415[Service]
416ExecStart=/usr/local/bin/zuka jobs
417EnvironmentFile=/etc/zuka.env
418User=zuka
419Restart=always
420[Install]
421WantedBy=multi-user.target
422```
423
424It garbage-collects (git's own auto-gc is disabled on every repo, so a repack
425never fires *inside* a push), sweeps deleted repositories and old runs, and
426fscks.
427
428What makes GC safe against a live clone is the **grace window**, not a lock — a
429lock couldn't span the two processes. `upload-pack` can reference an object
430between resolving and streaming it, so nothing younger than `GC_PRUNE_GRACE` is
431pruned. A test clones a 12 MB repo while GC runs and checks the result byte for
432byte.
433
434{% callout type="danger" title="Force-pushing does not delete" %}
435A committed secret stays fetchable by sha until GC prunes it. Force-pushing
436past it moves the ref, not the object.
437{% /callout %}
438
439---
440
441## Security
442
443Each of these has a test that fails if it regresses. Several are here because
444an earlier version got them wrong.
445
446**Names can't escape the data directory.** Ref names go through `gix-validate`,
447never a hand-rolled check — `../../config` is a command-execution primitive via
448`core.pager` and `core.sshCommand`. Tree paths reject any `.git` component
449after Unicode normalisation, case folding, HFS-ignorable-codepoint stripping
450and trailing dot/space removal.
451
452**Names are case-folded.** `Alice/Site` and `alice/site` are one resource.
453Comparing the raw path segment once let a case-variant account reach another
454account's repository on a case-insensitive filesystem while passing the
455ownership check.
456
457**An unreadable repository is `404`, not `403`.** `403` enumerates private
458repos. Confinement applies to destruction too: a token scoped to one repo
459can't delete another.
460
461**Anonymous means read, and only through one door.** Exactly one function
462grants anonymous access, it can only read, and a presented-but-rejected
463credential stays rejected — an expired token never quietly degrades into an
464anonymous visitor. The browser answers a private repository with the same page
465as an absent one; the git wire answers both with a `401` challenge, so neither
466surface can be used to enumerate names.
467
468**The viewer renders hostile input inert.** Every value is escaped, markdown
469drops raw HTML and rewrites link schemes against an allow-list, and pages carry
470a CSP with no script source — so anything that survived the escaping would
471still have nowhere to execute. Raw file bytes are served as an attachment in a
472CSP sandbox, never rendered on the origin.
473
474**Rewriting is allowed; protection is opt-in.** `rebase` and `--amend` are
475ordinary git and blocking them protects nobody when there's one agent and no
476reviewer — git's reflog is the recovery path. Set `protected_refs` on the
477branch something deploys from; it's then enforced server-side over HTTP *and*
478SSH, which matters because `--force` bypasses the client check. One Rust
479function; the hook is a one-line `exec` into the binary.
480
481**SSH runs git and nothing else.** No shell, no pty, no subsystem. Three
482services: `upload-pack`, `receive-pack`, `upload-archive`. The exec string is
483parsed, never shelled, so `git-upload-pack '/a/b.git'; id` is a parse error.
484The account comes from *which registered key matched*, never the SSH username.
485Only `GIT_PROTOCOL` crosses into the child.
486
487**Only three endpoints route under `.git`.** The dumb protocol isn't served. A
488duplicated `service` parameter is refused — this server would take the first
489and the CGI the last, so a duplicate would let the authorization decision and
490the running service disagree.
491
492**Import can't reach the local network.** The one place a caller-supplied URL
493is fetched. Checked in two stages: shape (scheme, credentials) then *every
494address the host resolves to* — checking the hostname alone is defeated by a
495name that resolves to `169.254.169.254`.
496
497**Credentials are hashed, scoped, expiring and `0600`.** Token comparison has
498no early return. Public keys match on raw bytes; `authorized_keys` option
499prefixes like `command=` are refused.
500
501**Deletes are recoverable.** Soft-deleted to `tmp/deleted/`, swept on
502retention.
503
504**Nothing buffers a pack.**
505
506```
507 before after
508200 MB push, server RSS ~360 MB 5 MB
5094 concurrent clones 1508 MB 5 MB
510```
511
512The child is killed by **process group** on disconnect — `kill_on_drop` reaches
513only the direct child, and `pack-objects` is a grandchild that otherwise kept
514running for seconds after the client left.
515
516### Multi-tenant isolation
517
518In `--isolated`, each account gets its own Incus container, unprivileged and
519with nesting off. The control plane holds no repositories: a push through it
520lands in the account's container and nowhere on the host.
521
522A tenant is told who is calling by an **Ed25519 assertion the control plane
523signs**, not a shared secret. That matters because a tenant runs the account's
524own CI, which is arbitrary code — anything symmetric in there would forge every
525other tenant. The private key never leaves the control plane; tenants hold only
526the public half, which forges nothing.
527
528The assertion authenticates one request, not an identity: single-use nonce,
52930-second window, bound to the method and path. It is deliberately *not* bound
530to the body, because a push is a multi-gigabyte pack the proxy streams — the
531nonce is what stops replay.
532
533Verified on a real host rather than asserted, in
534[`infra/README.md`](../../infra/README.md): CI inside a tenant reports
535`uname -n` = `zuka-alice`, and a second account gets `404` on the first's
536repository and an empty repo list.
537
538### CI is not a security boundary in standalone
539
540Steps run as the service user with rlimits, a scrubbed environment, a
541process-group kill on timeout and a bounded log — but **no container and no
542network namespace**. Anyone who can push can run code as that user. CI is off
543by default and the boot log says so.
544
545A step allowlist was considered and rejected: `sh -c` defeats it in one
546character. Enforcement is at the OS or it isn't enforcement. Multi-tenant
547isolation is what `--isolated` is for.
548
549---
550
551## Renaming it
552
553The name lives in exactly one file. `src/brand.rs` derives the env prefix, data
554directory, problem-type URIs, auth realm, git config namespace, CI filename and
555hook shim from `CARGO_PKG_NAME`.
556
557Renaming is one line in `Cargo.toml` plus the `openapi.json` title — verified
558by actually doing it. A unit test walks `src/` and fails if the name appears as
559a literal anywhere outside `brand.rs`; it has already caught a hardcoded auth
560realm, and it is the reason even the browser viewer's stylesheet URL is derived.
561
562---
563
564## Development
565
566```sh
567cargo fmt --check
568cargo clippy --all-targets -- -D warnings
569cargo test # 301 unit tests
570deno task test:live # 69 live tests: spawns the binary, drives real git and ssh
571```
572
573The live suite is the real proof — unit tests cover units, but only a real
574clone and push exercise the wire protocol. It runs in CI rather than
575self-skipping there.
576
577```
578src/
579├── brand.rs the one place the product name appears
580├── config.rs Config::from_env(); fails boot on a bad value
581├── error.rs Error -> RFC 9457 problem+json
582├── core/ domain operations shared by every facade
583├── git/
584│ ├── exec.rs the only place this service invokes git
585│ ├── validate.rs security controls: names, refs, paths
586│ ├── store.rs repositories, hooks, ref rules
587│ ├── discover.rs the read core, shared by REST, MCP and the browser
588│ ├── transport.rs streaming CGI bridge
589│ └── import.rs SSRF-guarded cloning
590├── http/ routing, limits, responses, auth, rate limiting
591├── api/ REST facade
592├── mcp/ MCP facade
593├── web/ the browser viewer: escaping, the tag dialect, the pages
594├── ssh/ SSH server and exec-command parser
595├── ci/ spec, run store, runner, executor
596├── control/ accounts, provisioning, reconciler, streaming proxy
597├── setup.rs systemd units, Caddyfile, environment file
598├── jobs/ gc, sweep, fsck
599├── account/ identity, tokens, SSH keys
600└── store/ metadata, quotas, change-aware file cache
601```
602
603Two rules worth knowing before changing anything:
604
605- **git is the only writer.** No endpoint or tool modifies repository contents.
606- **Facades translate; they don't decide.** Authorization lives on `Identity`,
607 git rules in `git/`, shared operations in `core/`. `api/` and `mcp/` reshape
608 payloads and nothing else — they had drifted copies of repo create and delete
609 once, and those copies had already diverged in four ways.
610
611More: [`SPEC.md`](SPEC.md) · [`ROADMAP.md`](ROADMAP.md) · [`AGENTS.md`](AGENTS.md)