---
id: 773766eac9e6ff3c46bd686d78539aed
author: Lemon Mint
title: 'Преосмисляне на Git monorepo: CAS-Native архитектура за хора и Coding Agents'
description: Как да изградим монорепозитории, които са без демони и с CAS като базова архитектура, върху стандартен Git, използвайки транки без родители (parentless trunk snapshots), изолирани референции за работни пространства, сливания на дървета в паметта (in-memory merge trees), йерархични OWNERS файлове и синхронизация на огледала със сложност O(delta).
language: bg
date: 2026-10-30T01:17:15.292299Z
path: /blog/posts/rethinking-the-git-monorepo-a-cas-native-architecture-for-humans-and-coding-agents-zfd3d7931
category: lowlevel
---

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

Разпределянето на тези задачи между отделни референции в едно хранилище премахва бутилкова шийка:

```
refs/heads/main               Само контролна равнина (~20 KB: правила, .gitconfig, конфигурация за синхронизация, скриптове)
refs/trunk/main               Snapshot commit без родители, който сочи към последното обединено корен дърво
refs/trunk/history            Линейна верига от родително свързани commit-и, споделяща точния корен tree SHA-1
refs/ws/<actor>/<id>          Изолирани снимки на работни пространства (changelists) за всеки разработчик или агент
refs/ws/cl/<change-id>/<tip>  Неизменяеми patchsets: всеки връх на промяна, изпратен за review
refs/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 хеш**:

```bash
# Snapshot commit без родител за незабавни checkout-и на работно пространство с дълбочина 1
SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")

# Commit с родител за линейни git log и git blame
HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")

git push --atomic origin \
  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
  "${SNAP}:refs/trunk/main" \
  "${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 и псевдонимите на хранилището:

```bash
# 1. Клониране на 20 KB контролната равнина с филтриране на blob обекти и включен sparse режим
git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
cd monorepo

# 2. Зареждане на псевдонимите на хранилището и прикачване на refs/trunk/*
git config include.path ../.gitconfig
git trunk-init

# 3. Деклариране на domain директориите, необходими за тази задача
git sparse-checkout set net/relay ipc/codec

# 4. Създаване на изолирано работно пространство, базирано на най-новия trunk snapshot
git 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+:

```bash
# 1. Определяне на корен snapshot commit, от който е клонирало работното пространство
BASE=$(git rev-list --max-parents=0 "$WORK_SHA")

BASE_TREE=$(git rev-parse "${BASE}^{tree}")
MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")

# 2. Изчисляване на обединеното корен дърво в паметта без checkout на работно дърво
if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
  NEW_TREE="$WORK_TREE"
elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
  NEW_TREE=$(head -n1 <<<"$OUT")
else
  echo "Merge conflict detected; run git ws-sync to rebase." >&2
  exit 1
fi
```

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