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

Каждая растущая инженерная организация рано или поздно выбирает между множеством репозиториев и одним. Начинаете с десятков небольших репозиториев, и поначалу жизнь кажется простой. У каждой библиотеки свой трекер задач, собственный конвейер 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):
blob: Необработанное байтовое содержимое отдельного файла.tree: Листинг каталога, который сопоставляет имена файлов и права доступа с хешамиblobили дочернихtree.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
Каждый шаг загружает только то, что ему нужно:
git clone --filter=blob:none --sparseзагружает control plane объёмом 20 КБ поrefs/heads/main.git trunk-initнастраивает+refs/trunk/*:refs/remotes/origin/trunk/*и выполняет fetch без 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 уже представляет собой чистое дерево Merkle в 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 слияние ветки с функцией либо создаёт 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 привело бы к попытке сравнить несвязанные истории. Мы решили эту задачу тремя рабочими процессами ревью, адаптированными под разные изменения:
- Мгновенные двухточечные 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 непосредственно в веб-интерфейсе. - Эфемерные Pull Request на GitHub (
git ws-pr) Для построчного ревью на GitHubgit 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>. - 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. Стоимость подключения растёт с количеством каталогов, нужных задаче, а стоимость слияния и синхронизации с зеркалом растёт с размером изменения, а не с размером репозитория.