zuka
Git hosting for agents. One Rust binary — bare repositories, SSH and HTTP git, a REST API, MCP, and CI. No database, no Docker, no external services.
Status Complete. M0–M7 shipped: standalone and multi-tenant both work.
Tests 253 unit · 59 live (real git, ssh and MCP against a real server)
Gate cargo fmt --check · clippy -D warnings · cargo test · deno task test:live
Named 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.
The idea
Agents already know git. What they can't do is the GitHub part.
Claude, Cursor and the rest run git clone, commit and push perfectly well. What
stops a non-developer shipping with them is everything bolted on top: creating the
repository, orgs and teams, deploy keys, permission matrices, pull requests, review,
Actions YAML.
So the split is strict:
| git does the git | Code moves over the wire protocol, HTTP or SSH. Nothing proprietary. |
| the API does the rest | Create a repo, register a key, read a file, check CI — over REST and MCP. |
Nothing in the API writes to a repository. No commit endpoint, no file endpoint,
no ref-moving endpoint. A second write path would mean two homes for the
fast-forward rule, two concurrency models and a merge story — git already has all
three. The write path is git push.
What's deliberately absent is the point: no organisations, no teams, no pull requests, no review, no issues. The whole model is accounts, repositories, refs, keys and runs.
Install onto a host
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.
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.
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.
The tenant binary must be statically linked — the control plane pushes it into a container whose C library is not the host's:
cargo build --release --target x86_64-unknown-linux-musl
Quickstart
Needs git ≥ 2.41 on the host.
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
curl -X POST localhost:8790/v1/repos \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"site"}'
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.
Use SSH instead
./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
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>"
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.
Turn on CI
ZUKA_CI_ENABLED=1 ./target/release/zuka
# .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.
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.
Administration — the part git can't do
| Tool | |
|---|---|
repo_create | Create a repository. Returns both clone URLs. |
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. |
API
Full contract: 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). |
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. |
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 instead, because git prints the body straight at the user.
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.
| 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_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_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_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. |
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.
[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.
Worth stating plainly: a committed secret stays fetchable by sha until GC prunes it. Force-pushing past it moves the ref, not the object.
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.
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: 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 M6 and
isn't built.
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.
Development
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test # 204 unit tests
deno task test:live # 59 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.
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 and MCP
│ ├── transport.rs streaming CGI bridge
│ └── import.rs SSRF-guarded cloning
├── http/ routing, limits, responses, auth, rate limiting
├── api/ REST facade
├── mcp/ MCP facade
├── 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 ingit/, shared operations incore/.api/andmcp/reshape payloads and nothing else — they had drifted copies of repo create and delete once, and those copies had already diverged in four ways.
More: SPEC.md · ROADMAP.md · AGENTS.md