{% hero tone="ink" %}
# zuka

Anyone can make software now.
Keeping it somewhere safe shouldn't be the hard part.

zuka is git hosting for a person and their agent — one small binary that keeps
the code safe, shows it as a website, and stays out of the way.

[See it live](https://zuka.worklyn.com) [Start with why](#why) [Run your own](#install-onto-a-host)
{% /hero %}

{% callout type="note" title="This page is zuka" %}
You are reading a README, served as a website by the thing it describes. zuka
hosts its own source and ships itself: every push builds, tests and deploys the
server that will serve the next push.
{% /callout %}

## Why

Agents changed who gets to make software. A person who has never opened a
terminal can describe a thing — a booking page, a family recipe book, a tool
for their shop — and an agent will build it. The code part stopped being the
barrier.

Then the code needs a home, and the old world returns: create an account and
an organisation and a team, mint a deploy key, open a pull request, satisfy a
review, appease a YAML file. Every one of those steps was designed for
professional software teams. Every one of them is a closed door to exactly the
person the agent just let in.

zuka exists because of one belief: **the machinery around code should not
decide who gets to make things.** Keep the work safe. Show it in a form a
person can actually read. Let the agent handle the technical part — it is
good at that.

## How

Three decisions carry the whole product.

{% cards %}
{% card title="git does the git" %}
Agents already speak git fluently. Code moves over the ordinary git wire
protocol, HTTP or SSH — nothing proprietary, nothing to relearn, nothing that
breaks when the agent changes.
{% /card %}
{% card title="Everything else only asks and reads" %}
The API and MCP create repositories, register keys, read files, report CI.
None of them can change code. The one write path is `git push`, so there is
exactly one concurrency model, one merge story, one source of truth — git's.
{% /card %}
{% card title="People get a website, not a wall of files" %}
The front page of a repository is its README, rendered as a page. The words
say Files, History and Branches. A wrong link gets a sentence and a way home,
not machine JSON.
{% /card %}
{% /cards %}

And one refusal, held on purpose: **no organisations, no teams, no pull
requests, no review queues, no issues.** Those are coordination tools for
large groups of humans. One person and their agent don't need them, and every
one of them is a room a newcomer gets lost in. The whole model is accounts,
repositories, refs, keys and runs.

Security follows the same shape rather than being bolted on: anonymous means
read-only and passes through exactly one door in the code; a private
repository is indistinguishable from an absent one; nothing on the browser
surface can execute a script at all. The detail lives in
[Security](#security), each claim with a test that fails if it regresses.

## What

One Rust binary, no database, no Docker, no external services. It serves:

- **Bare git repositories** over HTTP and SSH — clone, push, `git archive`
- **A browser** where every repository is a small website ([how that works](#your-readme-is-the-website))
- **A REST API** for everything git can't do: create, configure, read, search
- **MCP** so agents drive it natively ([the tools](#for-agents--mcp))
- **CI** from a five-line file in the repository
- **Multi-tenant isolation** — every account in its own container, when you want it

The same binary is the server, the CLI and the setup tool. A stranger can drop
it on a VM and have git hosting with TLS in minutes.

---

## Your README is the website

Open a repository in a browser and you don't get a wall of filenames — you get
the README, rendered as a page, the way [Markdoc](https://markdoc.dev/) turns
Stripe's doc files into stripe.com/docs. The file listing is one tab away, not
the thing a visitor has to squint at first.

The viewer is built for people who have never used a code host: the tabs say
**Overview · Files · History · Branches** instead of tree, log and refs; every
tab remembers which branch you were on; history reads like a chat feed with a
coloured initial for each author; statuses say *Added*, *Changed*, *Removed*.

Plain markdown already works. For readmes that want to be front pages, there's
a small dialect — a subset of Markdoc's syntax. A tag sits alone on its own
line; everything between tags is ordinary markdown:

```
{% hero tone="grape" %}
# Big friendly title
One-line pitch under it.

[Primary button](docs/start.md) [Second button](#below)
{% /hero %}

{% big tone="sun" %}
A statement in display type.
{% /big %}

{% band tone="mint" %}
## A full-width painted stripe
Bands turn a README into a landing page. Text keeps a readable
measure; the colour reaches the edges of the screen.
{% /band %}

{% callout type="tip" title="Good to know" %}
Callouts come as note, tip, warn and danger.
{% /callout %}

{% cards columns="2" %}
{% card tone="sky" title="Left" %}
Feature one.
{% /card %}
{% card tone="rose" title="Right" %}
Feature two.
{% /card %}
{% /cards %}

{% details summary="The long table nobody reads twice" %}
…hidden until tapped, no JavaScript involved…
{% /details %}
```

The rules, all of them:

- The front page is full-width: prose keeps a readable measure, heroes and
  bands paint edge to edge, and everything folds down cleanly on a phone.
- Six tones — `sun, mint, sky, rose, grape, ink` — work in light and dark
  mode. Attributes are allow-listed: a document picks from the palette, it
  cannot invent styles, so expressiveness costs no security.
- A tag line starts at column zero. Indented or fenced tags are shown, not run —
  which is how this README quotes them.
- Unknown tags vanish and their content stays, so a document written for a
  richer engine degrades to its words, never to tag soup.
- An unclosed tag is closed for you at the end. A typo can't eat the page.
- Relative links work: `[guide](docs/guide.md)` opens that file rendered as a
  page, `![shot](docs/shot.png)` serves the image, `[src](src/)` opens the
  listing. A folder of markdown becomes a small site with zero configuration.
- Headings get anchor ids, so `[jump](#why)` works — this page uses them.
- Any `.md` file renders as a document; add `?plain=1` to see the raw text.
- Every repository answers `llms.txt` — yours verbatim if committed, a
  generated one if not — so agents get a front door as readable as the human
  one. The site root's `/llms.txt` belongs to the featured repository.

And because a README is written by anyone who can push — on a public
repository, a stranger — the whole surface stays paranoid: raw HTML is
dropped, link schemes pass an allow-list, every interpolated value is escaped,
and the page ships a CSP with **no script source at all**. The dialect adds
wrappers, not execution.

---

## For agents — MCP

```
POST /mcp     JSON-RPC 2.0: initialize · ping · tools/list · tools/call
```

The intended session: **`repo_create` → `key_add` → plain
`git clone`/`commit`/`push` → `run_list`**. There is no tool that writes to a
repository, and `initialize` says so, so a model doesn't go hunting for one.

A tool failure that's the caller's fault comes back as a tool result with
`isError: true`, not a JSON-RPC error — the model should see it and adapt, not
have the call look broken.

{% details summary="The 18 tools" %}

**Administration** — the part git can't do
| Tool | |
|---|---|
| `repo_create` | Create a repository. Returns both clone URLs, and `web_url` when public. |
| `repo_list` `repo_get` `repo_delete` | |
| `key_add` `key_list` `key_remove` | Register an SSH key so the agent can then use git. |

**Discovery** — read without cloning
| Tool | |
|---|---|
| `ref_list` | Branches, tags, notes with tip commits. |
| `tree_list` | List a directory. |
| `file_read` | Read a text file. |
| `search` | Fixed-string search across the tree. |
| `commit_log` `commit_get` | History; optionally path-filtered. |
| `compare` | Diff from the merge base. |
| `blame` | Who last changed each line. |

**CI**
| Tool | |
|---|---|
| `run_list` `run_get` `run_logs` | Did the build pass, and why not. |

{% /details %}

---

## Quickstart

Needs `git` ≥ 2.41 on the host.

```sh
cargo build --release
export ZUKA_DATA_DIR=/tmp/zuka && mkdir -p "$ZUKA_DATA_DIR"

TOKEN=$(./target/release/zuka token alice --scopes admin)   # shown once
./target/release/zuka
# [boot] listening on http://127.0.0.1:8790
# [ssh]  listening on ssh://127.0.0.1:2222
```

**Create and push**

```sh
curl -X POST localhost:8790/v1/repos \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"name":"site","visibility":"public"}'

git clone "http://x-access-token:$TOKEN@127.0.0.1:8790/alice/site.git"
cd site && echo hello > README.md
git add . && git commit -m "first" && git push origin HEAD:refs/heads/main
```

The git username is ignored; the password is your token. `visibility` defaults
to `private`; a public repository is clonable and browsable by anyone,
writable by no one anonymous. Open `http://127.0.0.1:8790/alice/site` and
there's your website.

**Use SSH instead**

```sh
./target/release/zuka key alice ~/.ssh/id_ed25519.pub --title laptop
git clone ssh://git@127.0.0.1:2222/alice/site.git
```

`--read-only` makes a key clone-only; `--repos a,b` confines it.

**Read without cloning**

```sh
A="Authorization: Bearer $TOKEN"; R=localhost:8790/v1/repos/alice/site
curl -H "$A" $R/refs
curl -H "$A" "$R/tree/src?ref=refs/heads/main"
curl -H "$A" "$R/raw/README.md"
curl -H "$A" "$R/search?q=TODO"
curl -H "$A" "$R/blame/src/main.rs"
curl -H "$A" "$R/compare?base=<sha>&head=<sha>"
```

On `/v1` the ref is always a **query parameter**, never a path segment —
`feature/login` and `src/main.rs` both contain slashes, so `/raw/{ref}/{path}`
has no unique parse. The browser URLs accept the ambiguity for the sake of
readable links; the API does not.

**Turn on CI**

```sh
ZUKA_CI_ENABLED=1 ./target/release/zuka
```

```toml
# .zuka.toml, in the repository
[run]
steps = ["cargo fmt --check", "cargo test"]
timeout_secs = 600
branches = ["refs/heads/main"]   # optional; omit to run everywhere
```

Push, then `curl -H "$A" $R/runs` and `$R/runs/{id}/logs`.

---

## Install onto a host

```sh
sudo zuka setup --standalone --dry-run    # prints every file it would write
sudo zuka setup --standalone
```

It writes `/etc/zuka.env` and two systemd units — the service and maintenance
separately, because a long repack must not stall requests — then prints the
remaining commands rather than running them. Creating users and enabling units
are steps someone may reasonably want to do differently.

**`--domain` is optional, and does one thing:** it adds a Caddyfile so Caddy
obtains and renews TLS for that name, and sets the public URLs advertised in
clone URLs. Without it you get a service on `127.0.0.1:8790` with no TLS, which
is right behind an existing proxy or for a local trial.

```sh
sudo zuka setup --standalone --domain git.example.com
```

**`--isolated`** installs the multi-tenant shape: a control plane that holds no
repositories and gives every account its own Incus container. Code, CI and
objects for one account never touch another's filesystem. `--domain` is
optional here too and means exactly the same thing — the control plane is the
only public listener, so it is the only thing a certificate is for.

```sh
sudo zuka setup --isolated
curl -X POST localhost:8790/v1/accounts/alice     # returns immediately
```

Set a public URL if you have one: a tenant is only reachable *through* the
control plane, so left to itself it advertises its own container address and
hands callers a clone URL they cannot use.

Provisioning never blocks a request. The account records intent and a
reconciler drives it to `ready`; a request arriving early gets `429` with a
`Retry-After`. Tenants are stopped when idle and started again on demand.

{% callout type="warn" title="Static linking is not optional in --isolated" %}
The tenant binary is pushed into a container whose C library is not the host's:

```sh
cargo build --release --target x86_64-unknown-linux-musl
```
{% /callout %}

---

## API

Full contract: [`openapi.json`](openapi.json), also served at `/openapi.json`.

| | |
|---|---|
| `GET /healthz` | Mode, version, disk headroom, CI state. `503` below the reserve. |
| `GET /openapi.json` | This service's OpenAPI 3.1 document. |
| `GET /v1/account` | Identity, scopes, storage usage and limits. |
| `GET POST /v1/repos` | List, create (optionally `import_url`, `visibility`). |
| `GET PATCH DELETE /v1/repos/{account}/{repo}` | Read, update settings, soft-delete. |
| `GET .../refs?type=` | Branches, tags, notes. |
| `GET .../tree/{path}?ref=` | Directory listing. |
| `GET .../raw/{path}?ref=` | File bytes. |
| `GET .../commits?ref=&path=&limit=` | Log. |
| `GET .../commits/{sha}` | One commit and its changed paths. |
| `GET .../compare?base=&head=` | Diff from the merge base. |
| `GET .../search?q=&path=&ref=` | Content search. |
| `GET .../blame/{path}?ref=` | Line attribution. |
| `GET POST .../runs` · `GET PATCH .../runs/{id}` · `GET .../runs/{id}/logs` | CI. |
| `GET POST /v1/keys` · `DELETE /v1/keys/{id}` | SSH keys. |
| `POST /mcp` | MCP JSON-RPC. |
| `/{account}/{repo}.git/*` | Git smart HTTP. Protocol v2. |
| `ssh://git@host/{account}/{repo}.git` | Git over SSH, plus `git archive --remote`. |

The browser lives on the same origin, read-only, no credential required for
public repositories: `/{account}/{repo}` (the clone URL minus `.git`), plus
`/tree/{ref}/{path}`, `/blob/{ref}/{path}` (`?plain=1` for raw text),
`/raw/{ref}/{path}` (download), `/commits/{ref}` (`?from={sha}` pages older),
`/commit/{sha}` and `/refs`. `/` redirects to the repository named by
`ZUKA_HOME_REPO`.

Errors on `/v1` and `/mcp` are RFC 9457 `application/problem+json` with a
stable `type` URI — discriminate on `type`, never on `detail`. The git paths
answer in `text/plain`, because git prints the body straight at the user. The
browser pages answer a missing page in HTML, because a person tapped that link.

Lists return `{items, truncated}`. There is no cursor, and none is claimed.

### CLI

```
zuka                                   serve
zuka setup --standalone|--isolated     install onto this host [--domain d] [--dry-run]
zuka jobs                              maintenance daemon (own service unit)
zuka gc | sweep | fsck [--repair]      one-shot maintenance
zuka token <account> [--scopes s] [--repos r] [--days n] [--no-expiry]
zuka key   <account> <path.pub> [--title t] [--read-only] [--repos r]
zuka version
```

Scopes: `repo:read`, `repo:write`, `admin`, `ci`. Tokens expire in 90 days
unless you pass `--days` or opt out with `--no-expiry`, which warns.
Credentials added while the server runs take effect without a restart.

`fsck` exits non-zero on an orphan so a timer notices. It reports a repository
with no metadata but never deletes one — repair only removes records that
describe nothing.

---

## Configuration

Everything has a working default; nothing is required.

{% details summary="All settings" %}

| Variable | Default | |
|---|---|---|
| `ZUKA_BIND` | `127.0.0.1:8790` | HTTP listener. |
| `ZUKA_SSH_BIND` | `127.0.0.1:2222` | SSH listener; `off` disables. |
| `ZUKA_DATA_DIR` | `/var/lib/zuka` | Everything on disk. |
| `ZUKA_PUBLIC_URL` / `_SSH` | derived | Advertised clone URLs. |
| `ZUKA_HOME_REPO` | — | `account/repo` featured at `/` in the browser. |
| `ZUKA_PROBLEM_BASE` | `https://zuka.dev/problems` | Point error `type` URIs at your own docs. |
| **Limits** | | |
| `ZUKA_MAX_REPOS_PER_ACCOUNT` | `100` | `0` disables. |
| `ZUKA_MAX_REPO_MB` / `_ACCOUNT_MB` | `2048` / `10240` | `0` disables. |
| `ZUKA_RATE_PER_MINUTE` | `600` | Per credential, on `/v1` and `/mcp`. `0` disables. |
| `ZUKA_MAX_PACK_MB` | `512` | Largest push. Also sets `receive.maxInputSize`. |
| `ZUKA_MAX_BLOB_MB` | `32` | Largest blob served inline. |
| `ZUKA_MAX_BODY_MB` | `10` | Largest JSON body. |
| `ZUKA_PAGE_LIMIT` / `_MAX_PAGE_LIMIT` | `50` / `200` | |
| `ZUKA_MAX_CONCURRENT_GIT` | `8` | Concurrent git subprocesses. |
| `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes return `507`. |
| **CI** | | |
| `ZUKA_CI_ENABLED` | `0` | Off by default; see below. |
| `ZUKA_CI_TIMEOUT_SECS` | `600` | Ceiling. A spec may lower it, never raise it. |
| `ZUKA_CI_MAX_CONCURRENT` | `2` | |
| `ZUKA_CI_LOG_MB` | `8` | Output cap per run. |
| `ZUKA_CI_MEMORY_MB` | `2048` | `RLIMIT_AS`. `0` disables. |
| `ZUKA_CI_FILE_MB` | `2048` | `RLIMIT_FSIZE`. Independent of the memory limit. |
| `ZUKA_CI_MAX_PROCESSES` | `0` | `RLIMIT_NPROC` — per-UID, so only meaningful with a runner uid. |
| `ZUKA_CI_KEEP_RUNS` | `50` | Runs retained per repository. |
| **Maintenance** | | |
| `ZUKA_GC_INTERVAL_SECS` | `21600` | |
| `ZUKA_GC_PRUNE_GRACE` | `2.weeks.ago` | Objects younger than this are never pruned. |
| `ZUKA_DELETED_RETENTION_DAYS` | `7` | |
| **Import** | | |
| `ZUKA_IMPORT_ENABLED` | `1` | |
| `ZUKA_IMPORT_TIMEOUT_SECS` | `300` | |
| `ZUKA_IMPORT_ALLOW_PRIVATE` | `0` | Permit private/loopback sources. Only on a trusted network. |
| **Multi-tenant** | | |
| `ZUKA_MODE` | `standalone` | `standalone` · `control` · `tenant` |
| `ZUKA_OPERATOR` | — | Account allowed to manage accounts. Unset ⇒ refused for all. |
| `ZUKA_INCUS_IMAGE` | `images:debian/12` | Image for a new tenant. |
| `ZUKA_INCUS_NETWORK` | `incusbr0` | Network a tenant joins. |
| `ZUKA_TENANT_BINARY` | this binary | Pushed into each container; must be static. |
| `ZUKA_TENANT_PORT` | `8790` | Port a tenant listens on inside its container. |
| `ZUKA_CONTROL_PUBLIC_KEYS` | — | Tenant only. Comma-separated; more than one allows key rotation. |
| `ZUKA_PROXY_TIMEOUT_SECS` | `300` | Ceiling on one proxied request. |

{% /details %}

`ZUKA_ME_URL` is parsed and then **refuses to boot** — hosted auth isn't
implemented, and accepting a security-relevant setting while ignoring it is
worse than not accepting it.

### Maintenance

Run `zuka jobs` as a second service unit — a long repack must not stall request
handling.

```ini
[Unit]
Description=zuka maintenance
After=zuka.service
[Service]
ExecStart=/usr/local/bin/zuka jobs
EnvironmentFile=/etc/zuka.env
User=zuka
Restart=always
[Install]
WantedBy=multi-user.target
```

It garbage-collects (git's own auto-gc is disabled on every repo, so a repack
never fires *inside* a push), sweeps deleted repositories and old runs, and
fscks.

What makes GC safe against a live clone is the **grace window**, not a lock — a
lock couldn't span the two processes. `upload-pack` can reference an object
between resolving and streaming it, so nothing younger than `GC_PRUNE_GRACE` is
pruned. A test clones a 12 MB repo while GC runs and checks the result byte for
byte.

{% callout type="danger" title="Force-pushing does not delete" %}
A committed secret stays fetchable by sha until GC prunes it. Force-pushing
past it moves the ref, not the object.
{% /callout %}

---

## Security

Each of these has a test that fails if it regresses. Several are here because
an earlier version got them wrong.

**Names can't escape the data directory.** Ref names go through `gix-validate`,
never a hand-rolled check — `../../config` is a command-execution primitive via
`core.pager` and `core.sshCommand`. Tree paths reject any `.git` component
after Unicode normalisation, case folding, HFS-ignorable-codepoint stripping
and trailing dot/space removal.

**Names are case-folded.** `Alice/Site` and `alice/site` are one resource.
Comparing the raw path segment once let a case-variant account reach another
account's repository on a case-insensitive filesystem while passing the
ownership check.

**An unreadable repository is `404`, not `403`.** `403` enumerates private
repos. Confinement applies to destruction too: a token scoped to one repo
can't delete another.

**Anonymous means read, and only through one door.** Exactly one function
grants anonymous access, it can only read, and a presented-but-rejected
credential stays rejected — an expired token never quietly degrades into an
anonymous visitor. The browser answers a private repository with the same page
as an absent one; the git wire answers both with a `401` challenge, so neither
surface can be used to enumerate names.

**The viewer renders hostile input inert.** Every value is escaped, markdown
drops raw HTML and rewrites link schemes against an allow-list, and pages carry
a CSP with no script source — so anything that survived the escaping would
still have nowhere to execute. Raw file bytes are served as an attachment in a
CSP sandbox, never rendered on the origin.

**Rewriting is allowed; protection is opt-in.** `rebase` and `--amend` are
ordinary git and blocking them protects nobody when there's one agent and no
reviewer — git's reflog is the recovery path. Set `protected_refs` on the
branch something deploys from; it's then enforced server-side over HTTP *and*
SSH, which matters because `--force` bypasses the client check. One Rust
function; the hook is a one-line `exec` into the binary.

**SSH runs git and nothing else.** No shell, no pty, no subsystem. Three
services: `upload-pack`, `receive-pack`, `upload-archive`. The exec string is
parsed, never shelled, so `git-upload-pack '/a/b.git'; id` is a parse error.
The account comes from *which registered key matched*, never the SSH username.
Only `GIT_PROTOCOL` crosses into the child.

**Only three endpoints route under `.git`.** The dumb protocol isn't served. A
duplicated `service` parameter is refused — this server would take the first
and the CGI the last, so a duplicate would let the authorization decision and
the running service disagree.

**Import can't reach the local network.** The one place a caller-supplied URL
is fetched. Checked in two stages: shape (scheme, credentials) then *every
address the host resolves to* — checking the hostname alone is defeated by a
name that resolves to `169.254.169.254`.

**Credentials are hashed, scoped, expiring and `0600`.** Token comparison has
no early return. Public keys match on raw bytes; `authorized_keys` option
prefixes like `command=` are refused.

**Deletes are recoverable.** Soft-deleted to `tmp/deleted/`, swept on
retention.

**Nothing buffers a pack.**

```
                          before      after
200 MB push, server RSS   ~360 MB      5 MB
4 concurrent clones       1508 MB      5 MB
```

The child is killed by **process group** on disconnect — `kill_on_drop` reaches
only the direct child, and `pack-objects` is a grandchild that otherwise kept
running for seconds after the client left.

### Multi-tenant isolation

In `--isolated`, each account gets its own Incus container, unprivileged and
with nesting off. The control plane holds no repositories: a push through it
lands in the account's container and nowhere on the host.

A tenant is told who is calling by an **Ed25519 assertion the control plane
signs**, not a shared secret. That matters because a tenant runs the account's
own CI, which is arbitrary code — anything symmetric in there would forge every
other tenant. The private key never leaves the control plane; tenants hold only
the public half, which forges nothing.

The assertion authenticates one request, not an identity: single-use nonce,
30-second window, bound to the method and path. It is deliberately *not* bound
to the body, because a push is a multi-gigabyte pack the proxy streams — the
nonce is what stops replay.

Verified on a real host rather than asserted, in
[`infra/README.md`](../../infra/README.md): CI inside a tenant reports
`uname -n` = `zuka-alice`, and a second account gets `404` on the first's
repository and an empty repo list.

### CI is not a security boundary in standalone

Steps run as the service user with rlimits, a scrubbed environment, a
process-group kill on timeout and a bounded log — but **no container and no
network namespace**. Anyone who can push can run code as that user. CI is off
by default and the boot log says so.

A step allowlist was considered and rejected: `sh -c` defeats it in one
character. Enforcement is at the OS or it isn't enforcement. Multi-tenant
isolation is what `--isolated` is for.

---

## Renaming it

The name lives in exactly one file. `src/brand.rs` derives the env prefix, data
directory, problem-type URIs, auth realm, git config namespace, CI filename and
hook shim from `CARGO_PKG_NAME`.

Renaming is one line in `Cargo.toml` plus the `openapi.json` title — verified
by actually doing it. A unit test walks `src/` and fails if the name appears as
a literal anywhere outside `brand.rs`; it has already caught a hardcoded auth
realm, and it is the reason even the browser viewer's stylesheet URL is derived.

---

## Development

```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test                 # 301 unit tests
deno task test:live        # 69 live tests: spawns the binary, drives real git and ssh
```

The live suite is the real proof — unit tests cover units, but only a real
clone and push exercise the wire protocol. It runs in CI rather than
self-skipping there. The same four commands are the CI gate zuka runs on
itself before deploying itself.

```
src/
├── brand.rs       the one place the product name appears
├── config.rs      Config::from_env(); fails boot on a bad value
├── error.rs       Error -> RFC 9457 problem+json
├── core/          domain operations shared by every facade
├── git/
│   ├── exec.rs      the only place this service invokes git
│   ├── validate.rs  security controls: names, refs, paths
│   ├── store.rs     repositories, hooks, ref rules
│   ├── discover.rs  the read core, shared by REST, MCP and the browser
│   ├── transport.rs streaming CGI bridge
│   └── import.rs    SSRF-guarded cloning
├── http/          routing, limits, responses, auth, rate limiting
├── api/           REST facade
├── mcp/           MCP facade
├── web/           the browser viewer: escaping, the tag dialect, the pages
├── ssh/           SSH server and exec-command parser
├── ci/            spec, run store, runner, executor
├── control/       accounts, provisioning, reconciler, streaming proxy
├── setup.rs       systemd units, Caddyfile, environment file
├── jobs/          gc, sweep, fsck
├── account/       identity, tokens, SSH keys
└── store/         metadata, quotas, change-aware file cache
```

Two rules worth knowing before changing anything:

- **git is the only writer.** No endpoint or tool modifies repository contents.
- **Facades translate; they don't decide.** Authorization lives on `Identity`,
  git rules in `git/`, shared operations in `core/`. `api/` and `mcp/` reshape
  payloads and nothing else — they had drifted copies of repo create and delete
  once, and those copies had already diverged in four ways.

---

## The name

Chosen for the sound of it rather than the meaning: two syllables, a rare
opening consonant, one plausible spelling after hearing it once. It sits near
Swahili *kuzuka* — to surface, to emerge — without claiming to be it. Which
is, in the end, the why in one word: things people make should surface.

More: [`SPEC.md`](SPEC.md) · [`ROADMAP.md`](ROADMAP.md) · [`AGENTS.md`](AGENTS.md)
