Git и командная работа Большие репозитории и монорепо: submodules, subtree, sparse-checkout, LFS
0%

Большие репозитории и монорепо: submodules, subtree, sparse-checkout, LFS

Большие репозитории и монорепо: 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.

Два способа склеить репозитории: submodule и subtree

Первый архитектурный вопрос: если кода много, держать его в одном репозитории или в нескольких? Git предлагает два механизма связывания, и они устроены принципиально по-разному — что становится очевидным, если посмотреть на объекты.

Submodule: указатель в дереве

В «Внутри Git» мы видели, что запись в дереве — это <режим> <тип> <хеш> <имя>. Обычные режимы: 100644 (файл), 100755 (исполняемый), 120000 (симлинк), 040000 (подкаталог). Есть четвёртый: 160000gitlink, запись, которая ссылается не на 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. Три честных вопроса, которые его закрывают:

  1. Кто выпускает версии этой зависимости? Если у неё есть нормальные релизы и пакетный реестр — не используйте ни то ни другое. Обычная зависимость с версией в lock-файле лучше обоих механизмов: у неё есть семантика версий, разрешение конфликтов и аудит. Git-механизмы нужны там, где пакетного менеджера нет (C++ без Conan/vcpkg, ассеты, конфигурация инфраструктуры) или где нужен доступ к исходникам «прямо сейчас».
  2. Нужны ли атомарные изменения через границу? Если типичная задача меняет код и в основном проекте, и в библиотеке одновременно, любая граница репозиториев — источник боли: два PR, два ревью, окно несогласованности. Это главный аргумент за монорепо.
  3. Кто платит за забытую команду? Подмодуль перекладывает налог на каждого разработчика (и на 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: указатель в объектах Git, содержимое в отдельном хранилище

$ 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 не делает и о чём стоит знать заранее:

  1. Не бесплатен. У хостингов отдельные квоты на объём хранилища и на исходящий трафик LFS, и трафик расходуется каждым клоном CI. Считайте до внедрения.
  2. Не дружит с форками так, как вы ожидаете. На части платформ объекты LFS не наследуются форком автоматически, а квота за них списывается с владельца исходного репозитория.
  3. Ортогонален partial clone и требует клиента. --filter=blob:none фильтрует объекты Git, а указатели — это как раз крошечные объекты Git; содержимое LFS управляется своими настройками. Разработчик без установленного git lfs получит рабочее дерево с текстовыми файлами по 131 байту вместо картинок, и ошибка вылезет далеко от причины.
  4. Не единственный вариант. Для больших датасетов часто уместнее 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.

Источники

Что дальше

Мы разобрали, как удержать репозиторий работоспособным, когда кода и людей становится много. Остался второй слой той же задачи — человеческий: как устроено ревью, когда PR приходят десятками в день; какие соглашения о коммитах позволяют собирать changelog автоматически; как выпускать релизы из монорепо, где двести компонентов версионируются независимо.

Совместная работа: код-ревью в масштабе, соглашения о коммитах, релизы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов