GoSuda

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

By Lemon Mint views ...

Каждая растущая инженерная организация рано или поздно выбирает между множеством репозиториев и одним. Начинаете с десятков небольших репозиториев, и поначалу жизнь кажется простой. У каждой библиотеки свой трекер задач, собственный конвейер CI и git clone, который завершается за считанные секунды. Затем вы меняете структуру в основном сетевом пакете и проводите остаток дня, открывая version-bump pull request-ы в шести зависимых репозиториях и дожидаясь очистки очередей CI в топологическом порядке.

Переносите всё в традиционный монорепозиторий Git, и вы обмениваете координацию между репозиториями на размер репозитория. По мере углубления истории коммитов и накопления бинарных ресурсов в прошлых ревизиях git fetch замедляется. Рабочие деревья разрастаются до гигабайтов. Вскоре кто-нибудь предлагает развернуть собственный демон виртуальной файловой системы, выделенный сервер код-ревью или парк удалённых рабочих узлов сборки, лишь бы сохранить приемлемость повседневной разработки.

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

Если монорепозиторий содержит 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               Только control plane (~20 КБ: правила, .gitconfig, конфигурация синхронизации, скрипты)
2refs/trunk/main               Snapshot-коммит без родителей, указывающий на последнее единое корневое дерево
3refs/trunk/history            Линейная цепочка коммитов с родительскими ссылками, разделяющая точный корневой SHA-1 tree
4refs/ws/<actor>/<id>          Изолированные снимки рабочих пространств (changelists) для каждого разработчика или агента
5refs/ws/cl/<change-id>/<tip>  Неизменяемые patchset-ы: каждая вершина изменения, отправленная на ревью
6refs/heads/land/<id>          Эфемерный триггер merge queue, удаляемый сразу после слияния

1. Плоскость управления (refs/heads/main)

Ветка по умолчанию не содержит исходного кода приложений. Она содержит только правила работы репозитория (AGENTS.md, README.md), общий .gitconfig с псевдонимами рабочих процессов, конфигурацию синхронизации на Starlark (sync.star) и серверные скрипты слияния. Клонирование ветки по умолчанию передаёт около 20 КБ и завершается за миллисекунды.

2. Snapshot без родителей (refs/trunk/main)

