Преосмисляне на Git monorepo: CAS-Native архитектура за хора и Coding Agents

Всяка разрастваща се инженерна организация рано или късно трябва да избере между много хранилища и едно. Когато започнете с десетки малки хранилища, животът първоначално изглежда прост. Всяка библиотека има собствен тракер за проблеми, собствен CI конвейер и git clone, който завършва за секунди. След това променяте структура в основен мрежов пакет и прекарвате останалата част от следобеда в откриване на pull requests за актуализация на версията в шест зависими хранилища, като чакате опашките на CI да се изчистят в топологичен ред.
Ако преместите всичко в един традиционен Git монорепозитори, разменяте координацията между хранилищата за размер на хранилището. С нарастването на историята на commit-ите и натрупването на двоични активи в предишни ревизии git fetch се забавя. Работните дървета нарастват до гигабайти. Скоро някой предлага създаването на персонализиран демон за виртуална файлова система, специализиран сървър за code review или флот от отдалечени build работници, само за да остане ежедневната разработка поносима.
Когато автономни агенти за кодиране се присъединят към екипа, и двата режима на неуспех се влошават. Polyrepo архитектурите лишават агента от видимост към извикващите го код в други проекти и го принуждават да жонглира с идентификационни данни между хранилищата. Традиционните монорепозитории задушават диска на контейнера на агента и мрежовата честотна лента при всяка инициализация на задача, а едновременно работещи агенти, които работят в една и съща клонова линия, създават безкрайни състезания при rebase.
Вдъхновени от архитектурата google3, ние изградихме атомарни промени в един единствен trunk, sparse онбординг за под секунда, йерархично управление чрез OWNERS и самостоятелни отворени огледала със стандартна Git инфраструктура и масови обектни хранилища.
Дизайнът не се нуждае нито от персонализиран демон за файлова система, нито от външен appliance за review, тъй като третира 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 файла и редактирате един ред в един файл, новият commit използва повторно точните blob и tree хешове за останалите 49 999 файла.
Традиционният Git монорепозиторий изглежда тежък, защото конвенцията натоварва една единствена референция, refs/heads/main, с три конфликтни задачи:
- Той съдържа цялото обединено директорийно дърво на всеки проект.
- Той влачи след себе си цялата верига от родително свързани исторически commit-и до първия ден.
- Той се намира в
refs/heads/*, пространството от имена, което Git сървърите рекламират и договарят по подразбиране при всекиgit cloneиgit fetch.
При всяко извличане на refs/heads/main от разработчик или CI работник клиентът и сървърът договарят достижимостта в графа на commit-ите през хиляди исторически ревизии. Ако някой случайно е commit-нал архив от 100 MB преди три години и го е изтрил на следващия ден, неговият blob продължава да виси към исторически tree, достижим от refs/heads/main.
Разделяне на пространството от референции
Мрежовият протокол на Git рекламира само refs/heads/* и refs/tags/* при стандартно клониране. Референциите в персонализирани пространства от имена, като refs/trunk/* или refs/ws/*, се съхраняват в същото обектно хранилище на сървъра, ползват същата компресия на packfile с делтиране и могат да бъдат извличани или изпращани изрично, но не натоварват стандартните клонирания.
Разпределянето на тези задачи между отделни референции в едно хранилище премахва бутилкова шийка:
1refs/heads/main Само контролна равнина (~20 KB: правила, .gitconfig, конфигурация за синхронизация, скриптове)
2refs/trunk/main Snapshot commit без родители, който сочи към последното обединено корен дърво
3refs/trunk/history Линейна верига от родително свързани commit-и, споделяща точния корен tree SHA-1
4refs/ws/<actor>/<id> Изолирани снимки на работни пространства (changelists) за всеки разработчик или агент
5refs/ws/cl/<change-id>/<tip> Неизменяеми patchsets: всеки връх на промяна, изпратен за review
6refs/heads/land/<id> Ефимерен тригер за merge опашката, изтрит веднага след интегриране
1. Контролната равнина (refs/heads/main)
Основният клон не съдържа изходен код на приложения. Той проследява само правилата за работа на хранилището (AGENTS.md, README.md), споделен .gitconfig с псевдоними за работните процеси, конфигурацията за синхронизация на Starlark (sync.star) и серверните скриптове за сливане. Клонирането на основния клон прехвърля приблизително 20 KB и завършва за милисекунди.
2. Snapshot на trunk без родители (refs/trunk/main)
refs/trunk/main сочи към пълното, обединено изходно дърво на цялата организация (//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), но неговият commit обект се създава без флага -p за родител.
Тъй като refs/trunk/main няма родители (parents = []), извличането му не изисква договаряне на графа на commit-ите. Git сървърът не обхожда историята; той изпраща единствения връх на commit-а и текущите му директорийни tree обекти. Историческите двоични файлове или файловете, изтрити миналия месец, не са достижими от refs/trunk/main и никога не се прехвърлят.
3. Линейна история за одит (refs/trunk/history)
Разработчиците все още се нуждаят от git blame и git log при разследване на регресии. Всеки път, когато промяна бъде интегрирана, скриптът за сливане създава два commit обекта, които сочат към точно същия корен $NEW_TREE SHA-1 хеш:
1# Snapshot commit без родител за незабавни checkout-и на работно пространство с дълбочина 1
2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
3
4# Commit с родител за линейни 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 не удвоява отпечатъка на съхранението. Тъй като и двата commit-а сочат към идентичния $NEW_TREE хеш, съдържателно адресируемото хранилище на Git дедуплицира 100% от tree и blob обектите. Допълнителната цена за съхранение на snapshot без родители е един commit заглавен блок от 250 байта.
4. Изолирани работни пространства (refs/ws/<actor>/<id>)
Работните клонове в процес на разработка се изпращат към refs/ws/<actor>/<id>, а не към refs/heads/*. Стотици агенти за кодиране могат да запазват контролни точки всяка минута, без да замърсяват списъците с клонове или да забавят git fetch за човешките инженери.
Онбординг в четири стъпки: как работи sparse и blobless извличането
С тази референтна структура новият разработчик или ефимерният контейнер на агент за кодиране не инсталира персонализиран CLI или FUSE драйвер. Онбордингът отнема четири стъпки със стандартен Git и псевдонимите на хранилището:
1# 1. Клониране на 20 KB контролната равнина с филтриране на blob обекти и включен sparse режим
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. Деклариране на domain директориите, необходими за тази задача
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Създаване на изолирано работно пространство, базирано на най-новия trunk snapshot
13git ws-new alice/optimize-framing
Всяка стъпка извлича само това, от което се нуждае:
git clone --filter=blob:none --sparseизтегля 20 KB контролна равнина отrefs/heads/main.git trunk-initконфигурира+refs/trunk/*:refs/remotes/origin/trunk/*и изпълнява blobless извличане. Git изтегляtreeметаданните заrefs/trunk/main(обикновено няколко десетки килобайта) и нула файлови blob обекти.git sparse-checkout set net/relay ipc/codecинструктира cone-mode филтъра на индекса на Git да материализира само коренната директория и тези две поддървета.git ws-newпревключва към нов клон, базиран наorigin/trunk/main. След това Git изисква при нужда самоblobобектите вnet/relayиipc/codec. Останалите 95% от монорепозиторито никога не се изтеглят на диска.
Бъдещ хоризонт: монтиране на refs/trunk/main като виртуална файлова система (VFS)
Sparse checkout с blobless клонирания покрива ежедневните работни процеси без допълнителни клиентски инструменти. Тъй като 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 хранилище. - Copy-on-Write наслагване за работните пространства: Локалните редакции се записват в лек горен слой на наслагване. Изпълнението на
git ws-saveхешира само промените пътища в това наслагване, записва актуализиранитеblobиtreeобекти и изпраща получения commit къмrefs/ws/<actor>/<id>, без да сканира непроменените директории.
Модел на changelist върху чист Git: интегриране в паметта от страна на сървъра
В традиционния Git работен процес сливането на клон с функционалност или създава merge commit с два родителя, което прави историята нелинейна, или изисква от разработчиците ръчен rebase и състезание с едновременни изпращания към main.
Вдъхновени от модела Changelist (CL) на google3, всяко работно пространство в нашата архитектура представлява една атомарна промяна спрямо trunk snapshot. Докато работи локално в ws/alice/optimize-framing, разработчикът или агентът може да направи десет инкрементални commit-а (wip, fix tests, lint). Когато промяната е готова за интегриране, сървърът свива делтата на работното пространство в една линейна ревизия на trunk, без изобщо да извършва checkout на работна директория на CI работника.
Ето как скриптът за интегриране (land.sh) се изпълнява в паметта с водопроводна инфраструктура на Git 2.40+:
1# 1. Определяне на корен snapshot commit, от който е клонирало работното пространство
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:
- Независимо от това колко локални commit-а са направени в клона на работното пространство, скриптът сравнява само три хеша на дърва: първоначалния базов 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, добавя един линеен commit къмrefs/trunk/history, а тригерът за интегриранеrefs/heads/land/<id>се изтрива.
Налагане на архитектурните граници: четиристепенна видимост и йерархични OWNERS
Консолидирането на кода в едно хранилище работи само ако границите между домейните се прилагат механично, преди даден commit да може да бъде интегриран. Непосредствено след като 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в същия commit на работното пространство. - Изключение за пясъчника: Всеки път под
//experimental/<actor>/*заобикаля проверките наOWNERS, така че инженерите и агентите да могат свободно да създават прототипи сgit ws-exp <actor> <project>и по-късно да прехвърлят пакета в продукционен домейн сgit ws-graduate.
Преглед на кода без триене: ефимерни PR и diff на snapshot
Тъй като refs/heads/main съдържа само контролната равнина, а refs/trunk/main е commit без родители, отварянето на стандартен GitHub Pull Request директно от клона на работното пространство към main би сравнявало несвързани истории. Решихме това с три работни процеса за преглед, съобразени с различните промени:
- Незабавни двуточкови diff-ове на работното пространство (
git ws-save+ Edge Diff Router) Изпращането на работно пространство сgit ws-saveправиrefs/ws/<actor>/<id>достъпно през лек edge worker. Този worker преобразуваrefs/trunk/mainи референцията на работното пространство в техните SHA на commit-ите и пренасочва към двуточковия изглед за сравнение на GitHub (/compare/<trunk-sha>..<ws-sha>). При малки кръпки или промени, генерирани от агенти, рецензентите могат да прегледат diff-а и да натиснат Land директно в уеб интерфейса. - Ефимерни GitHub Pull Requests (
git ws-pr) За преглед ред по ред в GitHub,git ws-prизпраща корен snapshot commit-а ($(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 повишава head commit-а доrefs/heads/land/<id>, изпълнява конвейера за интегриране в паметта и автоматично изтрива кактоrefs/heads/base/<id>, така иrefs/heads/review/<id>. - Pull Requests към външни огледала Сътрудниците с отворен код никога не трябва да се запознават с работния процес на монорепозиторито. Те правят fork и отварят pull requests към самостоятелни огледални хранилища по обичайния начин.
Промени, patchsets и пренасяне на одобрения
Името на клон не може да идентифицира промяна. Агент може да преименува клона си, да измени върха или да направи rebase върху по-нов trunk няколко пъти преди интегриране. Както в Gerrit, работният процес идентифицира промяната чрез ред Change-Id: I<40 hex> в съобщението на върховия commit. git ws-save, git ws-pr и git ws-land добавят реда, когато върхът няма такъв, и го запазват, когато има, така че идентификаторът да оцелява при amend, rebase и преименуване. Интегрираният commit в trunk запазва реда и записва номера на своя review pull request, така че git trunk-cl <number|Change-Id> го намира в refs/trunk/history.
Всеки връх, изпратен за преглед, става patchset. git ws-pr го изпраща към неизменяемата референция refs/ws/cl/<change-id>/<tip> до review клоновете. Повторното изпълнение на git ws-pr след amend или git ws-sync добавя patchset към същия pull request. git ws-ps изброява patchset-ите на текущата промяна, а git ws-psdiff <a> <b> извежда interdiff между два от тях, така че рецензентът чете само това, което е променено след последния кръг.
GitHub свързва одобрението с единствен commit, така че всеки rebase обикновено би изисквал нов преглед. Конвейерът за интегриране приема и одобрение, дадено на друг patchset от същата промяна, когато и двата patchset-а правят една и съща кръпка спрямо собствения си базов snapshot. Той сравнява git patch-id --verbatim върху пълния двоичен diff. Чист rebase върху по-нов trunk се интегрира без нов преглед, докато всяка промяна на съдържанието, включително на интервали или режим на файла, изисква ново одобрение.
O(delta) двупосочна синхронизация и отделни продукционни клони
Много организации публикуват отделни пакети като самостоятелни GitHub хранилища, така че външни потребители да могат да изпълнят go get, cargo install или да направят fork на един проект, без да докосват монорепозиторито. Някои проекти, като статични блогове или уеб приложения със стриктно контролирано освобождаване, поддържат и отделен клон production до main.
Нашият енджин за синхронизация на Starlark обработва както изходящите експорти, така и входящите приноси със сложност O(delta), пропорционална на файловете, променени в даден commit, а не на размера на монорепозиторито:
- Вмъкване в индекса без blob обекти: При вмъкване или изваждане на поддървото на огледалното хранилище от корен дървото на монорепозиторито енджинът задава
GIT_NO_LAZY_FETCH=1и изпълняваgit update-index --index-info, последвано отgit write-tree --missing-ok. Непроменените поддървета и blob обекти остават препратени чрез SHA-1 и никога не се изтеглят на CI работника. - Предварително извличане на blob обекти само за делтата: Преди изпращане на експортиран commit към самостоятелно огледало скриптът изпълнява
git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"и предварително извлича само старите и новите blob обекти на променените файлове чрезgit cat-file --batch. Git тогава може да изчисли тънък delta packfile и да го изпрати към огледалното хранилище за секунди. - Запазване на работните процеси и клоновете за освобождаване: При изходящия експорт синхронизаторът запазва директорията
.github/workflows/на всяко самостоятелно хранилище, така че публичните CI работници да изпълняват тестовете на проекта върху огледалото. Всеки работен процес в конфигурацията на Starlark е насочен към изричен клон, напримерmain, което запазва контролните точки за освобождаване за хранилищата, които се разгръщат от отделен клонproduction. Интеграциите в монорепозиторито се синхронизират с клонаmainна огледалото, а автоматизираните commit-и с метаданни или преводи вmainсе синхронизират обратно къмrefs/trunk/mainчрез webhook. Повишаването наmainдоproductionостава изрична стъпка за освобождаване.
Заключителни бележки
Тази архитектура третира обектното хранилище на Git като Content-Addressable Store, отделя refs/heads/main от refs/trunk/main и refs/trunk/history, идентифицира всяка промяна чрез Change-Id с неизменяеми patchsets и обединява дърветата в паметта чрез git merge-tree --write-tree. Цената на онбординга расте с директориите, от които се нуждае дадена задача, а цената на интегрирането и синхронизацията с огледалата расте с размера на промяната, а не с размера на хранилището.