Почему именно такая архитектура
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: |
bundle-uri |
Бандлы нарезаются по календарным слотам (еженедельный полный, связанные ежедневные, почасовые) как чистая функция от WAL: свежий клон скачивает последний полный бандл плюс цепочку над ним прямо из бакета и обращается к серверу только за остатком; обновление скачивает ровно пропущенные слоты. Два списка на репозиторий: |
LFS |
Batch API + базовый перенос, объекты в бакете, опциональное проксирование (read-through) с внешнего LFS-сервера для импортированных репозиториев. |
Веб-интерфейс и API |
React-интерфейс (дерево, блоб, коммиты, диффы, страница здоровья WAL) на основе API только для чтения под |
Политики |
Правила push на уровне репозитория ( |
Настройки |
Конфигурация репозитория (расписания бандлов, уплотнение, следование upstream) публикуется в WAL с историей. |
События |
Небольшой мост отслеживает WAL и отправляет события refs через вебхук, ровно по одному разу на (repo, seq, ref), с дurable-курсором. Документация: |
Обслуживание |
Чекпоинты, сборка бандлов, геометрическое уплотнение, перестройка базы, аудит целостности и восстановление — один цикл, который каждый проход вычисляет желаемое состояние из (config, WAL) и выполняет одну ограниченную единицу самой важной недостающей работы. Самовосстановление по конструкции: сбой не оставляет дыр; удалённый артефакт помечается как «отсутствующий» и идентично пересобирается. |
Аутентификация |
|
Хранилища |
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 |
|---|---|---|
|
Все — как |
Ничего |
|
Статические |
|
|
Любой OpenID Connect-провайдер ( |
Токен доступа walgit: однократный вход в браузере, создание токена на |
Настройка машины разработчика — одна идемпотентная команда: 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.