refs/trunk/main указывает на полное, единое исходное дерево всей организации (//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //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# Snapshot-коммит без родителей для мгновенного 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. Дополнительная стоимость snapshot без родителей составляет один заголовок коммита размером 250 байт.

4. Изолированные рабочие пространства (refs/ws/<actor>/<id>)

Ветки в процессе работы отправляются в refs/ws/<actor>/<id>, а не в refs/heads/*. Сотня агентов, пишущих код, может сохранять контрольные точки каждую минуту, не засоряя выпадающие списки веток и не замедляя git fetch для инженеров-людей.

Подключение за четыре шага: как работает разреженный checkout без blob

При такой структуре ссылок новому разработчику или эфемерному контейнеру агента для написания кода не нужно устанавливать собственный CLI или драйвер FUSE. Подключение занимает четыре шага со стандартным Git и псевдонимами репозитория:

 1# 1. Клонировать control plane объёмом 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. Создать изолированное рабочее пространство на основе последнего snapshot trunk
13git ws-new alice/optimize-framing

Каждый шаг загружает только то, что ему нужно:

  1. git clone --filter=blob:none --sparse загружает control plane объёмом 20 КБ по refs/heads/main.
  2. git trunk-init настраивает +refs/trunk/*:refs/remotes/origin/trunk/* и выполняет fetch без 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 уже представляет собой чистое дерево Merkle в 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 слияние ветки с функцией либо создаёт merge-коммит с двумя родителями, что делает историю нелинейной, либо требует от разработчиков вручную выполнять rebase и соревноваться с параллельными push в main.

Вдохновлённая моделью Changelist (CL) из google3, каждое рабочее пространство в нашей архитектуре представляет одно атомарное изменение относительно snapshot trunk. Пока ws/alice/optimize-framing редактируется локально, разработчик или агент может сделать десять инкрементальных коммитов (wip, fix tests, lint). Когда изменение готово к слиянию, сервер сворачивает дельту рабочего пространства в одну линейную ревизию trunk, не выполняя checkout рабочего каталога на CI-раннере.

Вот как скрипт слияния (land.sh) выполняется в памяти с использованием сантехники Git 2.40+:

 1# 1. Определить корневой snapshot-коммит, от которого ответвилось рабочее пространство
 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. Вычислить объединённое корневое дерево в памяти без 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:

  • Независимо от того, сколько локальных коммитов было сделано в ветке рабочего пространства, скрипт сравнивает только три хеша дерева: исходный базовый snapshot ($BASE_TREE), текущий snapshot trunk ($MAIN_TREE) и итоговое дерево рабочего пространства ($WORK_TREE).
  • Если refs/trunk/main продвинулся в //web/blog, пока Алиса редактировала //net/relay, git merge-tree --write-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 на уровне каталогов с правилами наследования:

  • Для каждого пути, изменённого между $MAIN_TREE и $NEW_TREE, верификатор поднимается от каталога файла до //OWNERS.
  • Любой файл OWNERS может объявить set noparent, чтобы заблокировать унаследованные одобрения из родительских каталогов, или per-file *.sql=@dba-team, чтобы ограничить одобрения по glob-маске имени файла.
  • Инвариант защиты от подделки: существующие файлы OWNERS всегда читаются из $MAIN_TREE (текущего дерева trunk), но никогда не из $NEW_TREE. Неодобренный автор не может выдать себе разрешение, редактируя OWNERS в том же коммите рабочего пространства.
  • Исключение для песочницы: любой путь в //experimental/<actor>/* обходит проверки OWNERS, чтобы инженеры и агенты могли свободно создавать прототипы с помощью git ws-exp <actor> <project> и позднее перевести пакет в производственную предметную область командой git ws-graduate.

Ревью без трения: эфемерные PR и diff снимков

Поскольку refs/heads/main содержит только control plane, а refs/trunk/main является коммитом без родителей, открытие стандартного GitHub Pull Request напрямую из ветки рабочего пространства в main привело бы к попытке сравнить несвязанные истории. Мы решили эту задачу тремя рабочими процессами ревью, адаптированными под разные изменения:

  1. Мгновенные двухточечные diff рабочих пространств (git ws-save + Edge Diff Router) Отправка рабочего пространства командой git ws-save открывает refs/ws/<actor>/<id> через легковесного edge-воркера. Этот воркер разрешает refs/trunk/main и ссылку рабочего пространства в SHA их коммитов и перенаправляет на представление сравнения GitHub с двумя точками (/compare/<trunk-sha>..<ws-sha>). Для небольших патчей или изменений, сгенерированных агентами, рецензенты могут просмотреть diff и нажать Land непосредственно в веб-интерфейсе.
  2. Эфемерные Pull Request на GitHub (git ws-pr) Для построчного ревью на GitHub git ws-pr отправляет корневой snapshot-коммит ($(git rev-list --max-parents=0 HEAD)) в refs/heads/base/<id>, а вершину рабочего пространства в refs/heads/review/<id>. Затем открывает GitHub Pull Request, сравнивающий review/<id> с base/<id>. Поскольку базовая ветка является snapshot, от которого ответвилось рабочее пространство, вкладка Files changed на GitHub показывает только дельту рабочего пространства. Когда рецензент нажимает Merge pull request, webhook продвигает головной коммит в refs/heads/land/<id>, запускает конвейер слияния в памяти и автоматически удаляет обе ссылки: refs/heads/base/<id> и refs/heads/review/<id>.
  3. Pull Request во внешних зеркалах Участникам open-source не нужно изучать рабочий процесс монорепозитория. Они делают fork и открывают pull request-ы в самостоятельных зеркальных репозиториях как обычно.

Изменения, patchset-ы и перенос одобрений

Имя ветки не может идентифицировать изменение. Агент может переименовать свою ветку, исправить вершину через amend или несколько раз выполнить rebase на более новый trunk до слияния. Как и в Gerrit, рабочий процесс идентифицирует изменение по трейлеру Change-Id: I<40 hex> в сообщении вершинного коммита. Команды git ws-save, git ws-pr и git ws-land добавляют трейлер, если его нет, и сохраняют существующий, поэтому идентификатор переживает amend, rebase и переименования. Зафиксированный коммит trunk сохраняет трейлер и записывает номер своего pull request на ревью, поэтому git trunk-cl <number|Change-Id> находит его в refs/trunk/history.

Каждая вершина, отправленная на ревью, становится patchset. git ws-pr отправляет её в неизменяемую ссылку refs/ws/cl/<change-id>/<tip> рядом с ветками ревью. Повторный запуск git ws-pr после amend или git ws-sync добавляет patchset в тот же pull request. git ws-ps перечисляет patchset-ы текущего изменения, а git ws-psdiff <a> <b> выводит interdiff между двумя из них, так что рецензент читает только то, что изменилось с прошлого раунда.

GitHub привязывает одобрение к одному коммиту, поэтому каждый rebase обычно потребовал бы нового ревью. Конвейер слияния также принимает одобрение, выданное другому patchset того же изменения, если оба patchset вносят одинаковый патч относительно собственного базового snapshot. Он сравнивает git patch-id --verbatim по полному бинарному diff. Чистый rebase на более новый trunk проходит без повторного ревью, тогда как любая правка содержимого, включая изменение пробелов или режима файла, требует нового одобрения.

Двунаправленная синхронизация O(delta) и отдельные производственные ветки

Многие организации публикуют отдельные пакеты как самостоятельные репозитории GitHub, чтобы внешние пользователи могли выполнять go get, cargo install или форкать один проект, не трогая монорепозиторий. Некоторые проекты, например статические блоги или веб-приложения с контролем релизов, также поддерживают отдельную ветку production рядом с main.

Наш движок синхронизации на Starlark обрабатывает как исходящие экспорты, так и входящие вклады за время O(delta), пропорциональное числу файлов, изменённых в коммите, а не размеру монорепозитория:

  • Splicing индекса без 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 может вычислить тонкий delta-packfile и отправить его в зеркальный репозиторий за секунды.
  • Сохранение рабочих процессов и релизных веток: При исходящем экспорте синхронизатор сохраняет собственный каталог .github/workflows/ каждого самостоятельного репозитория, поэтому публичные CI-раннеры запускают тесты проекта на зеркале. Каждый workflow в конфигурации Starlark нацелен на явную ветку, например main, что сохраняет релизные ворота для репозиториев, которые развертываются из отдельной ветки production. Слияния монорепозитория синхронизируются с веткой main зеркала, а автоматические коммиты метаданных или переводов в main синхронизируются обратно в refs/trunk/main через webhook. Продвижение main в production остаётся явным шагом релиза.

Заключительные мысли

Эта архитектура рассматривает хранилище объектов Git как Content-Addressable Store, разделяет refs/heads/main, refs/trunk/main и refs/trunk/history, идентифицирует каждое изменение по Change-Id с неизменяемыми patchset-ами и объединяет деревья в памяти с помощью git merge-tree --write-tree. Стоимость подключения растёт с количеством каталогов, нужных задаче, а стоимость слияния и синхронизации с зеркалом растёт с размером изменения, а не с размером репозитория.