Преосмисляне на Git monorepo: CAS-нативна архитектура за хора и coding agents

Всяка растяща инженерна организация рано или късно стига до една и съща развилка. Когато започнете с няколко десетки малки хранилища, всичко изглежда просто. Всяка библиотека има собствен тракер за проблеми, собствен CI конвейер и git clone, който завършва, преди да сте отпили от кафето си. После настъпва данъкът. Променяте структура в основен мрежов пакет и прекарвате останалата част от следобеда в отваряне на pull request-и за обновяване на версии в шест зависими хранилища, като чакате опашките на CI да се изчистят в топологичен ред.
Ако преместите всичко в традиционно Git монорепо, размяната е следната: координационната болка се заменя с физическата. С нарастването на историята на commit-ите и натрупването на двоични активи в предишните ревизии git fetch се забавя. Работните дървета нарастват до гигабайти. Скоро някой предлага да се разгърне персонализиран демон за виртуална файлова система, специализиран сървър за code review или флот от отдалечени build работници, само за да остане ежедневната разработка поносима.
Когато в екипа се включат автономни агенти за писане на код, и двата режима на неуспех се влошават. Полихранилищата лишават агента от видимост към извикванията между проектите и го принуждават да жонглира с идентификационни данни между хранилищата. Традиционните монорепозитории изчерпват дисковото пространство в контейнера на агента и мрежовата честотна лента при всяка инициализация на задача, а едновременно работещите агенти, които се намесват в един и същ клон, създават безкрайни състезания при rebase.
Ние търсехме трети вариант. Вдъхновени от архитектурата google3, си зададохме прост въпрос: Можем ли да постигнем атомарни промени в единичен trunk, sparse onboarding за под секунда, йерархично управление чрез OWNERS и самостоятелни open-source огледала, използвайки единствено стандартната Git инфраструктура и обектно хранилище от масов клас?
Отговорът се оказа утвърдителен и не изискваше нито персонализиран демон за файлова система, нито външен апарат за 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хеша и метаданни за автора.
Като content-addressable store, този дизайн е изключително ефективен. Ако монорепо съдържа 50 000 файла и редактирате един ред в един файл, новият commit повторно използва точните blob и tree хешове за останалите 49 999 файла.
Защо тогава традиционното Git монорепо изглежда тежко? Защото конвенцията натоварва една референция, refs/heads/main, с три конфликтни функции:
- Тя съдържа цялото обединено директорийно дърво на всеки проект.
- Тя влачи след себе си цялата верига от родителски свързани исторически commit-и до първия ден.
- Тя се намира в
refs/heads/*, пространството от имена, което Git сървърите рекламират и с което по подразбиране договарят при всекиgit cloneиgit fetch.
Всеки път, когато разработчик или CI работник извлича refs/heads/main, клиентът и сървърът договарят достижимостта в графа на commit-ите през хиляди исторически ревизии. Ако някой по невнимание е commit-нал архив от 100 MB преди три години и го е изтрил на следващия ден, неговият blob все още виси към исторически tree, достижим от refs/heads/main.
Разделяне на пространството от референции на четири слоя
Протоколът за обмен на Git рекламира по време на стандартен clone само refs/heads/* и refs/tags/*. Референциите в персонализирани пространства от имена, като refs/trunk/* или refs/ws/*, се съхраняват в същото обектно хранилище на сървъра, ползват същата компресия на packfile с делти и могат да бъдат извличани или изпращани явно, без да натоварват по никакъв начин стандартните clone операции.
Чрез разпределяне на отговорностите между четири пространства от референции в рамките на едно хранилище, пречката изчезва:
1refs/heads/main Само контролна равнина (~20 KB: правила, .gitconfig, конфигурация за синхронизация, скриптове)
2refs/trunk/main Commit без родители, сочещ към най-новия обединен корен tree
3refs/trunk/history Линейна верига от родително свързани commit-и, споделяща точно същия корен tree SHA-1
4refs/ws/<actor>/<id> Изолирани снимки на работни пространства (changelist-и) за всеки разработчик или агент
5refs/heads/land/<id> Ефимерен тригер за merge опашката, изтрит веднага след приемането
1. Контролната равнина (refs/heads/main)
Основният клон не съдържа никакъв изходен код на приложения. Той проследява само оперативните правила на хранилището (AGENTS.md, README.md), споделен .gitconfig с псевдоними за работните процеси, конфигурацията за синхронизация на Starlark (sync.star) и скриптовете за сливане от страна на сървъра. Клонирането на основния клон прехвърля приблизително 20 KB и завършва за милисекунди.
2. Снимка на trunk без родители (refs/trunk/main)
refs/trunk/main сочи към пълното, обединено изходно дърво на цялата организация (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), но неговият commit обект се създава без флаг -p за родител.
Тъй като refs/trunk/main няма родители (parents = []), извличането му не изисква договаряне на графа на commit-ите. Git сървърът не обхожда историята; той изпраща единствения head commit и текущите му директорийни tree обекти. Историческите двоични файлове или файловете, изтрити преди месец, са недостъпни от refs/trunk/main и никога не се прехвърлят.
3. Линейна история за одит (refs/trunk/history)
При разследване на регресии разработчиците все още се нуждаят от git blame и git log. При всяко приемане на промяна скриптът за сливане създава два commit обекта, сочещи към точно същия корен $NEW_TREE SHA-1 хеш:
1# Снимка на commit без родители за мигновени плитки (depth-1) checkout-и на работни пространства
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, content-addressable store на Git дедуплицира 100% от tree и blob обектите. Допълнителната цена за съхранение на снимката без родители е един 250-байтов заглавен блок на commit.
4. Изолирани работни пространства (refs/ws/<actor>/<id>)
Работните клонове в процес на разработка се изпращат към refs/ws/<actor>/<id>, а не към refs/heads/*. Стотици агенти за писане на код могат да записват контролни точки всяка минута, без да замърсяват падащите менюта с клонове или да забавят git fetch за човешките инженери.
Onboarding за пет команди: как работи sparse и blobless checkout
С този референтен модел новият разработчик или ефимерният контейнер на агент за писане на код не инсталира персонализиран CLI или FUSE драйвер. Onboarding отнема пет стандартни 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. Деклариране на директориите на домейните, необходими за тази задача
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 KB контролната равнина отrefs/heads/main.git trunk-initконфигурира+refs/trunk/*:refs/remotes/origin/trunk/*и изпълнява blobless fetch. Git изтегляtreeметаданните заrefs/trunk/main(обикновено няколко десетки килобайта) и нула файлови blob обекти.git sparse-checkout set net/relay ipc/codecинструктира cone-mode филтъра на индекса на Git да материализира само корenската директория и тези две поддървета.git ws-newпревключва към нов клон, базиран наorigin/trunk/main. В този точен момент Git изисква при нужда самоblobобектите вnet/relayиipc/codec. Останалите 95% от монорепото никога не се изтеглят на диска.
Бъдещи перспективи: монтиране на refs/trunk/main като виртуална файлова система (VFS)
Sparse checkout с blobless clone покрива ежедневните работни процеси без допълнителни клиентски инструменти. Тъй като refs/trunk/main вече е чисто Merkle дърво в Content-Addressable Store, архитектурата осигурява и директна основа за незадължителна виртуална файлова система (VFS) в потребителското пространство чрез FUSE на Linux, FileProvider на macOS или ProjFS на Windows:
- Мигновено монтиране на цялото дърво: Лек локален демон за монтиране чете корenния
treeобект наrefs/trunk/mainи проектира цялата йерархия на монорепото в работната директория за милисекунди, премахвайки нуждата от изпълнение наgit sparse-checkout set. - Зареждане на blob обекти при поискване: Съдържанието на файловете остава виртуално, докато компилаторът, IDE или агентът не извика системно повикване
open(2)илиread(2). Тогава демонът извлича съответния SHA-1blobот CAS крайната точка и го кешира на локалното NVMe хранилище. - Copy-on-Write overlay за работните пространства: Локалните редакции се записват в лек горен overlay слой. Изпълнението на
git ws-saveхешира само променените пътища в този overlay, записва актуализиранитеblobиtreeобекти и изпраща получения commit къмrefs/ws/<actor>/<id>, без да сканира немодифицираните директории.
Модел на changelist върху чист Git: сливане в паметта от страна на сървъра
В традиционния Git работен процес сливането на feature клон или създава merge commit с два родителя (превръщайки историята в объркана релсова мрежа), или изисква разработчиците ръчно да правят rebase и да се състезават с други инженери, които изпращат към main.
Вдъхновени от модела Changelist (CL) на google3, всяко работно пространство в нашата архитектура представлява единична атомарна промяна спрямо trunk снимката. Докато работи локално в ws/alice/optimize-framing, разработчикът или агентът може да направи десет инкрементални commit-а (wip, fix tests, lint). Когато промяната е готова за приемане, сървърът свива делтата на работното пространство в една линейна trunk ревизия, без никога да извлича работно дърво на CI работника.
Ето как скриптът за приемане (land.sh) се изпълнява в паметта, използвайки plumbing командите на Git 2.40+:
1# 1. Определяне на корenния 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. Изчисляване на обединеното корenно дърво в паметта, без 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-и, направени в клона на работното пространство, скриптът сравнява само три хеша на дървета: оригиналната базова снимка (
$BASE_TREE), текущата trunk снимка ($MAIN_TREE) и крайното работно дърво ($WORK_TREE). - Ако
refs/trunk/mainе напреднал в//web/blog, докато Alice редактира//net/relay,git merge-tree --write-treeизчислява комбинираното корenно дърво$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. Четиринивова видимост на домейните
Корenното дърво е разделено на четири нива на видимост:
| Префикс на директория | Ниво | Разрешени импорти | Забранени импорти | 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 сървър
Не е необходим специален 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.
Code review без триене: ефимерни PR-и и diff-ове на снимки
Тъй като refs/heads/main съдържа само контролната равнина, а refs/trunk/main е commit без родители, отварянето на стандартен GitHub Pull Request директно от клона на работното пространство към main би се опитало да сравнява несвързани истории. Решихме това с три работни процеса за review, съобразени с различните видове промени:
- Мигновени diff-ове между две точки на работното пространство (
git ws-save+ Edge Diff Router) Изпращането на работно пространство сgit ws-saveизлагаrefs/ws/<actor>/<id>през лек edge worker. Този worker разрешаваrefs/trunk/mainи референцията на работното пространство до техните commit SHA и пренасочва към сравнителния изглед с две точки на GitHub (/compare/<trunk-sha>..<ws-sha>). При малки кръпки или промени, генерирани от агенти, рецензентите могат да преглеждат diff-а и да кликнат Land директно в уеб интерфейса. - Ефимерни GitHub Pull Request-и (
git ws-pr) За преглед ред по ред в GitHub,git ws-prизпраща корenния snapshot commit ($(git rev-list --max-parents=0 HEAD)) къмrefs/heads/base/<id>и върха на работното пространство къмrefs/heads/review/<id>. След това отваря GitHub Pull Request, който сравняваreview/<id>сbase/<id>. Тъй като базовият клон е точната снимка, от която е отклонено работното пространство, таблицата Files changed в GitHub показва само точните редове, променени в работното пространство. Когато рецензентът кликне Merge pull request, webhook повишава head commit доrefs/heads/land/<id>, изпълнява конвейера за приемане в паметта и автоматично изтрива кактоrefs/heads/base/<id>, така иrefs/heads/review/<id>. - PR-и към външни огледала Open-source сътрудниците никога не трябва да учат работния процес на монорепото. Те правят fork и отварят pull request-и към самостоятелни огледални хранилища, както обикновено.
Двупосочна синхронизация със сложност O(delta) и отделни production клони
Много организации публикуват отделни пакети като самостоятелни GitHub хранилища, така че външните потребители да могат да изпълнят go get, cargo install или да направят fork на един проект, без да докосват монорепото. Някои проекти, като статичните блогове или уеб приложенията с контрол на издаването, поддържат и отделен клон production до main.
Нашият енджин за синхронизация на Starlark обработва както изходящите експорти, така и входящите приноси за време O(delta), пропорционално на файловете, променени в commit, а не на размера на монорепото:
- Splice на индекса без blob обекти: При вмъкване или изваждане на поддървото на огледалното хранилище от корenното дърво на монорепото, енджинът задава
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 работници да изпълняват тестовете на проекта върху огледалото. Всеки workflow в конфигурацията на Starlark се насочва към изричен клон, напримерmain, което запазва контролите за издаване при хранилища, които внедряват от отделен клонproduction. Приемането в монорепото се синхронизира с клонаmainна огледалото, а автоматизираните commit-и с метаданни или преводи вmainсе синхронизират обратно къмrefs/trunk/mainчрез webhook. Повишаването наmainдоproductionостава изричната стъпка на издаване.
Заключителни мисли
Изграждането на монорепо с висока скорост не изисква отказ от стандартния Git или експлоатация на сложен флот от специализирана инфраструктура. Тази архитектура третира обектното хранилище на Git като Content-Addressable Store, отделя refs/heads/main от refs/trunk/main и refs/trunk/history и слива дървета в паметта с git merge-tree --write-tree. В резултат както човешките инженери, така и автономните агенти за писане на код могат да клонират работно пространство за под секунда и да се мащабират ефективно при стотици проекти.