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

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):
blob: Surowa zawartość bajtowa pojedynczego pliku.tree: Listing katalogu, który mapuje nazwy plików i uprawnienia na skrótybloblub podrzędnychtree.commit: Niewielki nagłówek tekstowy (zazwyczaj 200 do 300 bajtów) wskazujący na jeden korzeniowy skróttree, zero lub więcej skrótów rodzicielskichcommitoraz 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żdymgit cloneigit 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:
git clone --filter=blob:none --sparsepobiera 20-kilobajtową płaszczyznę sterowania zrefs/heads/main.git trunk-initkonfiguruje+refs/trunk/*:refs/remotes/origin/trunk/*i wykonuje pobieranie bez blobów. Git pobiera metadanetreedlarefs/trunk/main(zazwyczaj kilkadziesiąt kilobajtów) i zero plików blob.git sparse-checkout set net/relay ipc/codecnakazuje indeksowemu filtrowi trybu cone w Git zmaterializować tylko katalog główny i te dwa poddrzewa.git ws-newprzełącza na nową gałąź opartą naorigin/trunk/main. W tym dokładnie momencie Git na żądanie pobiera tylko obiektyblobznajdujące się wnet/relayiipc/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:
- Natychmiastowe montowanie pełnego drzewa: Lekki lokalny demon montowania odczytuje korzeniowy obiekt
treezrefs/trunk/maini rzutuje całą hierarchię katalogów monorepozytorium do katalogu roboczego w ciągu milisekund, eliminując potrzebę uruchamianiagit sparse-checkout set. - Pobieranie blobów na żądanie: Zawartość plików pozostaje wirtualna, dopóki kompilator, IDE lub agent nie wykona wywołania systemowego
open(2)lubread(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. - Nakładka copy-on-write dla przestrzeni roboczych: Lokalne edycje zapisują się w lekkiej górnej warstwie nakładki. Uruchomienie
git ws-savehaszuje tylko zmodyfikowane ścieżki w tej nakładce, zapisuje zaktualizowane obiektyblobitreeoraz wypycha wynikowy commit dorefs/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/mainprzesunął się w//web/blog, podczas gdy Alice edytowała//net/relay,git merge-tree --write-treeoblicza połączone korzeniowe drzewo$NEW_TREEbezpośrednio w bazie obiektów Git w milisekundach. Na system plików runnera nie są zapisywane żadne pliki. - Na koniec
git push --atomicz--force-with-leaseaktualizujerefs/trunk/main, dopisuje jeden liniowy commit dorefs/trunk/historyi usuwa wyzwalacz lądowaniarefs/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 katalogu | Poziom | Dozwolone importy | Zabronione importy | Bramka OWNERS |
|---|---|---|---|---|
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/* | Publiczna produkcja | Publiczna produkcja, //third_party/* | //corp/*, //experimental/* | Egzekwowana |
//third_party/* | Zewnętrzny dostawca | Samodzielne lub //third_party/* | Wszystkie domeny inne niż third_party | Egzekwowana |
//corp/* | Wewnętrzny prywatny | Publiczna produkcja, //corp/*, //third_party/* | //experimental/* | Egzekwowana |
//experimental/<actor>/* | Piaskownica | Dowolny 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_TREEa$NEW_TREEweryfikator przechodzi w górę od katalogu pliku aż do//OWNERS. - Każdy plik
OWNERSmoże zadeklarowaćset noparent, aby zablokować dziedziczone zatwierdzenia z katalogów nadrzędnych, lubper-file *.sql=@dba-team, aby zawęzić zatwierdzenia do globu nazwy pliku. - Niezmiennik przeciwdziałania manipulacjom: Istniejące pliki
OWNERSsą zawsze odczytywane z$MAIN_TREE(aktualnego drzewa pnia), nigdy z$NEW_TREE. Nieautoryzowany autor nie może przyznać sobie uprawnień, edytującOWNERSw tym samym commicie przestrzeni roboczej. - Wyłączenie dla piaskownicy: Każda ścieżka w
//experimental/<actor>/*omija sprawdzeniaOWNERS, 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 poleceniemgit 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:
- Natychmiastowe diffy dwukropkowe przestrzeni roboczej (
git ws-save+ Edge Diff Router): Wypchnięcie przestrzeni roboczej poleceniemgit ws-saveudostępniarefs/ws/<actor>/<id>przez lekkiego workera brzegowego. Ten worker rozwiązujerefs/trunk/mainoraz 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. - Efemeryczne Pull Requesty na GitHubie (
git ws-pr): Do przeglądu linia po linii na GitHubiegit ws-prwypycha korzeniowy commit-snapshot ($(git rev-list --max-parents=0 HEAD)) dorefs/heads/base/<id>, a wierzchołek przestrzeni roboczej dorefs/heads/review/<id>. Następnie otwiera Pull Request porównującyreview/<id>zbase/<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 dorefs/heads/land/<id>, uruchamia potok lądowania w pamięci i automatycznie usuwa obie gałęzierefs/heads/base/<id>irefs/heads/review/<id>. - 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=1i uruchamiagit update-index --index-info, a następniegit 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 poprzezgit 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ą jakmain, co zachowuje bramki wydań dla repozytoriów wdrażanych z oddzielnej gałęziproduction. Lądowania w monorepozytorium synchronizują się z gałęziąmainlustra, a zautomatyzowane commity metadanych lub tłumaczeń namainsynchronizują się z powrotem dorefs/trunk/mainprzez webhook. Awansowaniemaindoproductionpozostaje 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.