yumak (*ball of yarn* in turkish) is a [tangled](https://tangled.org) knot with
no daemon on the read path. yumak only runs when something changes, compiling
your git repos and atproto records into immutable files that nginx serves
straight from disk.

- clones and fetches work over smart http and ssh. nginx matches the request
  body against prebuilt replies, including `depth-1` shallow clones (deeper
  `--depth` requests get the full history).
- pushes go over ssh as normal, or over http when `[writes]` is enabled (`git
  push -o` and git lfs are not supported).
- issues, pull requests, comments, and attachments work like on a normal knot
  when `[writes]` is on, each write updates the repo and activates a new
  generation.

[`git.klbr.net`](https://git.klbr.net) runs yumak and hosts this repo
(`did:plc:amxsvho4opuwe53msnkbwa5j`, on tangled at
[tangled.org/ptr.pet/yumak](https://tangled.org/ptr.pet/yumak)).

## performance

hosting the linux kernel (torvalds/linux: 12.0M objects in one 6.5 GB pack,
953 refs) on `git.klbr.net` itself: 4 vcpus of amd epyc genoa, 7.9 GB of
memory, btrfs. measured on 2026-11-06 with yumak limited to 4 GB without
swap, at nice 19 and a low cpu and io weight, next to the box's other services
(a pds, knot2, navidrome, metrics, logs, bun server, etc.).

| what | time | memory |
|---|---|---|
| push of one commit | 1.9 s | 0.03 GB |
| push of 6 commits | 1.9-2.0 s | 0.03 GB |
| cold generate | 31-32 s | 0.7 GB |
| nightly repack and bitmap, built on the last one | 1.4-1.6 min | 0.8 GB |
| first night, the bitmap built from nothing | 3.9-4.1 min | 1.3 GB |

note the memory here is peak anon memory, rest of the memory usage depends on
how much goes through the page cache, for a big repo like the kernel it will
matter more.

## installation

there is a nix flake:

```
nix profile add git+https://git.klbr.net/did:plc:amxsvho4opuwe53msnkbwa5j
```

or build it with cargo (`cargo build --release`). the host needs `git`, an
`nginx` built with pcre jit, and `fcgiwrap`. for best performance (and disk
usage on big repos), put `<output.root>` on a filesystem that supports reflinks
(`btrfs`, `xfs`, or `apfs`).

## configuration

copy [`knot-static.toml.example`](knot-static.toml.example) to
`knot-static.toml` and fill it in. `--config` or `KNOT_STATIC_CONFIG` points
at another path. the master key that seals `sealed_key_file` comes from
`KNOT_MASTER_KEY` (base64 of 32 bytes), or from the variable
`secrets.master_key_env` names.

then:

1. run `knot-static init` once. it creates the meta repo and seals the knot's
   signing key.
2. run `knot-static generate`, then `knot-static activate <generation>`, which
   writes `<output.root>/active.conf` and reloads nginx.
3. run nginx against `<output.root>/active.conf`, behind a tls reverse proxy
   with `client_max_body_size 200m`.
4. run `fcgiwrap -f -c 4 -s unix:/run/knot-static/fcgiwrap.sock` with
   `KNOT_STATIC_CONFIG` and `KNOT_MASTER_KEY` in its environment. one worker
   would let a slow push hold up every write.

note: fcgiwrap is needed even with `[writes]` off.

### ssh

point your ssh user's `authorized_keys` at a wrapper that exports
`KNOT_MASTER_KEY` and runs
`knot-static --config /etc/knot-static/config.toml git-ssh "$@"`. a key you add
there yourself is the operator's and can push anywhere. `git-ssh` refuses to
run without `--operator` or `--actor`, so a key like that says it:

```
command="/etc/knot-static/git-ssh --operator",restrict ssh-ed25519 AAAA...
```

fetches over ssh get the same canned replies nginx serves, and those need git
protocol v2. git 2.26+ and gix ask for it in `GIT_PROTOCOL`, which sshd only
passes on with `AcceptEnv GIT_PROTOCOL`.

for collaborators, sshd asks `knot-static keys list` for their keys. `keys
sync` reads them into `<output.root>/ssh-keys.json`, and a push or a grant
reads accounts it hasn't seen yet. every key is forced to run `--command`,
with `--actor DID` added:

```
Match User op
    AuthorizedKeysCommand /etc/knot-static/keys-list
    AuthorizedKeysCommandUser op
```

`/etc/knot-static/keys-list` is a script that runs
`knot-static --config /etc/knot-static/config.toml keys list --command /etc/knot-static/git-ssh`.
sshd refuses the command when anyone but root can write to it or a directory
above it, which rules out `/nix/store`. on nixos, copy it out with
`environment.etc.<name>.mode`.

## usage

```
knot-static init                  create the meta repo and knot signing key
knot-static generate              build a new generation (--repo to rebuild one)
knot-static activate              switch active.conf to a generation and reload
knot-static gc                    remove old generations and unreferenced data
knot-static maintenance           repack, bitmap, and prune hosted repos
knot-static repo                  create, import, rename, and configure repos
knot-static issue / pull / label  manage issues, pull requests, and labels
knot-static oauth                 log in to a pds to publish repo declarations
knot-static keys                  sync collaborator ssh keys for sshd
knot-static plc                   retry pending plc genesis operations
knot-static git-ssh / cgi         sshd ForceCommand and fcgiwrap entry points
```

run `knot-static <command> --help` for flags on any subcommand.

## tests

the dev shell has `cargo-nextest`:

```
cargo nextest run                                       # the default set
NGINX_BINARY=$(command -v nginx) cargo nextest run --run-ignored all
```

the second adds the tests that need a real nginx (`cargo test -- --include-ignored`
without nextest). `.config/nextest.toml` turns retries off, and a flaky test
fails the run.

## how it works

- **smart http git in nginx** ([`src/nginx/`](src/nginx/)): `git-upload-pack` is
  a `POST`, which nginx refuses to serve static files for, and nginx only reads
  a request body when proxying. yumak makes nginx proxy the `POST` back to
  itself as a `GET` and uses regex `map` tables on the request body to pick the
  right prebuilt pack (`ls-refs`, full clone, incremental fetch, or `deepen 1`).
  if the client gzips its request body, `fcgiwrap` decompresses it and hands the
  chosen file back to nginx to stream.
- **spare filler slots in big packs**
  ([`src/git_export/spare.rs`](src/git_export/spare.rs),
  [`src/git_export/hasher.rs`](src/git_export/hasher.rs)): a git packfile has an
  object count in its header and a sha1/sha256 checksum at the end, so appending
  a push would normally mean rehashing a multi-gigabyte file. instead, big
  replies reserve extra object slots at the tail filled with tiny dummy blobs
  that no branch points to, and save the hash state right before them. a push
  clones the previous reply file with a reflink
  ([`src/git_export/reply_pack.rs`](src/git_export/reply_pack.rs); falls back to
  a copy on filesystems like `ext4`), overwrites the next free slots in place,
  and finishes the hash over the last few kilobytes.
- **depth-1 clones by slicing the full pack**
  ([`src/git_export/depth_one.rs`](src/git_export/depth_one.rs)): instead of
  running `git pack-objects` for shallow clones, yumak indexes where each
  entry lives in the full pack and slices out just the tip's objects, patching
  up any delta headers whose base wasn't in the slice.
- **custom bitmap engine** ([`src/bitmap/`](src/bitmap/)): issues and pull
  requests live in the same git repo as the code (`refs/cobs/*`), so yumak has
  to keep them out of public clones. it has its own multi-pack-index bitmap
  reader and parallel writer that streams object types from pack headers in
  disk order and diffs root trees as raw bytes before parsing them.
- **static xrpc tables** ([`src/xrpc/`](src/xrpc/),
  [`src/atproto/`](src/atproto/)): every read xrpc response and pagination page
  is prebuilt into a content-addressed pool, and nginx `map` tables route query
  params straight to the file on disk.
- **`no_std` firehose daemon** ([`firehose/`](firehose/),
  [`src/firehose.rs`](src/firehose.rs)): activating a generation appends
  pre-framed websocket messages to a flat file, and a tiny zero-allocation
  `no_std` daemon streams it to subscribers with `sendfile`.

## credits

this was made possible thanks to LLMs! thanks to opus / sonnet 5.5, gemini 4 argon, gemini 3.8 flash, gpt 6.1 sol!

thanks to tangled and specifically @oyster.cafe for awesome knot2 :3

the yarn-ball favicon is drawn fresh after twemoji's 🧶 (original by twitter, inc. and contributors under [cc-by 4.0](https://creativecommons.org/licenses/by/4.0/)).
