Большие репозитории и монорепо: submodules, subtree, sparse-checkout, LFS
Момент, когда репозиторий «стал большим», обычно замечают не по метрикам, а по раздражению: git status думает восемь секунд, клон на новой машине идёт двадцать минут, а после того как дизайнер положил в репозиторий десяток PSD-файлов, каждый git clone тянет три гигабайта. Дальше начинается стадия народных средств — git gc --aggressive, --depth=1, «давайте разнесём на пять репозиториев» — и половина из них не помогает, потому что лечит не ту ось.
Полезная новость в том, что все эти симптомы выводятся из модели данных, разобранной в «Внутри Git», почти механически. Репозиторий — это контентно-адресуемое хранилище объектов плюс DAG коммитов. У него ровно три независимых измерения роста, и у каждой команды Git своя зависимость от каждого измерения. Как только вы понимаете, какое измерение выросло и какая команда от него страдает, выбор инструмента перестаёт быть вопросом вкуса.
Эта статья — про механику. Она не пересказывает обзорную «Git» и не повторяет «Как создать хороший Pull Request»; процессная сторона монорепо (кто владеет кодом, как устроено ревью в масштабе) — в «Совместной работе», а выбор ветвления под монорепо — в «Стратегиях ветвления».
Три оси роста
Возьмём одну версию одного файла в одном коммите — назовём это клеткой. Тогда весь репозиторий укладывается в прямоугольник: по одной оси коммиты, по другой пути, а «толщина» клетки — размер содержимого. Полный git clone забирает весь прямоугольник целиком, включая все версии всех файлов за все годы.
- Глубина истории — сколько коммитов и, следовательно, сколько объектов в графе. Растёт линейно со временем и числом разработчиков. Бьёт по
clone,fetch,log,blame. - Ширина дерева — сколько путей существует в одном коммите. Растёт при слиянии проектов в один репозиторий. Бьёт по
status,checkout,merge,grep— то есть по операциям, обходящим рабочее дерево и индекс. - Вес содержимого — сколько байт в блобах. Растёт нелинейно, если в репозиторий попадают бинарники: дельта-компрессия между двумя версиями PNG или ZIP практически не работает, каждая версия ложится полным весом и остаётся навсегда, потому что объекты неизменяемы.
Оси независимы. Репозиторий Linux глубокий (миллион с лишним коммитов), но узкий и текстовый. Репозиторий мобильной игры мелкий по истории, но весит сотни гигабайт из-за ассетов. Корпоративный монорепо широкий: сто тысяч файлов в одном коммите. Лекарства у них разные, и применение чужого лекарства не даст ничего.
Модель стоимости команд
Полезно держать в голове грубые оценки — они объясняют, почему одни команды ускоряются от sparse-checkout, а другие нет.
| Команда | Что доминирует | Оценка |
|---|---|---|
git status |
обход индекса и lstat по рабочему дереву |
O(файлов в рабочем дереве) |
git status с fsmonitor |
опрос демона о списке изменённых | O(изменённых файлов) |
git status с sparse-index |
обход только раскрытой части индекса | O(файлов в конусе) |
git checkout <ветка> |
diff двух деревьев + запись файлов | O(различающихся путей) |
git clone |
передача всех достижимых объектов | O(объектов в истории) |
git clone --filter=blob:none |
передача коммитов и деревьев | O(коммитов + деревьев) |
git log -- path |
обход графа с упрощением истории | O(коммитов), с Bloom-фильтрами → O(коммитов, тронувших path) |
git merge |
трёхпутевое слияние по различающимся путям | O(различающихся путей) |
Отсюда сразу видно: sparse-checkout ничего не сделает с медленным clone (он про ось X, а клон про ось Y и Z), а --depth=1 не ускорит status в широком репозитории.
Сначала измерить
Никогда не начинайте с лечения. Три минуты диагностики экономят неделю неправильных решений.
# сколько объектов и сколько это весит на диске
$ git count-objects -vH
count: 1423
size: 9.21 MiB
in-pack: 1842301
packs: 14
size-pack: 3.72 GiB
# глубина истории и ширина дерева
$ git rev-list --all --count
94218
$ git ls-files | wc -l
128744
# .git против рабочего дерева
$ du -sh .git . --exclude=.git
3.8G .git
1.1G .
size-pack 3.7 ГиБ при рабочем дереве в 1.1 ГиБ — это сигнал: в истории лежит что-то, чего в текущем срезе нет. Ищем самые тяжёлые блобы за всю историю:
# топ самых больших объектов за всю историю, с путями
$ git rev-list --objects --all |
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
awk '$1 == "blob" { print $3, $4 }' | sort -rn | head -5
251658240 design/hero.psd
198443008 design/hero.psd
187301888 assets/intro.mov
94371840 vendor/sdk/android-ndk.zip
Обратите внимание: design/hero.psd встречается дважды с разными размерами — это две версии одного файла, и в упакованном виде они занимают почти столько же, сколько по отдельности, потому что дельта между двумя PSD бесполезна. Именно так репозиторий «на пару сотен мегабайт кода» превращается в четыре гигабайта.
Второй источник правды — что реально занимает место на диске после упаковки: git verify-pack -v .git/objects/pack/pack-*.idx | sort -k 3 -nr | head, где третий столбец — размер объекта, а четвёртый — размер после дельта-сжатия. И, наконец, где именно тратится время конкретной команды:
$ GIT_TRACE_PERFORMANCE=1 git status 2>&1 | tail -4
12:31:02.104 read-cache.c: performance: 0.812 s: read cache .git/index
12:31:09.377 preload-index.c: performance: 7.183 s: preload index
12:31:14.902 read-cache.c: performance: 5.520 s: refresh index
12:31:16.235 trace.c: performance: 14.204 s: git command: git status
Четырнадцать секунд, из них двенадцать — обход рабочего дерева. Это диагноз «ось X», и лечится он fsmonitor и sparse-index, а не --depth.
граф или блобы?"} B1 -->|"много коммитов"| B2["--filter=blob:none"] B1 -->|"тяжёлые бинарники"| B3["Git LFS или вынос артефактов"] B1 -->|"нужен один срез"| B4["--depth=1 --single-branch, только CI"] C --> C1{"Сколько файлов
в рабочем дереве?"} C1 -->|"десятки тысяч"| C2["core.fsmonitor + core.untrackedCache"] C1 -->|"сотни тысяч"| C3["sparse-checkout --cone + index.sparse"] D --> D1["commit-graph --changed-paths"] E --> E1{"Мусор в истории
или живые данные?"} E1 -->|"случайный дамп"| E2["git filter-repo"] E1 -->|"нужные бинарники"| E3["git lfs migrate import"] E1 -->|"давно не паковали"| E4["git maintenance start"]
Два способа склеить репозитории: submodule и subtree
Первый архитектурный вопрос: если кода много, держать его в одном репозитории или в нескольких? Git предлагает два механизма связывания, и они устроены принципиально по-разному — что становится очевидным, если посмотреть на объекты.
Submodule: указатель в дереве
В «Внутри Git» мы видели, что запись в дереве — это <режим> <тип> <хеш> <имя>. Обычные режимы: 100644 (файл), 100755 (исполняемый), 120000 (симлинк), 040000 (подкаталог). Есть четвёртый: 160000 — gitlink, запись, которая ссылается не на blob и не на tree, а на коммит другого репозитория.
$ git ls-tree HEAD
100644 blob 8ab686eafeb1f44702738c8b0f24f2567c36da6d README.md
100644 blob 2e9a1b3c4d5e6f708192a3b4c5d6e7f8091a2b3c .gitmodules
040000 tree 3ea9a1f27ea9a1f271ea9a1f27ea9a1f27ea9a1f2 src
160000 commit 5f2e3b1c4d6a7e8f9012a3b4c5d6e7f8091a2b3c vendor/protobuf
Это вся модель. Суперпроект хранит 41 байт: хеш коммита, и больше ничего. Ни объектов подмодуля, ни его истории — их нет в хранилище суперпроекта вообще. Где искать репозиторий по этому хешу, записано в отдельном версионируемом файле:
# .gitmodules
[submodule "vendor/protobuf"]
path = vendor/protobuf
url = https://github.com/protocolbuffers/protobuf.git
branch = 21.x
shallow = true
Из этого выводится всё поведение подмодулей, включая то, которое обычно считают загадочным:
- Почему в подмодуле detached HEAD. Суперпроект зафиксировал коммит, а не ветку.
git submodule updateделает буквальноgit checkout <хеш>внутри подмодуля. Ветка там появится, только если вы её потребуете (submodule.<name>.branch+git submodule update --remote). - Почему коллега получил «пустую папку». Клон суперпроекта тянет только gitlink. Объекты подмодуля надо забрать отдельно:
git submodule update --init --recursive. - Почему конфликт в подмодуле выглядит странно. Конфликтуют не файлы, а два разных хеша в одной записи дерева. Git не знает, какой «новее», — у него нет объектов, чтобы это выяснить.
Практический набор команд:
$ git submodule add -b 21.x https://github.com/protocolbuffers/protobuf.git vendor/protobuf
$ git clone --recurse-submodules --shallow-submodules --jobs 8 git@corp:app.git
$ git submodule update --init --recursive --jobs 8 # если забыли при клоне
# один раз включить и больше не вспоминать
$ git config --global submodule.recurse true # pull/checkout сами обходят подмодули
$ git config --global push.recurseSubmodules check # не дать запушить висячий указатель
$ git submodule status --recursive
5f2e3b1c4d6a7e8f9012a3b4c5d6e7f8091a2b3c vendor/protobuf (v21.12)
+9c1d0a3f7b2e5a6d8c9f0e1a2b3c4d5e6f708192 vendor/spdlog (v1.11.0-4-g9c1d0a3)
-0e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091 vendor/fmt
# обновить до свежего апстрима по ветке из .gitmodules
$ git submodule update --remote vendor/protobuf
$ git add vendor/protobuf && git commit -m "chore: bump protobuf to v21.14"
Префикс в git submodule status — это и есть состояние подмодуля, и его стоит выучить: пробел — всё совпадает; + — в рабочем дереве другой коммит, чем записан в индексе суперпроекта; - — не инициализирован; U — конфликт слияния.
Переход DANGLING → fatal — самая частая беда подмодулей и прямое следствие модели: вы закоммитили в суперпроект хеш, которого нет ни на одном сервере, потому что забыли запушить сам подмодуль. Локально всё работает, у коллеги — нет. Лечится привычкой push.recurseSubmodules on-demand или проверкой в CI.
Разрешение конфликта gitlink делается не текстовым редактором, а выбором коммита:
$ git diff
- Subproject commit 5f2e3b1c4d6a7e8f9012a3b4c5d6e7f8091a2b3c
+ Subproject commit 9c1d0a3f7b2e5a6d8c9f0e1a2b3c4d5e6f708192
# смотрим внутри подмодуля, какая из двух версий нужна, и выбираем явно
$ git -C vendor/protobuf log --oneline --left-right 5f2e3b1...9c1d0a3
$ git -C vendor/protobuf checkout 9c1d0a3 && git add vendor/protobuf
# удаление: состояние размазано по трём местам, значит и шага три
$ git submodule deinit -f vendor/protobuf # рабочее дерево и .git/config
$ git rm -f vendor/protobuf # gitlink и запись в .gitmodules
$ rm -rf .git/modules/vendor/protobuf # хранилище объектов подмодуля
Ещё одна деталь, объясняющая половину странностей: начиная с Git 1.7.8 .git внутри подмодуля — не каталог, а файл с одной строкой gitdir: ../../.git/modules/vendor/protobuf. Настоящее хранилище живёт в суперпроекте. Поэтому rm -rf vendor/protobuf не удаляет историю подмодуля, а git submodule absorbgitdirs умеет втянуть внутрь старые подмодули, у которых .git ещё каталог.
Subtree: файлы, а не указатели
Subtree решает ту же задачу противоположным способом: файлы чужого проекта физически лежат в вашем дереве, а его история либо приклеивается к вашей, либо схлопывается в один коммит.
# подключить внешний проект в подкаталог, схлопнув его историю
$ git subtree add --prefix=vendor/protobuf \
https://github.com/protocolbuffers/protobuf.git v21.12 --squash
git fetch https://github.com/protocolbuffers/protobuf.git v21.12
Added dir 'vendor/protobuf'
$ git log --oneline -3
8c1f0a2 Merge commit '3b7e21d' as 'vendor/protobuf'
3b7e21d Squashed 'vendor/protobuf/' content from commit 1a2b3c4
9d4c7e1 fix: таймаут в платежах
# обновиться до новой версии апстрима; отдать свои правки обратно
$ git subtree pull --prefix=vendor/protobuf <url> v21.14 --squash
$ git subtree push --prefix=vendor/protobuf git@github.com:myorg/protobuf.git my-fixes
# выделить подкаталог в отдельную историю (вынос модуля в свой репозиторий)
$ git subtree split --prefix=libs/auth -b extracted-auth
Механика subtree — надстройка над git read-tree --prefix и стратегией слияния subtree (см. таблицу стратегий в «Merge и rebase»): она умеет сопоставить дерево внешнего проекта с вашим подкаталогом и сделать обычное трёхпутевое слияние со сдвигом путей.
Ключевое отличие от подмодуля видно прямо на графе: история склеена в один DAG. Тот, кто клонирует репозиторий, получает всё сразу, без единой дополнительной команды и без знания слова «subtree». Цена — размер репозитория (чужой код лежит в вашей истории навсегда) и шумные merge-коммиты.
Честное сравнение
| Submodule | Subtree | Пакетный менеджер | |
|---|---|---|---|
| Что в истории | 41 байт gitlink | все файлы зависимости | строка в lock-файле |
| Клон «просто работает» | нет, нужен --recurse-submodules |
да | да, после install |
| Вес репозитория | не растёт | растёт на весь апстрим | не растёт |
| Атомарный коммит через границу | невозможен | возможен | невозможен |
| Отдать правку в апстрим | естественно, это обычный репозиторий | subtree push, работает, но неуклюже |
форк + PR |
| Кто должен что-то знать | все, кто клонирует | только тот, кто обновляет | все, но это привычно |
| Типичный отказ | висячий указатель, забытый push | «случайно откатили vendor/ при rebase» | конфликт версий транзитивных зависимостей |
Спор «submodule или subtree» почти всегда оказывается спором не про Git, а про владение кодом и релизный процесс — ровно как спор про rebase и merge. Три честных вопроса, которые его закрывают:
- Кто выпускает версии этой зависимости? Если у неё есть нормальные релизы и пакетный реестр — не используйте ни то ни другое. Обычная зависимость с версией в lock-файле лучше обоих механизмов: у неё есть семантика версий, разрешение конфликтов и аудит. Git-механизмы нужны там, где пакетного менеджера нет (C++ без Conan/vcpkg, ассеты, конфигурация инфраструктуры) или где нужен доступ к исходникам «прямо сейчас».
- Нужны ли атомарные изменения через границу? Если типичная задача меняет код и в основном проекте, и в библиотеке одновременно, любая граница репозиториев — источник боли: два PR, два ревью, окно несогласованности. Это главный аргумент за монорепо.
- Кто платит за забытую команду? Подмодуль перекладывает налог на каждого разработчика (и на CI) в обмен на маленький репозиторий. Subtree платит один раз размером репозитория и дальше не мешает никому.
Взять только часть: sparse-checkout
Допустим, решение принято: один большой репозиторий. Сто тысяч файлов, вы работаете с тремя тысячами. Не хочется ни выкачивать остальное, ни платить за него временем status.
Sparse-checkout — механизм, который позволяет иметь в рабочем дереве только часть путей, сохранив при этом целостность индекса и истории. Работает он через флаг skip-worktree в записи индекса: файл в индексе есть, а на диске его нет, и Git обязуется этот факт игнорировать.
# включаем и задаём конус путей (cone mode — режим по умолчанию с Git 2.37)
$ git sparse-checkout set apps/web libs/core libs/ui
$ git sparse-checkout list
apps/web
libs/core
libs/ui
# что реально записалось
$ cat .git/info/sparse-checkout
/*
!/*/
/apps/
!/apps/*/
/apps/web/
/libs/
!/libs/*/
/libs/core/
/libs/ui/
# индекс знает про всё, рабочее дерево — нет
$ git ls-files | wc -l
128744
$ find . -path ./.git -prune -o -type f -print | wc -l
3187
$ git ls-files -t | grep '^S' | head -2 # S = флаг skip-worktree
S apps/ios/Podfile
S assets/hero.psd
Обратите внимание на структуру файла шаблонов: «взять всё в корне, но не спускаться в подкаталоги, кроме перечисленных». Это и есть cone mode — ограничение, при котором шаблоны описываются каталогами, а не произвольными glob-выражениями. Ограничение введено ради скорости: произвольные шаблоны надо примерять к каждому пути (O(путей × шаблонов)), а конус проверяется хешом по префиксу каталога. Режим --no-cone с произвольными шаблонами существует, но объявлен устаревшим — на больших репозиториях он даёт ровно ту медленность, от которой убегали.
Sparse-index: почему одного sparse-checkout мало
Вот неприятный сюрприз: включили sparse-checkout, файлов на диске стало 3 тысячи вместо 128 тысяч, а git status быстрее не стал. Причина в модели: индекс всё ещё содержит все 128 тысяч записей, потому что индекс описывает коммит целиком, а не рабочее дерево. Git читает и перезаписывает мегабайты на каждой операции.
Решение — sparse-index: разрешить записи индекса указывать не только на файл, но и на целое дерево (режим 040000) для каталогов вне конуса.
$ git config index.sparse true
$ git sparse-checkout reapply
$ git ls-files --sparse | grep '/$' # записи-каталоги вместо тысяч файлов
apps/android/
apps/ios/
assets/
$ git ls-files --sparse | wc -l
3204 # вместо 128744
$ time git status
real 0m0.21s # было 14.2s
Записи-каталоги «раскрываются» лениво, когда команде действительно нужен путь снаружи конуса. Механику подробно описал GitHub в «Make your monorepo feel small with Git’s sparse index» — там же список команд, интегрированных со sparse-index (status, add, commit, checkout, merge, rebase, stash, diff); неинтегрированная команда просто раскроет индекс целиком, отработает медленно и корректно.
Типичные грабли sparse-checkout:
git addфайла вне конуса молча ничего не делает, аgit statusего не покажет. С Git 2.42 команды предупреждают об этом; проверять — черезgit status --sparseилиgit ls-files --sparse.- Слияние затрагивает пути вне конуса. Это нормально и работает: Git временно раскроет нужные записи. Но конфликт в файле, которого нет на диске, разрешать неудобно — придётся временно расширить конус.
- Сборка ищет файл, которого нет. Sparse-checkout требует, чтобы система сборки умела работать с частичным деревом; в монорепо это обычно Bazel, Pants, Nx или Turborepo, знающие граф зависимостей.
- Инструменты, ходящие по диску напрямую (линтеры, IDE-индексаторы, скрипты на
find), увидят усечённое дерево и могут выдать ложные ошибки.
Взять только часть объектов: partial clone и shallow clone
Sparse-checkout режет ось путей. Ось истории и ось содержимого режут другие механизмы — и их регулярно путают.
Shallow clone (--depth) обрезает граф: вы получаете N последних коммитов, а на границе стоят «привитые» (grafted) коммиты без родителей.
$ git clone --depth=1 --single-branch --branch main git@corp:monorepo.git
$ git log --oneline -2
3ba0c17 (grafted, HEAD -> main, origin/main) chore: bump deps
$ cat .git/shallow
3ba0c17f8e21b4d7a09c3f1e6b8d0a5c2f47e9b1
Что ломается сразу: git merge-base (общего предка может не быть в графе), git bisect, git blame дальше границы, git describe (не видит тегов), git log за пределами глубины. Что ломается неочевидно: сервер платит за shallow-клон дороже, чем за полный, потому что не может отдать заранее посчитанный pack с bitmap-индексом и вынужден считать особый набор объектов на лету. На популярном репозитории массовые shallow-клоны из CI — известный способ уронить сервер Git.
Partial clone (--filter) обрезает не граф, а содержимое: граф приходит целиком, а блобы скачиваются лениво по требованию.
$ git clone --filter=blob:none git@corp:monorepo.git # только коммиты и деревья
$ git clone --filter=blob:limit=1m git@corp:monorepo.git # мелкие блобы сразу, крупные лениво
$ git clone --filter=tree:0 git@corp:monorepo.git # минимальный клон под git log
$ git config --get-regexp 'remote.origin.*'
remote.origin.url git@corp:monorepo.git
remote.origin.promisor true
remote.origin.partialclonefilter blob:none
# каких объектов нет локально
$ git rev-list --objects --all --missing=print | grep '^?' | wc -l
1738402
Термин «promisor remote» означает: удалённый репозиторий пообещал отдать любой недостающий объект по запросу. Когда команде нужен отсутствующий блоб, Git делает синхронный fetch — и вот тут возникает главный практический эффект:
Практический вывод: partial clone превосходен для повседневной работы и опасен для операций, которые исторически перебирают много версий файлов (log -p по всей истории, blame старого файла, полный grep по всем ревизиям). Прогреть кеш заранее можно git fetch --refetch с другим фильтром или экспериментальной командой git backfill (Git 2.49+), которая докачивает недостающие блобы пакетами, а не по одному.
| Механизм | Что режет | История цела | Что ломает | Кому подходит |
|---|---|---|---|---|
--depth=1 |
коммиты | нет | merge-base, bisect, describe, blame | одноразовые сборки в CI |
--single-branch |
ссылки | частично | работу с другими ветками | CI, узкие задачи |
--filter=blob:none |
содержимое | да | скорость log -p, работу офлайн |
ежедневная разработка |
--filter=blob:limit=1m |
крупное содержимое | да | то же, но мягче | репозитории с редкими бинарниками |
--filter=tree:0 |
деревья и содержимое | да | почти всё, кроме log --oneline |
аналитика истории, зеркала |
| sparse-checkout | рабочее дерево | да | инструменты, ждущие полное дерево | монорепо |
Комбинировать можно и нужно — это и есть «монорепо-клон»:
$ git clone --filter=blob:none --sparse git@corp:monorepo.git
$ cd monorepo
$ git sparse-checkout set apps/web libs/core
$ git config index.sparse true
$ git config core.fsmonitor true
$ git maintenance start
Именно эту последовательность автоматизирует Scalar — инструмент, приехавший из проекта Microsoft VFS for Git и с Git 2.38 входящий в стандартную поставку: scalar clone <url> делает всё перечисленное сразу, а scalar register применяет те же настройки к существующему клону.
В CI применяются те же механизмы, только через настройки платформы:
# GitHub Actions: не тянуть блобы и взять только нужные пути
- uses: actions/checkout@v4
with:
filter: blob:none
sparse-checkout: |
apps/web
libs/core
fetch-depth: 0 # 0 = полная история; нужна для git describe и changelog
# GitLab CI
variables:
GIT_DEPTH: "50" # не 1: merge-base для diff с целевой веткой
GIT_STRATEGY: fetch # переиспользовать кеш раннера, не клонировать заново
GIT_SUBMODULE_STRATEGY: recursive
fetch-depth: 1 по умолчанию — источник классической загадки «локально git describe работает, в CI пишет fatal: No names found» и «сравнение с базовой веткой находит несуществующие изменения». Если пайплайн считает changelog, версию по тегам или diff относительно main, глубина нужна.
Вынести содержимое наружу: Git LFS
Бинарные файлы ломают Git не из-за размера как такового, а из-за неизменяемости объектов вкупе с бесполезностью дельт. Двадцать итераций макета по 240 МиБ — это почти пять гигабайт, которые останутся в истории навсегда и будут скачиваться при каждом клоне, даже если сегодня в рабочем дереве только последняя версия.
Git LFS решает это подменой: в истории лежит крошечный текстовый указатель, а содержимое хранится отдельно и скачивается только для тех ревизий, которые вы реально выкладываете на диск.
$ git lfs install # один раз на машину: прописать фильтры в ~/.gitconfig
$ git lfs track "*.psd" "*.mov"
$ cat .gitattributes
*.psd filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text
$ git add .gitattributes design/hero.psd && git commit -m "feat: макет главной"
# в истории — три строки, а не 240 МиБ
$ git cat-file -p HEAD:design/hero.psd
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
size 251658240
$ git cat-file -s HEAD:design/hero.psd
131
# что отслеживается и скачано ли содержимое (* = есть локально, - = только указатель)
$ git lfs ls-files
4d7a214614 * design/hero.psd
8b21f0e3c7 - assets/intro.mov
Механизм — стандартные фильтры Git из gitattributes(5), а не что-то особенное: clean запускается при git add и превращает 240 МиБ в указатель, smudge запускается при checkout и превращает указатель обратно, попутно скачивая объект. Отсюда все следствия:
.gitattributesверсионируется. Правило действует на той ревизии, где записано. Переключились на старую ветку до включения LFS — получите настоящие бинарники, и это правильно.- Файлы, закоммиченные до включения LFS, остаются в истории полным весом. Включение LFS не уменьшает существующий репозиторий ни на байт. Чтобы уменьшить — надо переписать историю:
# перенести все .psd и .mov из всей истории в LFS (история будет переписана!)
$ git lfs migrate import --include="*.psd,*.mov" --everything
migrate: Rewriting commits: 100% (9421/9421), done
$ git push --force-with-lease --all
Это полноценная перезапись истории со всеми последствиями (новые хеши у всех коммитов, сломанные ссылки в тикетах, обязательная координация с командой) — механика и техника безопасности разобраны в «Переписывании истории». Обратная операция — git lfs migrate export --include="*.psd".
Управление трафиком, местом и параллельной работой:
$ git lfs pull # догрузить содержимое для текущего среза
$ git lfs fetch --recent # + недавние ветки, по lfs.fetchrecentrefsdays
$ git lfs prune # выкинуть локальные объекты старых ревизий
$ git config lfs.fetchexclude "assets/video/**" # никогда не тянуть видео
$ GIT_LFS_SKIP_SMUDGE=1 git clone git@corp:app.git # клон с указателями, без содержимого
# блокировки: бинарник нельзя слить, поэтому нужен внешний протокол
# в .gitattributes: *.psd filter=lfs diff=lfs merge=lfs -text lockable
$ git lfs lock design/hero.psd
$ git lfs locks
design/hero.psd anna ID:42
$ git lfs unlock design/hero.psd
Чего LFS не делает и о чём стоит знать заранее:
- Не бесплатен. У хостингов отдельные квоты на объём хранилища и на исходящий трафик LFS, и трафик расходуется каждым клоном CI. Считайте до внедрения.
- Не дружит с форками так, как вы ожидаете. На части платформ объекты LFS не наследуются форком автоматически, а квота за них списывается с владельца исходного репозитория.
- Ортогонален partial clone и требует клиента.
--filter=blob:noneфильтрует объекты Git, а указатели — это как раз крошечные объекты Git; содержимое LFS управляется своими настройками. Разработчик без установленногоgit lfsполучит рабочее дерево с текстовыми файлами по 131 байту вместо картинок, и ошибка вылезет далеко от причины. - Не единственный вариант. Для больших датасетов часто уместнее DVC или
git-annex, для игровых ассетов — Perforce Helix Core или Plastic SCM, а для собранных артефактов — обычное объектное хранилище с версией в манифесте. Правило простое: если файл является результатом сборки, ему не место в системе контроля версий ни в каком виде.
Обслуживание: без него монорепо деградирует
Даже идеально нарезанный репозиторий со временем замедляется, если не заниматься упаковкой. Git это умеет сам, начиная с версии 2.30:
Одна команда git maintenance start выключает автоматический gc (maintenance.auto false), включает стратегию incremental и регистрирует фоновые задачи в планировщике ОС — systemd timers, launchd или Task Scheduler. Задачи разложены по частоте: prefetch каждый час подтягивает объекты с сервера, чтобы утренний git fetch был мгновенным; commit-graph, loose-objects и incremental-repack работают ночью.
Что именно даёт ускорение и почему:
-
commit-graph — файл с предвычисленным графом коммитов: родители, времена, «поколения» (generation numbers). Без него каждый
git log --graphили расчёт merge-base распаковывает тысячи объектов коммитов. С флагом--changed-pathsдополнительно пишутся Bloom-фильтры путей, превращающиеgit log -- libs/coreиз полного обхода истории в дешёвую проверку фильтра на каждом коммите. Включается какgit commit-graph write --reachable --changed-pathsплюсgit config gc.writeCommitGraph true. -
Multi-pack-index и bitmap-индексы (
git repack -a -d --write-midx --write-bitmap-index) — единый индекс поверх множества pack-файлов и предвычисленные множества достижимости. Bitmap решает главный вопрос сервера «какие объекты отдать этому клиенту» за миллисекунды вместо полного обхода графа. -
fsmonitor — демон, слушающий события файловой системы, чтобы
git statusспрашивал «что изменилось с прошлого раза» вместо обхода дерева. Встроенный вариант (core.fsmonitor true, Git 2.37+) работает на Windows и macOS; на Linux обычно подключают Watchman через хук. Рядом идутcore.untrackedCache trueиfeature.manyFiles true— последний включает формат индекса версии 4 и кеш неотслеживаемых файлов одной строкой. -
Ссылки. Сто тысяч веток и тегов — отдельная патология: каждый
fetchначинается с обмена списком ссылок. Помогаютgit pack-refs --all, протокол v2 (protocol.version=2, по умолчанию с Git 2.26), который позволяет запрашивать только нужные ссылки, а также новый бэкенд reftable (git init --ref-format=reftable, экспериментальный с Git 2.45), рассчитанный на миллионы ссылок. -
Про
gc --aggressive. Народное средство, которое почти всегда не нужно: этоrepackс окном поиска дельт по умолчанию 250 и глубиной 50, работающий часами и выбрасывающий уже найденные хорошие дельты. Осмысленно ровно один раз — после массового импорта илиfilter-repo. В остальное время достаточноgit maintenanceили обычногоgit gc.
Серверная сторона тоже настраивается: pack.writeBitmaps, repack.writeBitmaps, ограничение uploadpack.allowFilter (без него partial clone не заработает), receive.fsckObjects для отсечения битых объектов и uploadpack.allowAnySHA1InWant для точечных запросов CI.
Как это выглядит у тех, кто действительно большой
Полезно понимать: самые известные монорепо работают не на ванильном Git, и ссылаться на них как на доказательство «Git тянет монорепо» некорректно.
- Google. Около двух миллиардов строк, десятки тысяч коммитов в день, единый репозиторий Piper с виртуальной файловой системой CitC — не Git вообще. Описание архитектуры и мотивации: «Why Google Stores Billions of Lines of Code in a Single Repository», CACM, 2016.
- Microsoft. Репозиторий Windows — порядка 3,5 миллионов файлов и сотни гигабайт. Чтобы Git с ним справился, пришлось написать виртуальную ФС (GVFS, потом VFS for Git), а затем перенести наработки в апстрим: partial clone, sparse-index, fsmonitor, Scalar. То, чем мы сейчас пользуемся из коробки, — во многом побочный продукт этой работы.
- Meta. Ушла с Git на Mercurial и в итоге написала собственную систему Sapling с совместимым с Git форматом коммитов и виртуальной ФС.
- Android и Chromium. Формально много Git-репозиториев, склеенных внешними оркестраторами (
repoиgclient+depot_tools): манифест перечисляет проекты и ревизии — по сути промышленный вариант идеи подмодулей, вынесенный за пределы Git.
Вывод не «монорепо невозможен», а более скромный: чистый Git на монорепо требует вложений — в настройки клиентов, в серверную инфраструктуру, в систему сборки, понимающую граф зависимостей, и в merge queue. До нескольких десятков гигабайт и сотен тысяч файлов современный Git справляется штатными средствами из этой статьи. Дальше начинается инженерия, для которой нужна отдельная команда.
Типичные ошибки
- Лечить не ту ось.
--depth=1при медленномstatus; sparse-checkout при медленномclone. Сначала измерьте (GIT_TRACE_PERFORMANCE=1), потом лечите. git gc --aggressiveкак первое действие. Часы работы, обычно нулевой эффект, иногда — репозиторий больше прежнего.- Подмодуль без
push.recurseSubmodules. Рано или поздно кто-то получитfatal: reference is not a tree, и это будет плохой день. - Shallow-клоны в CI без нужды. Дорого для сервера, ломает
describe,merge-baseи любой diff с базовой веткой. - Включить LFS и считать, что репозиторий похудел. Пока не сделан
lfs migrate import, старые блобы лежат на месте. - Хранить артефакты сборки. Бинарники,
node_modules, датасеты и дампы БД в истории — самая частая причина того, что репозиторий вырос до неприличия. Артефактам место в артефакт-хранилище, версия — в манифесте. - Sparse-checkout без поддержки в сборке. Если система сборки требует полное дерево, вы получите не ускорение, а поломанную сборку.
- Разнести на десять репозиториев, чтобы «стало быстрее». Оси роста никуда не денутся, а атомарность изменений исчезнет; вместо одной проблемы с производительностью появится постоянная проблема с совместимостью версий.
- Забыть про
.gitignoreи.gitattributes. Половина проблем с большими репозиториями предотвращается двумя файлами и хуком, который отклоняет коммит с файлом больше N мегабайт (см. «Хуки и автоматизация»).
Мини-итог
- Репозиторий растёт по трём независимым осям: глубина истории, ширина дерева, вес содержимого. Каждая ось бьёт по своим командам и лечится своим механизмом.
- Диагностика раньше лечения:
git count-objects -vH, топ блобов черезrev-list --objects | cat-file --batch-check,GIT_TRACE_PERFORMANCE=1. - Submodule — это gitlink, запись в дереве с режимом
160000, хранящая 41 байт: хеш чужого коммита. Отсюда detached HEAD, пустые каталоги после клона и висячие указатели. - Subtree — чужие файлы физически в вашем дереве плюс слияние со сдвигом путей. Клон «просто работает», ценой размера репозитория.
- Спор submodule vs subtree решается вопросами про владение кодом, атомарность изменений и наличие пакетного менеджера, а не свойствами Git. Часто правильный ответ — вообще не Git-механизм, а зависимость с версией.
- Sparse-checkout режет рабочее дерево через флаг
skip-worktree; ускорение даёт только вместе с sparse-index (index.sparse true), потому что иначе индекс всё равно содержит все записи. - Partial clone (
--filter=blob:none) режет содержимое, сохраняя граф целиком, — рабочий вариант на каждый день. Shallow clone режет граф, ломает merge-base/bisect/describe и дорог для сервера — только для одноразовых сборок. - LFS заменяет бинарник указателем на 131 байт через фильтры clean/smudge. Не уменьшает уже существующую историю: для этого нужен
lfs migrate importс перезаписью. - Обслуживание обязательно:
git maintenance start, commit-graph с--changed-paths, multi-pack-index с bitmap, fsmonitor,pack-refs.gc --aggressive— почти всегда не то, что вам нужно. - Готовый рецепт монорепо-клона:
scalar cloneлибо--filter=blob:none --sparse+index.sparse+core.fsmonitor+git maintenance start.
Источники
- Pro Git, глава 7.11 «Submodules» — Scott Chacon, Ben Straub.
- git-submodule(1), gitsubmodules(7), git-subtree(1).
- git-sparse-checkout(1) — раздел
INTERNALS — CONE PATTERN SETобъясняет, почему конус быстрее произвольных шаблонов. - «Bring your monorepo down to size with sparse-checkout» и «Make your monorepo feel small with Git’s sparse index» — GitHub Blog, Derrick Stolee.
- «Get up to speed with partial clone and shallow clone» — сравнение фильтров с замерами.
- partial-clone design doc и git-maintenance(1).
- Git LFS specification — формат указателя; git-lfs-migrate(1).
- gitattributes(5) — раздел про фильтры
clean/smudge, на которых построен LFS. - «Why Google Stores Billions of Lines of Code in a Single Repository» — Rachel Potvin, Josh Levenberg, CACM 59(7), 2016.
- «Sapling: Source control that’s user-friendly and scalable» — инженерный блог Meta.
- Scalar: документация — настройки, которые он применяет, стоит прочитать даже если не пользуетесь.
Что дальше
Мы разобрали, как удержать репозиторий работоспособным, когда кода и людей становится много. Остался второй слой той же задачи — человеческий: как устроено ревью, когда PR приходят десятками в день; какие соглашения о коммитах позволяют собирать changelog автоматически; как выпускать релизы из монорепо, где двести компонентов версионируются независимо.
Совместная работа: код-ревью в масштабе, соглашения о коммитах, релизы