Переосмысление монорепозитория Git: архитектура на основе CAS для людей и программных агентов для кодирования

Каждая растущая инженерная организация рано или поздно сталкивается с одной и той же развилкой. Вначале, когда есть десятки небольших репозиториев, всё кажется простым. У каждой библиотеки есть собственный трекер задач, собственный CI-конвейер и git clone, который завершается раньше, чем вы успеваете сделать глоток кофе. Затем приходит «налог». Вы меняете структуру в базовом сетевом пакете и остаток дня открываете pull request-ы с обновлением версии в шести зависимых репозиториях, дожидаясь, пока очереди CI освободятся в топологическом порядке.
Если перенести всё в единый традиционный монорепозиторий Git, вы обменяете сложности координации на сложности физических ограничений. По мере того как история коммитов углубляется и бинарные активы накапливаются в предыдущих ревизиях, git fetch замедляется. Рабочие деревья разрастаются до гигабайтов. Вскоре кто-нибудь предлагает развернуть собственный демон виртуальной файловой системы, выделенный сервер код-ревью или парк удалённых сборочных рабочих узлов, лишь бы повседневная разработка оставалась приемлемой.
Когда в команду приходят автономные агенты для написания кода, оба типа проблем усугубляются. Полирепозитории лишают агента видимости вызывающих кодов в других проектах и вынуждают его жонглировать учётными данными между репозиториями. Традиционные монорепозитории исчерпывают дисковое пространство контейнера агента и сетевую пропускную способность при инициализации каждой задачи, а параллельные агенты, работающие с одной и той же веткой, порождают бесконечные гонки при rebase.
Нам был нужен третий вариант. Вдохновившись архитектурой google3, мы задали простой вопрос: можно ли получить атомарные изменения в едином trunk, разреженное подключение менее чем за секунду, иерархическое управление через OWNERS и автономные зеркала с открытым исходным кодом, используя исключительно стандартные средства Git и недорогое объектное хранилище?
Ответ оказался утвердительным, и для этого не потребовался ни собственный демон файловой системы, ни сторонний сервис код-ревью. Требовалось лишь использовать Git для того, чем он на самом деле является под капотом: Merkle-tree Content-Addressable Store (CAS).
Заблуждение, лежащее в основе медленных монорепозиториев Git
Когда разработчики жалуются, что Git плохо масштабируется на больших монорепозиториях, они редко упираются в ограничение объектной базы Git. Внутри .git/objects Git хранит три неизменяемых примитива, индексированных по хешу содержимого SHA-1 (или SHA-256):
blob: сырое байтовое содержимое отдельного файла.tree: листинг каталога, который сопоставляет имена файлов и права доступа с хешамиblobили дочернихtree.commit: небольшой текстовый заголовок (обычно от 200 до 300 байт), указывающий на корневой хешtree, ноль или более родительских хешейcommitи метаданные автора.
Как content-addressable store, эта конструкция исключительно эффективна. Если монорепозиторий содержит 50 000 файлов и вы редактируете одну строку в одном файле, новый коммит повторно использует точные хеши blob и tree для остальных 49 999 файлов.
Почему же традиционный монорепозиторий Git ощущается тяжёлым? Потому что соглашение возлагает на одну ссылку, refs/heads/main, три конфликтующие функции:
- Она содержит полное единое дерево каталогов всех проектов.
- Она тянет за собой полную цепочку исторических коммитов, связанных через родителей, до самого первого дня.
- Она находится внутри
refs/heads/*, пространства имён, которое серверы Git по умолчанию анонсируют и согласовывают при каждомgit cloneиgit fetch.
Каждый раз, когда разработчик или CI-раннер получает refs/heads/main, клиент и сервер согласовывают достижимость в графе коммитов по тысячам исторических ревизий. Если кто-то случайно закоммитил архив объёмом 100 МБ три года назад и удалил его на следующий день, его blob всё равно висит на историческом tree, достижимом из refs/heads/main.
Разделение пространства ссылок на четыре уровня
Протокол передачи Git анонсирует только refs/heads/* и refs/tags/* при стандартном клонировании. Ссылки в пользовательских пространствах имён, таких как refs/trunk/* или refs/ws/*, находятся в том же объектном хранилище на сервере, используют то же сжатие packfile на основе дельт и могут быть получены или отправлены явно, при этом не создавая никаких накладных расходов для клонирования по умолчанию.
Разделив обязанности между четырьмя пространствами имён ссылок внутри одного репозитория, мы устраняем узкое место:
1refs/heads/main Только плоскость управления (~20 КБ: правила, .gitconfig, конфигурация синхронизации, скрипты)
2refs/trunk/main Снимок коммита без родителей, указывающий на актуальный единый корневой tree
3refs/trunk/history Линейная цепочка коммитов, связанных через родителей, с тем же точным корневым SHA-1 tree
4refs/ws/<actor>/<id> Изолированные снимки рабочих областей (changelist) для каждого разработчика или агента
5refs/heads/land/<id> Эфемерный триггер merge-queue, удаляемый сразу после слияния
1. Плоскость управления (refs/heads/main)
Ветка по умолчанию не содержит исходного кода приложений. Она хранит только правила эксплуатации репозитория (AGENTS.md, README.md), общий .gitconfig с псевдонимами рабочих процессов, конфигурацию синхронизации на Starlark (sync.star) и серверные скрипты слияния. Клонирование ветки по умолчанию передаёт примерно 20 КБ и завершается за миллисекунды.
2. Снимок trunk без родителей (refs/trunk/main)
refs/trunk/main указывает на полное, единое исходное дерево всей организации (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), однако его объект коммита создаётся без флага родителя -p.
Поскольку у refs/trunk/main нет родителей (parents = []), для его получения не требуется согласование графа коммитов. Сервер Git не обходит историю: он отправляет единственный головной коммит и текущие объекты tree каталогов. Исторические бинарные файлы или файлы, удалённые в прошлом месяце, недостижимы из refs/trunk/main и никогда не передаются.
3. Линейная история для аудита (refs/trunk/history)
При расследовании регрессий разработчикам по-прежнему нужны git blame и git log. Каждый раз, когда изменение попадает в trunk, скрипт слияния создаёт два объекта коммита, указывающих на точно такой же корневой SHA-1 хеш $NEW_TREE:
1# Снимок коммита без родителей для мгновенного checkout рабочей области с глубиной 1
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Коммит, связанный с родителем, для линейного git log и git blame
5HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")
6
7git push --atomic origin \
8 --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
9 --force-with-lease="refs/trunk/history:${OLD_HIST}" \
10 "${SNAP}:refs/trunk/main" \
11 "${HIST}:refs/trunk/history"
Удваивает ли поддержка и refs/trunk/main, и refs/trunk/history объём хранилища репозитория? Нисколько. Поскольку оба коммита ссылаются на идентичный хеш $NEW_TREE, content-addressable store Git дедуплицирует 100% объектов tree и blob. Дополнительная стоимость хранения снимка без родителей составляет лишь заголовок коммита размером 250 байт.
4. Изолированные рабочие области (refs/ws/<actor>/<id>)
Ветки незавершённой работы отправляются в refs/ws/<actor>/<id>, а не в refs/heads/*. Сотня агентов, пишущих код, может сохранять контрольные точки каждую минуту, не засоряя выпадающие списки веток и не замедляя git fetch для инженеров-людей.
Подключение за пять команд: как работает разреженный checkout без blob
При такой схеме ссылок новый разработчик или эфемерный контейнер агента не устанавливает собственный CLI или драйвер FUSE. Подключение занимает пять стандартных команд Git:
1# 1. Клонирование плоскости управления объёмом 20 КБ с фильтрацией blob и включённым разреженным режимом
2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
3cd monorepo
4
5# 2. Загрузка псевдонимов репозитория и подключение refs/trunk/*
6git config include.path ../.gitconfig
7git trunk-init
8
9# 3. Объявление доменных каталогов, необходимых для этой задачи
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Создание изолированной рабочей области на основе последнего снимка trunk
13git ws-new alice/optimize-framing
Под капотом каждый шаг выполняет минимально необходимый объём работы:
git clone --filter=blob:none --sparseзагружает плоскость управления объёмом 20 КБ сrefs/heads/main.git trunk-initнастраивает+refs/trunk/*:refs/remotes/origin/trunk/*и выполняет получение без blob. Git загружает метаданныеtreeдляrefs/trunk/main(обычно несколько десятков килобайт) и ноль файловых blob.git sparse-checkout set net/relay ipc/codecпоручает фильтру индекса Git в режиме cone материализовать только корневой каталог и два указанных поддерева.git ws-newпереключается на новую ветку, основанную наorigin/trunk/main. В этот момент Git по требованию запрашивает только объектыblobвнутриnet/relayиipc/codec. Оставшиеся 95% монорепозитория никогда не загружаются на диск.
Перспектива: монтирование refs/trunk/main как виртуальной файловой системы (VFS)
Разреженный checkout с клонированием без blob покрывает повседневные рабочие процессы без дополнительных клиентских инструментов. Поскольку refs/trunk/main уже представляет собой чистое дерево Меркла в Content-Addressable Store, архитектура также служит прямой основой для необязательной пользовательской виртуальной файловой системы (VFS) через FUSE в Linux, FileProvider в macOS или ProjFS в Windows:
- Мгновенное монтирование полного дерева: лёгкий локальный демон монтирования читает корневой объект
treeизrefs/trunk/mainи проецирует всю иерархию каталогов монорепозитория в рабочий каталог за миллисекунды, устраняя необходимость вgit sparse-checkout set. - Подгрузка blob по требованию: содержимое файлов остаётся виртуальным до тех пор, пока компилятор, IDE или агент не выполнит системный вызов
open(2)илиread(2). Тогда демон получает соответствующий SHA-1blobс конечной точки CAS и кэширует его на локальном хранилище NVMe. - Копирование при записи для рабочих областей: локальные правки записываются в лёгкий верхний слой-оверлей. Команда
git ws-saveвычисляет хеши только для изменённых путей в этом оверлее, записывает обновлённые объектыblobиtreeи отправляет полученный коммит вrefs/ws/<actor>/<id>, не сканируя немодифицированные каталоги.
Модель changelist на чистом Git: слияние в памяти на стороне сервера
В традиционном рабочем процессе Git слияние ветки с функциональными изменениями либо создаёт коммит слияния с двумя родителями (превращая историю в запутанную железнодорожную сортировочную станцию), либо требует, чтобы разработчики вручную выполняли rebase и состязались с другими инженерами, отправляющими изменения в main.
Вдохновившись моделью changelist (CL) из google3, мы представили каждую рабочую область в нашей архитектуре как единое атомарное изменение относительно снимка trunk. Пока разработчик или агент работает локально в ws/alice/optimize-framing, он может сделать десять инкрементальных коммитов (wip, fix tests, lint). Когда изменение готово к слиянию, сервер сворачивает дельту рабочей области в одну линейную ревизию trunk, не выполняя checkout рабочего каталога на CI-раннере.
Ниже показано, как скрипт слияния (land.sh) выполняется в памяти с использованием средств Git 2.40+:
1# 1. Определение корневого снимка коммита, от которого ответвилась рабочая область
2BASE=$(git rev-list --max-parents=0 "$WORK_SHA")
3
4BASE_TREE=$(git rev-parse "${BASE}^{tree}")
5MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
6WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")
7
8# 2. Вычисление объединённого корневого tree в памяти без checkout рабочего дерева
9if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
10 NEW_TREE="$WORK_TREE"
11elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
12 NEW_TREE=$(head -n1 <<<"$OUT")
13else
14 echo "Merge conflict detected; run git ws-sync to rebase." >&2
15 exit 1
16fi
Внимательно рассмотрим шаг 2:
- Независимо от того, сколько локальных коммитов было сделано в ветке рабочей области, скрипт сравнивает только три хеша tree: исходный базовый снимок (
$BASE_TREE), текущий снимок trunk ($MAIN_TREE) и итоговое дерево рабочей области ($WORK_TREE). - Если
refs/trunk/mainпродвинулась в//web/blog, пока Алиса редактировала//net/relay,git merge-tree --write-treeвычисляет объединённый корневой tree$NEW_TREEнепосредственно в объектном хранилище Git за миллисекунды. На файловую систему раннера ничего не записывается. - Наконец,
git push --atomicс--force-with-leaseобновляетrefs/trunk/main, добавляет один линейный коммит вrefs/trunk/historyи удаляет триггер слиянияrefs/heads/land/<id>в рамках одной транзакции.
Принудительное соблюдение архитектурных границ: четырёхуровневая видимость и иерархические OWNERS
Консолидация кода в одном репозитории работает только тогда, когда границы между доменами механически соблюдаются до того, как коммит может быть принят. Сразу после того, как git merge-tree --write-tree формирует $NEW_TREE (и до того, как git push --atomic обновит trunk), наш механизм политик выполняет две проверки со сложностью O(delta) над git diff-tree "$MAIN_TREE" "$NEW_TREE".
1. Четырёхуровневая видимость доменов
Корневое дерево разделено на четыре уровня видимости:
| Префикс каталога | Уровень | Разрешённые импорты | Запрещённые импорты | Ограничение OWNERS |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Публичный продакшн | Публичный продакшн, //third_party/* | //corp/*, //experimental/* | Применяется |
//third_party/* | Внешний вендор | Самодостаточные или //third_party/* | Все домены, кроме third_party | Применяется |
//corp/* | Внутренний закрытый | Публичный продакшн, //corp/*, //third_party/* | //experimental/* | Применяется |
//experimental/<actor>/* | Песочница | Любой пакет, не относящийся к corp | //corp/* | Исключение (автоодобрение) |
Если публичный пакет в //web/* пытается импортировать внутренний пакет из //corp/* или непроверенный прототип из //experimental/*, задание слияния прерывается до того, как затронет refs/trunk/main.
2. Иерархическая проверка OWNERS без сервера Gerrit
Для принудительного соблюдения файлов OWNERS на уровне каталогов с правилами наследования выделенный экземпляр Gerrit не нужен:
- Для каждого пути, изменённого между
$MAIN_TREEи$NEW_TREE, верификатор поднимается от каталога файла до//OWNERS. - Любой файл
OWNERSможет объявитьset noparent, чтобы заблокировать унаследованные одобрения из родительских каталогов, либоper-file *.sql=@dba-team, чтобы ограничить одобрения по маске имени файла. - Инвариант против подделки: существующие файлы
OWNERSвсегда читаются из$MAIN_TREE(текущего дерева trunk), а не из$NEW_TREE. Неодобренный автор не может выдать себе разрешение, отредактировавOWNERSв том же коммите рабочей области. - Исключение для песочницы: любой путь в
//experimental/<actor>/*обходит проверкиOWNERS, так что инженеры и агенты могут свободно создавать прототипы с помощьюgit ws-exp <actor> <project>, а позднее перевести пакет в продакшн-домен командойgit ws-graduate.
Код-ревью без трения: эфемерные PR и снимочные diff
Поскольку refs/heads/main содержит только плоскость управления, а refs/trunk/main является коммитом без родителей, открытие стандартного pull request на GitHub из ветки рабочей области в main попыталось бы сравнить несвязанные истории. Мы решили эту задачу тремя рабочими процессами ревью, адаптированными под разные типы изменений:
- Мгновенные двухточечные diff рабочих областей (
git ws-save+ маршрутизатор граничных diff) Отправка рабочей области командойgit ws-saveоткрываетrefs/ws/<actor>/<id>через облегчённый граничный воркер. Этот воркер определяет SHA коммитов дляrefs/trunk/mainи ссылки рабочей области и перенаправляет на представление сравнения GitHub с двумя точками (/compare/<trunk-sha>..<ws-sha>). Для небольших патчей или изменений, сгенерированных агентами, рецензенты могут изучить diff и нажать Land непосредственно в веб-интерфейсе. - Эфемерные pull request на GitHub (
git ws-pr) Для построчного ревью на GitHubgit ws-prотправляет корневой снимок коммита ($(git rev-list --max-parents=0 HEAD)) вrefs/heads/base/<id>, а вершину рабочей области вrefs/heads/review/<id>. Затем он открывает pull request на GitHub, сравнивающийreview/<id>сbase/<id>. Поскольку базовая ветка является точным снимком, от которого ответвилась рабочая область, вкладка Files changed на GitHub показывает только строки, изменённые в рабочей области. Когда рецензент нажимает Merge pull request, вебхук продвигает головной коммит вrefs/heads/land/<id>, запускает конвейер слияния в памяти и автоматически удаляетrefs/heads/base/<id>иrefs/heads/review/<id>. - Pull request во внешних зеркалах Участникам открытого исходного кода никогда не нужно изучать рабочий процесс монорепозитория. Они создают форки и открывают pull request в автономных зеркальных репозиториях обычным образом.
Двунаправленная синхронизация со сложностью O(delta) и отдельные производственные ветки
Многие организации публикуют отдельные пакеты как самостоятельные репозитории на GitHub, чтобы внешние пользователи могли выполнять go get, cargo install или форкать один проект, не обращаясь к монорепозиторию. Некоторые проекты, например статические блоги или веб-приложения с управляемыми релизами, также поддерживают отдельную ветку production наряду с main.
Наш движок синхронизации на Starlark обрабатывает как исходящий экспорт, так и входящий вклад за время O(delta), пропорциональное числу файлов, изменённых в коммите, а не размеру монорепозитория:
- Сплайсинг индекса без blob: при встраивании поддерева зеркального репозитория в корневое дерево монорепозитория или извлечении из него движок устанавливает
GIT_NO_LAZY_FETCH=1и выполняетgit update-index --index-info, после чегоgit write-tree --missing-ok. Немодифицированные поддеревья и blob указываются исключительно по их хешам SHA-1, без загрузки на CI-раннер. - Предварительная выборка только дельты blob: перед отправкой экспортированного коммита в автономное зеркало скрипт выполняет
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"и предварительно загружает только старые и новые blob изменённых файлов черезgit cat-file --batch. Затем Git может вычислить тонкий packfile с дельтами и отправить его в зеркальный репозиторий за секунды. - Сохранение рабочих процессов и релизных веток: при исходящем экспорте синхронизатор сохраняет собственный каталог
.github/workflows/каждого автономного репозитория, чтобы публичные CI-раннеры запускали тесты проекта на зеркале. Каждый рабочий процесс в конфигурации Starlark ориентирован на явную ветку, напримерmain, что сохраняет релизные ограничения для репозиториев, развёртывание которых выполняется из отдельной веткиproduction. Слияния в монорепозитории синхронизируются с веткойmainзеркала, а автоматические коммиты с метаданными или переводами вmainсинхронизируются обратно вrefs/trunk/mainчерез вебхук. Продвижениеmainвproductionостаётся явным этапом выпуска.
Заключительные замечания
Создание высокоскоростного монорепозитория не требует отказа от стандартного Git или эксплуатации сложного парка самописных серверов инфраструктуры. Эта архитектура рассматривает объектное хранилище Git как Content-Addressable Store, разделяет refs/heads/main, refs/trunk/main и refs/trunk/history и выполняет слияние деревьев в памяти с помощью git merge-tree --write-tree. В результате и инженеры-люди, и автономные агенты для написания кода могут клонировать рабочую область менее чем за секунду и масштабироваться на сотни проектов.