GoSuda

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

By Lemon Mint views ...

Каждая растущая инженерная организация рано или поздно сталкивается с одной и той же развилкой. Вначале, когда есть десятки небольших репозиториев, всё кажется простым. У каждой библиотеки есть собственный трекер задач, собственный 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):

  1. blob: сырое байтовое содержимое отдельного файла.
  2. tree: листинг каталога, который сопоставляет имена файлов и права доступа с хешами blob или дочерних tree.
  3. 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

Под капотом каждый шаг выполняет минимально необходимый объём работы:

  1. git clone --filter=blob:none --sparse загружает плоскость управления объёмом 20 КБ с refs/heads/main.
  2. git trunk-init настраивает +refs/trunk/*:refs/remotes/origin/trunk/* и выполняет получение без blob. Git загружает метаданные tree для refs/trunk/main (обычно несколько десятков килобайт) и ноль файловых blob.
  3. git sparse-checkout set net/relay ipc/codec поручает фильтру индекса Git в режиме cone материализовать только корневой каталог и два указанных поддерева.
  4. 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:

  1. Мгновенное монтирование полного дерева: лёгкий локальный демон монтирования читает корневой объект tree из refs/trunk/main и проецирует всю иерархию каталогов монорепозитория в рабочий каталог за миллисекунды, устраняя необходимость в git sparse-checkout set.
  2. Подгрузка blob по требованию: содержимое файлов остаётся виртуальным до тех пор, пока компилятор, IDE или агент не выполнит системный вызов open(2) или read(2). Тогда демон получает соответствующий SHA-1 blob с конечной точки CAS и кэширует его на локальном хранилище NVMe.
  3. Копирование при записи для рабочих областей: локальные правки записываются в лёгкий верхний слой-оверлей. Команда 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 попыталось бы сравнить несвязанные истории. Мы решили эту задачу тремя рабочими процессами ревью, адаптированными под разные типы изменений:

  1. Мгновенные двухточечные diff рабочих областей (git ws-save + маршрутизатор граничных diff) Отправка рабочей области командой git ws-save открывает refs/ws/<actor>/<id> через облегчённый граничный воркер. Этот воркер определяет SHA коммитов для refs/trunk/main и ссылки рабочей области и перенаправляет на представление сравнения GitHub с двумя точками (/compare/<trunk-sha>..<ws-sha>). Для небольших патчей или изменений, сгенерированных агентами, рецензенты могут изучить diff и нажать Land непосредственно в веб-интерфейсе.
  2. Эфемерные pull request на GitHub (git ws-pr) Для построчного ревью на GitHub git 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>.
  3. 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. В результате и инженеры-люди, и автономные агенты для написания кода могут клонировать рабочую область менее чем за секунду и масштабироваться на сотни проектов.