GoSuda

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

By Lemon Mint views ...

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

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

Под повърхността всяка стъпка извършва само минималната необходима работа:

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

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

  1. Мигновени 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 директно в уеб интерфейса.
  2. Ефимерни 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>.
  3. 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. В резултат както човешките инженери, така и автономните агенти за писане на код могат да клонират работно пространство за под секунда и да се мащабират ефективно при стотици проекти.