GoSuda

Przemyślenie monorepozytorium Git: architektura natywna dla CAS dla ludzi i agentów kodujących

By Lemon Mint views ...

Każda rozwijająca się organizacja inżynierska prędzej czy później staje przed tym samym rozwidleniem dróg. Na początku, gdy pracuje się z dziesiątkami małych repozytoriów, życie wydaje się proste. Każda biblioteka ma własny tracker zgłoszeń, własny potok CI oraz git clone, który kończy się, zanim zdążysz upić łyk kawy. Potem nadchodzi podatek. Zmieniasz strukturę w kluczowym pakiecie sieciowym i resztę popołudnia spędzasz na otwieraniu pull requestów z podbiciem wersji w sześciu zależnych repozytoriach, czekając, aż kolejki CI opróżnią się w porządku topologicznym.

Przeniesienie wszystkiego do tradycyjnego monorepozytorium Git wymienia problemy koordynacji na problemy fizyczne. Wraz z pogłębianiem się historii commitów i gromadzeniem się dużych plików binarnych w poprzednich rewizjach git fetch zwalnia. Katalogi robocze puchną do gigabajtów. Wkrótce ktoś proponuje uruchomienie niestandardowego demona wirtualnego systemu plików, dedykowanego serwera przeglądu kodu lub floty zdalnych wykonawców budowania, aby tylko utrzymać codzienną pracę na znośnym poziomie.

Gdy do zespołu dołączają autonomiczni agenci programistyczni, oba tryby awarii się nasilają. Polirepozytoria pozbawiają agenta widoczności wywołań między projektami i zmuszają go do jonglowania poświadczeniami między repozytoriami. Tradycyjne monorepozytoria zalewają dysk kontenera agenta i przepustowość sieci przy każdej inicjalizacji zadania, a równoległe agenty działające na tej samej gałęzi tworzą niekończące się wyścigi rebase.

Chcieliśmy trzeciej opcji. Inspirowani architekturą google3 zadaliśmy proste pytanie: Czy możemy uzyskać atomowe zmiany na jednym pniu, sparse onboarding w czasie poniżej sekundy, hierarchiczne zarządzanie OWNERS oraz samodzielne lustra open source, korzystając wyłącznie ze standardowej infrastruktury Git i tanich obiektowych magazynów danych?

Odpowiedź brzmi tak, i nie wymagała ani niestandardowego demona systemu plików, ani zewnętrznego urządzenia do przeglądu. Wymagała jedynie użycia Git do tego, czym jest on naprawdę pod spodem: Merkle-tree Content-Addressable Store (CAS).

Błędne przekonanie u podstaw wolnych monorepozytoriów Git

Gdy deweloperzy narzekają, że Git nie skaluje się do dużych monorepozytoriów, rzadko trafiają na ograniczenie bazy obiektów Git. W .git/objects Git przechowuje trzy niezmienne prymitywy indeksowane skrótem zawartości SHA-1 (lub SHA-256):

  1. blob: Surowa zawartość bajtowa pojedynczego pliku.
  2. tree: Listing katalogu, który mapuje nazwy plików i uprawnienia na skróty blob lub podrzędnych tree.
  3. commit: Niewielki nagłówek tekstowy (zazwyczaj 200 do 300 bajtów) wskazujący na jeden korzeniowy skrót tree, zero lub więcej skrótów rodzicielskich commit oraz metadane autora.

Jako content-addressable store ta konstrukcja jest niezwykle wydajna. Jeśli monorepozytorium zawiera 50 000 plików, a ty edytujesz jedną linię w jednym pliku, nowy commit ponownie wykorzystuje dokładnie te same skróty blob i tree dla pozostałych 49 999 plików.

