walgit: Git-хостинг на WAL и объектном хранилище

Почему именно такая архитектура

Git — распределённая система, и именно это делает его хостинг неудобным по одной причине: packfiles (упакованные файлы). Всё содержимое репозитория сжимается в крупные бинарные паки, оптимизированные под компактность, а не под последовательное чтение; любая операция git — это случайный обход гигабайт данных. На ноутбуке, где файл находится в кэше страниц, это терпимо, а по сетевой файловой системе — катастрофа: именно поэтому подход «просто положить репозитории на NFS» провалился у всех крупных хостингов, кто его пробовал. Выжившая архитектура (GitHub Spokes) хранит настоящие репозитории на локальных NVMe-дисках, чтобы сам git делал всю работу, и реплицирует на уровне packfile с жёсткими гарантиями согласованности — ценой трёхфазного коммита через фиксированный набор реплик, базы данных с маппингом каждого репозитория на его машины и парка «питомцев» (pets).

Ключевая идея Continuity меняет экономику: сделать журнал с упреждающей записью (write-ahead log, WAL) в объектном хранилище единственным источником истины, а любой репозиторий на диске — лишь кэшем. Push сохраняется как неизменяемый объект в бакете и становится виден только после атомарной перезаписи небольшого манифеста через compare-and-swap (CAS). Этот CAS и есть консенсус — без выборов лидера, без кворума, без первичного узла. Принять push может любой экземпляр; два конкурирующих экземпляра не могут победить оба. Реплика, которая никогда не видела репозитория, читает лог и получает его целиком. Чтение согласованно без координации, потому что каждый запрос сначала проверяет у хранилища, изменилось ли что-либо (условный GET, обычно возвращающий 304). Уплотнение (compaction) выполняет ровно один экземпляр, удерживающий аренду, и публикует результат в лог — реплики скачивают уже уплотнённые паки вместо того, чтобы переупаковывать самостоятельно. А поскольку WAL является истиной, каждое действие задокументировано: каждый push и каждый repack воспроизводимы до любой точки.

walgit берёт эту архитектуру как есть и добавляет то, что нужно монорепозиторию на небольших машинах: отдача refs и веб-страниц для репозитория, чьи паки никогда не поместятся на экземпляре (режим remote reader через HTTP range requests), хранение коммитов и деревьев локально при том, что блобы остаются в бакете (режим history pack), и перенос байт клонирования полностью за пределы сервера (bundle-uri: свежие клоны и обновления раздаются как статические файлы из бакета или CDN).

Что умеет walgit

git

Умный HTTP v0/v2: ls-refs с префиксами, fetch с filter/shallow/deepen/sideband-all, receive-pack (атомарный, удаление веток, теги, push options, report-status-v2), пространства имён <owner>/<repo>, репозитории sha1 и sha256. Upstream git выполняет upload-pack/repack/bundle; walgit берёт на себя receive-pack, WAL и всю сантехнику.

bundle-uri

Бандлы нарезаются по календарным слотам (еженедельный полный, связанные ежедневные, почасовые) как чистая функция от WAL: свежий клон скачивает последний полный бандл плюс цепочку над ним прямо из бакета и обращается к серверу только за остатком; обновление скачивает ровно пропущенные слоты. Два списка на репозиторий: bundles/list для клонов, bundles/catchup для fetch. Семейства без блобов для --filter=blob:none.

LFS

Batch API + базовый перенос, объекты в бакете, опциональное проксирование (read-through) с внешнего LFS-сервера для импортированных репозиториев.

Веб-интерфейс и API

