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