Dlaczego więc tradycyjne monorepozytorium Git wydaje się ciężkie? Ponieważ konwencja przeciąża jedną referencję, refs/heads/main, trzema sprzecznymi zadaniami:

  • Przechowuje całe zunifikowane drzewo katalogów wszystkich projektów.
  • Ciągnie za sobą cały łańcuch historycznych commitów powiązanych przez rodziców aż do pierwszego dnia.
  • Znajduje się w refs/heads/*, czyli przestrzeni nazw, którą serwery Git ogłaszają i negocjują domyślnie przy każdym git clone i git fetch.

Za każdym razem, gdy deweloper lub runner CI pobiera refs/heads/main, klient i serwer negocjują osiągalność w grafie commitów przez tysiące historycznych rewizji. Jeśli ktoś przypadkowo commitował archiwum o rozmiarze 100 MB trzy lata temu i usunął je następnego dnia, jego blob nadal wisi na historycznym tree osiągalnym z refs/heads/main.

Podział przestrzeni nazw referencji na cztery warstwy

Protokół sieciowy Git ogłasza podczas standardowego klonowania tylko refs/heads/* i refs/tags/*. Referencje w niestandardowych przestrzeniach nazw, takich jak refs/trunk/* lub refs/ws/*, znajdują się w tej samej bazie obiektów na serwerze, korzystają z tej samej kompresji delta w packfile i mogą być jawnie pobierane lub wypychane, a jednocześnie nie generują żadnego narzutu przy domyślnych klonach.

Dzieląc odpowiedzialności między cztery przestrzenie nazw referencji w jednym repozytorium, eliminujemy wąskie gardło:

1refs/heads/main          Tylko płaszczyzna sterowania (~20 KB: reguły, .gitconfig, konfiguracja synchronizacji, skrypty)
2refs/trunk/main          Commit-snapshot bez rodziców wskazujący na najnowsze zunifikowane korzeniowe drzewo
3refs/trunk/history       Liniowy łańcuch commitów powiązanych przez rodziców, współdzielący dokładnie ten sam SHA-1 korzeniowego drzewa
4refs/ws/<actor>/<id>     Izolowane snapshoty przestrzeni roboczych (changelists) dla każdego dewelopera lub agenta
5refs/heads/land/<id>     Efemeryczny wyzwalacz kolejki scalania, usuwany natychmiast po wylądowaniu

1. Płaszczyzna sterowania (refs/heads/main)

Domyślna gałąź nie zawiera kodu źródłowego aplikacji. Śledzi wyłącznie reguły działania repozytorium (AGENTS.md, README.md), współdzielony .gitconfig z aliasami przepływów pracy, konfigurację synchronizacji w Starlark (sync.star) oraz skrypty scalania po stronie serwera. Klonowanie domyślnej gałęzi przesyła około 20 KB i kończy się w milisekundach.

2. Snapshot pnia bez rodziców (refs/trunk/main)

refs/trunk/main wskazuje na kompletne, zunifikowane drzewo źródłowe całej organizacji (//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*), ale jego obiekt commit jest tworzony bez flagi -p z rodzicem.

Ponieważ refs/trunk/main nie ma rodziców (parents = []), jego pobranie nie wymaga negocjacji grafu commitów. Serwer Git nie przechodzi po historii; wysyła pojedynczy commit szczytowy i jego bieżące obiekty tree katalogu. Historyczne binaria lub pliki usunięte w zeszłym miesiącu nie są osiągalne z refs/trunk/main i nigdy nie są przesyłane.

3. Liniowa historia audytu (refs/trunk/history)

Deweloperzy nadal potrzebują git blame i git log podczas badania regresji. Za każdym razem, gdy zmiana zostaje zaakceptowana, skrypt scalania tworzy dwa obiekty commit wskazujące na dokładnie ten sam skrót SHA-1 korzeniowego drzewa $NEW_TREE:

 1# Commit-snapshot bez rodzica dla natychmiastowych checkoutów przestrzeni roboczej o głębokości 1
 2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
 3
 4# Commit z rodzicem dla liniowego git log i 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"

Czy utrzymywanie zarówno refs/trunk/main, jak i refs/trunk/history podwaja zajętość pamięci repozytorium? Ani trochę. Ponieważ oba commity odwołują się do identycznego skrótu $NEW_TREE, content-addressable store deduplikuje 100% obiektów tree i blob. Dodatkowy koszt pamięci snapshotu bez rodzica to pojedynczy nagłówek commit o rozmiarze 250 bajtów.

4. Izolowane przestrzenie robocze (refs/ws/<actor>/<id>)

Gałęzie w trakcie pracy są wypychane do refs/ws/<actor>/<id>, a nie do refs/heads/*. Setka agentów programistycznych może zapisywać punkty kontrolne co minutę, nie zaśmiecając list gałęzi i nie spowalniając git fetch dla ludzkich inżynierów.

Onboarding w pięciu poleceniach: jak działa sparse + blobless checkout

Przy tym układzie referencji nowy deweloper lub efemeryczny kontener agenta kodującego nie instaluje niestandardowego CLI ani sterownika FUSE. Onboarding wymaga pięciu standardowych poleceń Git:

 1# 1. Sklonuj 20-kilobajtową płaszczyznę sterowania z filtrowaniem blobów i włączonym trybem sparse
 2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
 3cd monorepo
 4
 5# 2. Załaduj aliasy repozytorium i podłącz refs/trunk/*
 6git config include.path ../.gitconfig
 7git trunk-init
 8
 9# 3. Zadeklaruj katalogi domenowe potrzebne do tego zadania
10git sparse-checkout set net/relay ipc/codec
11
12# 4. Utwórz izolowaną przestrzeń roboczą opartą na najnowszym snapshocie pnia
13git ws-new alice/optimize-framing

Pod spodem każdy krok wykonuje tylko minimum potrzebnej pracy:

  1. git clone --filter=blob:none --sparse pobiera 20-kilobajtową płaszczyznę sterowania z refs/heads/main.
  2. git trunk-init konfiguruje +refs/trunk/*:refs/remotes/origin/trunk/* i wykonuje pobieranie bez blobów. Git pobiera metadane tree dla refs/trunk/main (zazwyczaj kilkadziesiąt kilobajtów) i zero plików blob.
  3. git sparse-checkout set net/relay ipc/codec nakazuje indeksowemu filtrowi trybu cone w Git zmaterializować tylko katalog główny i te dwa poddrzewa.
  4. git ws-new przełącza na nową gałąź opartą na origin/trunk/main. W tym dokładnie momencie Git na żądanie pobiera tylko obiekty blob znajdujące się w net/relay i ipc/codec. Pozostałe 95% monorepozytorium nigdy nie jest pobierane na dysk.

Perspektywa: montowanie refs/trunk/main jako wirtualnego systemu plików (VFS)

Sparse checkout z klonami bez blobów obejmuje codzienne przepływy pracy bez dodatkowych narzędzi klienckich. Ponieważ refs/trunk/main jest już czystym drzewem Merkle w Content-Addressable Store, architektura stanowi też bezpośrednią podstawę dla opcjonalnego wirtualnego systemu plików (VFS) w przestrzeni użytkownika, realizowanego przez FUSE w Linuksie, FileProvider w macOS lub ProjFS w Windows:

  1. Natychmiastowe montowanie pełnego drzewa: Lekki lokalny demon montowania odczytuje korzeniowy obiekt tree z refs/trunk/main i rzutuje całą hierarchię katalogów monorepozytorium do katalogu roboczego w ciągu milisekund, eliminując potrzebę uruchamiania git sparse-checkout set.
  2. Pobieranie blobów na żądanie: Zawartość plików pozostaje wirtualna, dopóki kompilator, IDE lub agent nie wykona wywołania systemowego open(2) lub read(2). Demon pobiera wtedy odpowiedni blob o skrócie SHA-1 z punktu końcowego CAS i zapisuje go w pamięci podręcznej na lokalnym dysku NVMe.
  3. Nakładka copy-on-write dla przestrzeni roboczych: Lokalne edycje zapisują się w lekkiej górnej warstwie nakładki. Uruchomienie git ws-save haszuje tylko zmodyfikowane ścieżki w tej nakładce, zapisuje zaktualizowane obiekty blob i tree oraz wypycha wynikowy commit do refs/ws/<actor>/<id>, bez skanowania niezmienionych katalogów.

Model changelist na czystym Git: lądowanie w pamięci po stronie serwera

W tradycyjnym przepływie Git scalenie gałęzi funkcjonalnej albo tworzy commit scalający z dwoma rodzicami (zamieniając historię w splątaną stację rozrządową), albo wymaga, aby deweloperzy ręcznie wykonywali rebase i ścigali się z innymi inżynierami wypychającymi zmiany do main.

Inspirowany modelem Changelist (CL) z google3, każdy workspace w naszej architekturze reprezentuje pojedynczą atomową zmianę względem snapshotu pnia. Podczas pracy lokalnie w ws/alice/optimize-framing deweloper lub agent może wykonać dziesięć przyrostowych commitów (wip, fix tests, lint). Gdy zmiana jest gotowa do wylądowania, serwer zwija deltę przestrzeni roboczej w jedną liniową rewizję pnia, bez checkoutu katalogu roboczego na runnerze CI.

Oto jak skrypt lądowania (land.sh) wykonuje się w pamięci przy użyciu plumbingu Git 2.40+:

 1# 1. Zidentyfikuj korzeniowy commit-snapshot, z którego odgałęziła się przestrzeń robocza
 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. Oblicz scalone korzeniowe drzewo w pamięci bez checkoutu katalogu roboczego
 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

Przyjrzyj się uważnie krokowi 2:

  • Niezależnie od liczby lokalnych commitów wykonanych na gałęzi przestrzeni roboczej, skrypt porównuje tylko trzy skróty drzew: oryginalny snapshot bazowy ($BASE_TREE), aktualny snapshot pnia ($MAIN_TREE) oraz końcowe drzewo przestrzeni roboczej ($WORK_TREE).
  • Jeśli refs/trunk/main przesunął się w //web/blog, podczas gdy Alice edytowała //net/relay, git merge-tree --write-tree oblicza połączone korzeniowe drzewo $NEW_TREE bezpośrednio w bazie obiektów Git w milisekundach. Na system plików runnera nie są zapisywane żadne pliki.
  • Na koniec git push --atomic z --force-with-lease aktualizuje refs/trunk/main, dopisuje jeden liniowy commit do refs/trunk/history i usuwa wyzwalacz lądowania refs/heads/land/<id> w jednej transakcji.

Egzekwowanie granic architektury: czterostopniowa widoczność i hierarchiczne OWNERS

Konsolidacja kodu w jednym repozytorium działa tylko wtedy, gdy granice między domenami są egzekwowane mechanicznie, zanim commit zostanie zaakceptowany. Zaraz po tym, jak git merge-tree --write-tree wygeneruje $NEW_TREE (a przed tym, jak git push --atomic zaktualizuje pień), nasz silnik polityk uruchamia dwa sprawdzenia o złożoności O(delta) na wyniku git diff-tree "$MAIN_TREE" "$NEW_TREE".

1. Czterostopniowa widoczność domen

Korzeniowe drzewo jest podzielone na cztery poziomy widoczności:

Prefiks kataloguPoziomDozwolone importyZabronione importyBramka OWNERS
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Publiczna produkcjaPubliczna produkcja, //third_party/*//corp/*, //experimental/*Egzekwowana
//third_party/*Zewnętrzny dostawcaSamodzielne lub //third_party/*Wszystkie domeny inne niż third_partyEgzekwowana
//corp/*Wewnętrzny prywatnyPubliczna produkcja, //corp/*, //third_party/*//experimental/*Egzekwowana
//experimental/<actor>/*PiaskownicaDowolny pakiet niebędący corp//corp/*Zwolniona (automatycznie zatwierdzana)

Jeśli publiczny pakiet w //web/* próbuje zaimportować wewnętrzny pakiet z //corp/* lub niezakończony prototyp z //experimental/*, zadanie lądowania zostaje przerwane, zanim dotknie refs/trunk/main.

2. Hierarchiczna weryfikacja OWNERS bez serwera Gerrit

Nie potrzebujesz dedykowanej instancji Gerrit, aby egzekwować pliki OWNERS na poziomie katalogów z regułami dziedziczenia:

  • Dla każdej ścieżki zmodyfikowanej między $MAIN_TREE a $NEW_TREE weryfikator przechodzi w górę od katalogu pliku aż do //OWNERS.
  • Każdy plik OWNERS może zadeklarować set noparent, aby zablokować dziedziczone zatwierdzenia z katalogów nadrzędnych, lub per-file *.sql=@dba-team, aby zawęzić zatwierdzenia do globu nazwy pliku.
  • Niezmiennik przeciwdziałania manipulacjom: Istniejące pliki OWNERS są zawsze odczytywane z $MAIN_TREE (aktualnego drzewa pnia), nigdy z $NEW_TREE. Nieautoryzowany autor nie może przyznać sobie uprawnień, edytując OWNERS w tym samym commicie przestrzeni roboczej.
  • Wyłączenie dla piaskownicy: Każda ścieżka w //experimental/<actor>/* omija sprawdzenia OWNERS, dzięki czemu inżynierowie i agenci mogą swobodnie prototypować za pomocą git ws-exp <actor> <project>, a następnie przenieść pakiet do domeny produkcyjnej poleceniem git ws-graduate.

Przegląd kodu bez tarcia: efemeryczne PR-y i diffy snapshotów

Ponieważ refs/heads/main przechowuje tylko płaszczyznę sterowania, a refs/trunk/main jest commitem bez rodzica, otwarcie standardowego Pull Requesta na GitHubie bezpośrednio z gałęzi przestrzeni roboczej do main próbowałoby porównać niezwiązane historie. Rozwiązaliśmy to, stosując trzy przepływy przeglądu dostosowane do różnych rodzajów zmian:

  1. Natychmiastowe diffy dwukropkowe przestrzeni roboczej (git ws-save + Edge Diff Router): Wypchnięcie przestrzeni roboczej poleceniem git ws-save udostępnia refs/ws/<actor>/<id> przez lekkiego workera brzegowego. Ten worker rozwiązuje refs/trunk/main oraz referencję przestrzeni roboczej do ich SHA commitów i przekierowuje do widoku porównania dwukropkowego GitHub (/compare/<trunk-sha>..<ws-sha>). W przypadku małych poprawek lub zmian wygenerowanych przez agentów recenzenci mogą sprawdzić diff i kliknąć Land bezpośrednio w interfejsie webowym.
  2. Efemeryczne Pull Requesty na GitHubie (git ws-pr): Do przeglądu linia po linii na GitHubie git ws-pr wypycha korzeniowy commit-snapshot ($(git rev-list --max-parents=0 HEAD)) do refs/heads/base/<id>, a wierzchołek przestrzeni roboczej do refs/heads/review/<id>. Następnie otwiera Pull Request porównujący review/<id> z base/<id>. Ponieważ gałąź bazowa jest dokładnie snapshotem, z którego odgałęziła się przestrzeń robocza, zakładka Files changed na GitHubie pokazuje tylko te linie, które zostały zmodyfikowane w przestrzeni roboczej. Gdy recenzent kliknie Merge pull request, webhook awansuje commit szczytowy do refs/heads/land/<id>, uruchamia potok lądowania w pamięci i automatycznie usuwa obie gałęzie refs/heads/base/<id> i refs/heads/review/<id>.
  3. Pull Requesty do zewnętrznych luster: Współtwórcy open source nigdy nie muszą uczyć się przepływu monorepozytorium. Tworzą forki i otwierają pull requesty do samodzielnych repozytoriów-luster w zwykły sposób.

Dwukierunkowa synchronizacja o złożoności O(delta) i oddzielne gałęzie produkcyjne

Wiele organizacji publikuje pojedyncze pakiety jako samodzielne repozytoria GitHub, aby zewnętrzni użytkownicy mogli wykonać go get, cargo install lub sforkować pojedynczy projekt bez ingerencji w monorepozytorium. Niektóre projekty, na przykład statyczne blogi lub aplikacje webowe z bramkami wydań, utrzymują też oddzielną gałąź production obok main.

Nasz silnik synchronizacji w Starlark obsługuje zarówno eksporty wychodzące, jak i wkłady przychodzące w czasie O(delta), proporcjonalnym do liczby plików zmienionych w commicie, a nie do rozmiaru monorepozytorium:

  • Splatanie indeksu bez blobów: Podczas wstawiania lub wyjmowania poddrzewa repozytorium-lustra do/z korzeniowego drzewa monorepozytorium silnik ustawia GIT_NO_LAZY_FETCH=1 i uruchamia git update-index --index-info, a następnie git write-tree --missing-ok. Niezmienione poddrzewa i bloby są wskazywane wyłącznie przez ich skróty SHA-1, bez pobierania na runner CI.
  • Wstępne pobieranie blobów tylko dla delt: Przed wypchnięciem wyeksportowanego commita do samodzielnego lustra skrypt uruchamia git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree" i pobiera wstępnie tylko stare i nowe bloby zmodyfikowanych plików poprzez git cat-file --batch. Git może wtedy obliczyć cienki pakiet delta i wypchnąć go do repozytorium-lustra w ciągu sekund.
  • Zachowanie przepływów pracy i gałęzi wydań: Podczas eksportu wychodzącego synchronizator zachowuje katalog .github/workflows/ każdego samodzielnego repozytorium, dzięki czemu publiczne runnery CI wykonują testy projektu na lustrze. Każdy workflow w konfiguracji Starlark wskazuje jawną gałąź, taką jak main, co zachowuje bramki wydań dla repozytoriów wdrażanych z oddzielnej gałęzi production. Lądowania w monorepozytorium synchronizują się z gałęzią main lustra, a zautomatyzowane commity metadanych lub tłumaczeń na main synchronizują się z powrotem do refs/trunk/main przez webhook. Awansowanie main do production pozostaje jawnym krokiem wydania.

Uwagi końcowe

Budowanie monorepozytorium o wysokim tempie pracy nie wymaga porzucania standardowego Git ani obsługi złożonej floty niestandardowych serwerów infrastruktury. Ta architektura traktuje magazyn obiektów Git jak Content-Addressable Store, oddziela refs/heads/main od refs/trunk/main i refs/trunk/history oraz scala drzewa w pamięci za pomocą git merge-tree --write-tree. W rezultacie zarówno ludzcy inżynierowie, jak i autonomiczni agenci programistyczni mogą sklonować przestrzeń roboczą w mniej niż sekundę i skalować się czysto w setkach projektów.