React-интерфейс (дерево, блоб, коммиты, диффы, страница здоровья WAL) на основе API только для чтения под /{owner}/{repo}/api/*; ответы с sha-адресами неизменяемы и кешируются везде; длинные ответы транслируются через SSE. repos.js — SDK без зависимостей для страниц, агентов и скриптов.

Политики

Правила push на уровне репозитория (policy.json): защищённые refs, группы, только fast-forward, списки обхода. Документация: docs/POLICY.md.

Настройки

Конфигурация репозитория (расписания бандлов, уплотнение, следование upstream) публикуется в WAL с историей.

События

Небольшой мост отслеживает WAL и отправляет события refs через вебхук, ровно по одному разу на (repo, seq, ref), с дurable-курсором. Документация: docs/EVENTS.md.

Обслуживание

Чекпоинты, сборка бандлов, геометрическое уплотнение, перестройка базы, аудит целостности и восстановление — один цикл, который каждый проход вычисляет желаемое состояние из (config, WAL) и выполняет одну ограниченную единицу самой важной недостающей работы. Самовосстановление по конструкции: сбой не оставляет дыр; удалённый артефакт помечается как «отсутствующий» и идентично пересобирается.

Аутентификация

none (loopback), token (статические токены), oidc (любой OpenID Connect-провайдер: вход через браузер, ID-токены и токены доступа, выпускаемые walgit, для git). /services/public/install.sh настраивает машину разработчика одной идемпотентной командой.

Хранилища

S3 и S3-совместимые (AWS, MinIO, rustfs, R2, Ceph, …​) и GCS — в полноценной поддержке; хранилище в памяти для тестов.

Как это работает — кратко

Репозиторий — это WAL в бакете. Под repos/<owner>/<repo>/: manifest.pb (маленький, перезаписывается через CAS: порядковый номер головы, набор живых паков, указатель на чекпоинт, настройки — точка линеаризации), log/<seq>.pb (неизменяемые записи: PUSH, COMPACT, CHECKPOINT, SETTINGS), wal/<checksum>.pack|.idx|.rev|.bitmap|.commit-graph (неизменяемые, content-addressed паки с сопутствующими файлами), checkpoints/<seq>/ (свёрнутый снимок refs + опись паков для холодного старта — снимок плюс хвост лога), bundles/, leases/ (CAS с TTL — единственный кросс-экземплярный мьютекс), policy.json, lfs/objects/, events/cursor.json.

Push: наш receive-pack индексирует пак (git index-pack --fix-thin --rev-index во временной директории), проверяет связность и политики, загружает pack ∥ idx ∥ log entry, а затем выполняет CAS манифеста. При ответе 412 перечитывает манифест, повторно проверяет старые значения каждого ref и повторяет попытку. Конкурирующие push в один репозиторий на одном экземпляре группируются в один CAS. Клиент видит ok только после того, как бакет подтвердил запись.

Чтение: один условный GET манифеста; 304 → отдаём из локальной копии, 200 → применяем новые записи. Что именно означает «применить» — зависит от того, что нужно запросу: refs (снимок + лог → packed-refs, без паков: объявления, API, списки бандлов), serve (набор паков в объёме, который данная машина может хранить: небольшие паки и history pack локально, слишком большая база читается range-запросом), full (всё локально, для repack), objects (remote reader, для UI на репозитории, который не помещается целиком). Загрузки паков идут в собственном рантайме и никогда не блокируют запрос refs.

Размещение — это конфигурация. Glob-паттерны [placement] serve / maintain указывают, для каких репозиториев хост выполняет объектную работу; чтение на уровне refs работает везде. Один сервер: оставьте значения по умолчанию. Несколько: поместите монорепозиторий на хост с SSD (cache.mode = "disk"), остальное — на маленькие машины, и маршрутизируйте по /<owner>/<repo> перед ними.

Ничто не ждёт молча. Всё медленное — это задача (task) с идентификатором, логом и потоком прогресса — нарратив отправляется в git по sideband 2 (remote: * …) и в браузер через SSE.

AGENTS.md — это полная архитектурная документация и руководство оператора: ограничения, стратегии WAL, каждое проектное решение с обоснованием, инварианты и модель стоимости (бюджет — количество round-trip’ов к бакету).

Запуск

# сборка (нужны: rust по rust-toolchain.toml, protoc, node 24 + pnpm для веб-интерфейса)
just web-build && cargo build --release -p walgit-cli
# или: nix build .#walgit        или: podman build -t walgit -f Containerfile .

# один сервер, TLS силами walgit, локальное S3-хранилище (rustfs в контейнере)
just dev-store
./target/release/walgit-server --config walgit.standalone.toml
open https://walgit.localhost:8080/
  • walgit.standalone.toml — конфигурация для одного сервера (самоподписанный TLS, rustfs, все роли). С этого стоит начать.

  • walgit.example.toml — все ключи с их значениями по умолчанию и комментариями.

  • Containerfile, flake.nix — OCI-образ и Nix-пакет/devshell.

  • deploy/nginx.conf.example — опциональный nginx в качестве фронтенда: публичный TLS, один auth_request на учётные данные, и разгрузка байт: walgit отвечает на скачивание бандлов/LFS через X-Accel-Redirect, а nginx сам проксирует и кеширует объект из бакета (S3 presigned или GCS с bearer-токеном walgit). Файл описывает контракт взаимодействия.

Роли (server.roles): serve (git, API, UI, бандлы, LFS), maintain (чекпоинты, бандлы, уплотнение, fsck/восстановление), events (мост вебхуков). Пусто = все. Любое количество хостов с ролью serve может смотреть на один бакет; назначьте каждому репозиторию одного мейнтейнера (glob-паттерны размещения) — и готово.

Аутентификация

Режим Кто получает доступ Как аутентифицируется git

none

Все — как anon с правом записи — для экспериментов в loopback

Ничего

token

Статические tokens в конфиге (token_env читает секрет из переменной окружения)

Authorization: Bearer <token> или токен как пароль HTTP Basic

oidc

Любой OpenID Connect-провайдер (issuer, oauth_client_id/secret, allowed_domains/allowed_emails): Google, Entra, Okta, Auth0, Keycloak, Dex, GitLab…

Токен доступа walgit: однократный вход в браузере, создание токена на /_auth/tokens, вставка в установщик. Без состояния (HMAC с session_secret, access_token_ttl); ротация секрета отзывает все токены. ID-токены от провайдера (audiences) и статические tokens тоже работают.

Настройка машины разработчика — одна идемпотентная команда: sh -c "$(curl -fsSL 'https://git.example.com/services/public/install.sh')" — которая сохраняет токен в файл, доступный только этому пользователю, устанавливает небольшой git credential helper (git ≥ 2.46: отвечает на get значением authtype=Bearer, а при настоящем 401 выполняет erase токена и сообщает, где получить новый) и включает transfer.bundleURI. Параметр ?repo=owner/name сразу клонирует репозиторий.

Разработка

just test          # быстрый герметичный уровень (< 1 мин): unit + быстрая интеграция, хранилище в памяти, настоящий git
just e2e           # настоящий git против сервера (~20 с)
just warnings      # ноль предупреждений rustc по всем целям
just ci            # всё вышеперечисленное
cargo test -p walgit-server --test sim     # симуляция с инъекцией сбоев (крэши, разделение сети, устаревшие чтения)
just test-s3       # контрактные тесты хранилища против локального rustfs

Карта кода:

crates/
  walgit-proto    protobuf-схема (wal.proto), фреймирование лога, ключи хранилища
  walgit-store    трейт ObjectStore (CAS-версии, условный GET, range, compose); бэкенды s3, gcs, memory; аренды
  walgit-git      bare-репозитории на диске, receive-pack, приём паков, refs ↔ packed-refs, объявления, драйверы upload-pack
  walgit-wal      RepoHandle: уровни синхронизации, publish (групповой коммит + CAS), чекпоинты, чтение лога, remote reader, задачи
  walgit-bundle   bundle-uri: слоты и цепочки, сборка, композиция заголовок ∘ пак, списки, политика хранения
  walgit-server   axum: умный HTTP, LFS, бандлы, аутентификация (none/token/oidc), цикл мейнтейнера, следование upstream,
                  web/ (API, UI, SDK-маршруты, SSE), setup.rs (установщик + рецепты), мост событий
  walgit-config   walgit.toml (+ переопределения через env WALGIT__), слияние настроек репозитория, fail-closed-валидация
  walgit-cli      `walgit serve|import|compact|bundle|wal|mirror|synth|config|repo`; `walgit-server` = `walgit serve`
web/              React SPA (Vite) + sdk/repos.ts, встроены в бинарник; wire-контракт описан в web/API.md
docs/             BUNDLE_URI_DESIGN, ROUNDTRIPS (модель стоимости), POLICY, LFS, INTEGRITY, EVENTS, CONTRACT, patches/

Инварианты, которые стоит запомнить

  • CAS манифеста — единственная точка фиксации; всё до неё невидимо, всё после — идемпотентно и воспроизводимо.

  • Неизменяемые объекты адресуются по содержимому; ничто не перезаписывается, кроме манифеста, списка бандлов и аренд.

  • Каждое чтение сначала перепроверяет бакет; понятия «в конечном счёте» не существует.

  • Локальный диск — это кэш. Память — кэш. Бакет — репозиторий.

  • Размещение конфигурируется, а не выводится автоматически; чтение на уровне refs работает везде, объектная работа — только там, где размещено.

  • Результат мейнтейнера — чистая функция от (config, WAL); «отсутствует» означает лишь «ещё не собрано».

  • Стоимость не должна расти с количеством refs на горячем пути и с размером пака на машине, слишком маленькой для него.

  • Долгая работа — это задача: обнаруживаемая, присоединяемая, нарратируемая.

  • Корректности недостаточно: каждое изменение протокола оценивается по числу round-trip’ов к бакету (docs/ROUNDTRIPS.md).

Лицензия

MIT — см. LICENSE.

© 2026 meganuke