GoSuda

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

By Lemon Mint views ...

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

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

Всяка стъпка извлича само това, от което се нуждае:

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

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

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