13.1 Submodules: репо внутри репо
Submodule - это указатель из одного репозитория на конкретный коммит другого репозитория. Не копия, не зависимость в смысле пакетного менеджера, а именно «пиннинг» на SHA.
Внутри репо submodule выглядит как обычная директория, но в
.gitmodules записан URL источника, а в индексе записан SHA
коммита из этого источника:
[submodule "vendor/awesome"]
path = vendor/awesome
url = https://github.com/foo/awesome.git
Команды:
git submodule add https://github.com/foo/awesome vendor/awesome
git submodule init # инициализация (после clone)
git submodule update # подтянуть до SHA из индекса
git submodule update --remote # подтянуть до HEAD upstream
git submodule deinit vendor/awesome # деинициализировать
Типичный заход на свежем клоне:
git clone https://github.com/foo/bar
cd bar
# vendor/awesome пустая - нужно подтянуть
git submodule update --init --recursive
Или сразу при clone:
git clone --recursive https://github.com/foo/bar
Что физически записано
В индексе родительского репо vendor/awesome лежит как
специальный объект «gitlink» - указатель на коммит SHA. Не tree,
не blob, а ссылка на коммит. При git status ты видишь:
modified: vendor/awesome (new commits)
Это значит: ты зашёл в submodule, сделал там что-то, теперь родительскому репо нужно обновить указатель.
Где submodules адекватны
- Вендоринг библиотеки, которая редко меняется. Пиннинг на конкретный SHA, защита от внезапного breaking change.
- Шаринг кода между несколькими проектами. Например, общий протобуф-пакет.
- Огромная third-party зависимость. Когда package manager слишком тяжёл (или его нет, например в C/C++).
Где submodules болят
- Каждая активная разработка. Если внутри submodule ты тоже часто коммитишь, цикл «обновить родительский указатель» - отдельная работа на каждое изменение.
- Сложность для новых разработчиков. Без
--recursiveрепо просто не собирается, без объяснений где что. - CI должен помнить про submodules. Без
--recurse-submodulesв pipeline - собирается пустота. - Branch / submodule mismatch. Ветки родительского репо
могут указывать на разные SHA submodule. При переключении
ветки
git submodule updateнужен явно.
Полезный конфиг
# Автоматически обновлять submodules при checkout/pull
git config --global submodule.recurse true
# Показывать diff внутри submodule, не только «modified»
git config --global diff.submodule log
С этими настройками submodules ведут себя ощутимо менее агрессивно.
Подводный камень: detached HEAD внутри submodule
git submodule update делает checkout по SHA, не по ветке.
Внутри submodule у тебя detached HEAD (см. detached-head).
Если ты там что-то коммитишь, не переключаясь на ветку - твои
коммиты висят на отдельном указателе, и при следующем
git submodule update их можно потерять.
Правильный цикл «изменить код внутри submodule»:
cd vendor/awesome
git switch -c fix/x # выйти из detached, создать ветку
# ... правки ...
git commit -am "fix"
git push
cd ..
git add vendor/awesome # обновить указатель в родителе
git commit -m "bump awesome"
13.2 Альтернативы submodule: subtree, monorepo
Submodule - не единственный способ держать несколько кодовых баз вместе. Альтернативы:
git subtree
Subtree копирует содержимое одного репо внутрь другого как
обычные файлы. Без gitlink, без .gitmodules. Внешне это
выглядит как обычная директория, и git clone тащит её
целиком, без отдельной команды.
git subtree add --prefix=vendor/awesome https://github.com/foo/awesome main --squash
git subtree pull --prefix=vendor/awesome https://github.com/foo/awesome main --squash
Плюсы:
- Новый разработчик клонирует и сразу всё на месте.
- CI не нужно знать про submodule.
- Можно править файлы внутри, и они не теряются.
Минусы:
- Каждый pull увеличивает размер репо (история копируется).
- Команды длинные, легко забыть
--prefix. - Чтобы отправить изменения обратно в upstream - отдельная
процедура
git subtree push.
Subtree подходит для разового вендоринга, когда дальнейшие обновления редки. Если планируется регулярный sync - subtree становится утомительным.
Monorepo
Альтернативный подход: отказаться от идеи «несколько репо», положить всё в один. Все приложения, все библиотеки, общие зависимости - в одном дереве.
monorepo/
apps/
web/
mobile/
api/
libs/
auth/
utils/
models/
tools/
deploy/
ci/
Плюсы:
- Один clone, всё на месте.
- Refactoring через всё дерево - атомарный коммит.
- Никакого pinning'а - все приложения видят актуальные библиотеки.
Минусы:
- Требует системы сборки, понимающей зависимости (Bazel, Nx, Turborepo, Pants).
- Репо растёт быстро. Часто > гигабайта.
- CI должен выбирать, что собирать (incremental builds), иначе каждое изменение запускает всю сборку.
Monorepo выбирают большие компании (Google, Meta, Stripe) и многие фронтенд-команды (Nx-based). Mid-size команды чаще сидят на multi-repo. Это политическое решение, не техническое - и часто принимается «потому что у Google monorepo», что плохой аргумент.
Сравнение
| Что | submodules | subtree | monorepo |
|---|---|---|---|
| Pinning на SHA | да | нет (есть копия) | нет |
Нужен --recursive при clone | да | нет | нет |
| Простота для нового разработчика | низкая | средняя | высокая |
| Размер репо | маленький | растёт | большой |
| Atomic refactor через всё дерево | нет | нет | да |
| Подходит для редких обновлений | да | да | - |
| Подходит для активной разработки в нескольких компонентах | нет | нет | да |
Правило большого пальца: submodules - для read-only зависимостей. Для активного кода - monorepo или multi-repo с package manager'ом.
13.3 Worktrees: параллельная работа без переключения веток
Обычно у репозитория одно working tree - одна директория с
файлами, которая отражает HEAD. Чтобы посмотреть другую ветку -
git switch, и working tree переключается.
Это раздражает, когда нужно одновременно работать с двумя
ветками. Типичный сценарий: ты пишешь фичу в feat/x, прилетает
срочный hotfix-запрос на main. Варианты:
- Stash текущие изменения, переключиться на main, починить, вернуться, unstash. Работает, но контекст рассыпается.
- Клонировать репо ещё раз в другую директорию - оверхед на
clone, две
.git/директории на диск. - Worktree - несколько working tree из одного
.git/.
git worktree add ../bar-hotfix main
# создаёт ../bar-hotfix с checkout main
Что произошло: в ../bar-hotfix появилась полная working
copy, как будто это отдельный клон. Но .git/ шарится с
основной директорией - никакого второго fetch'а, никакого
дублирования объектов.
В этой директории ты делаешь hotfix, коммитишь, пушишь, как
обычно. В основной директории всё это время продолжается работа
в feat/x - без переключения.
Когда закончил - удалить worktree:
git worktree remove ../bar-hotfix
# либо просто rm -rf, если внутри ничего не сохраняется
Список worktree
git worktree list
# /path/to/main abc123 [feat/x]
# /path/to/bar-hotfix def456 [main]
Видно, какие directories связаны с какими ветками.
Ограничения
- Одна ветка - одна worktree. Нельзя checkout одну и ту же ветку в двух worktree одновременно. Git защищается от двух одновременных коммитов на одну ветку.
- Не для submodules-сценариев. Submodules внутри worktree работают, но с нюансами; лучше избегать.
git worktree removeотказывается с грязными изменениями. Либо commit/stash, либо--force.
Когда worktree оправданы
- Hotfix во время фича-работы.
- Сравнение поведения двух веток вживую (запустить обе одновременно).
- Долгий тест на одной ветке, продолжение работы на другой.
- Code review большой ветки в отдельной директории, чтобы IDE не мешалась.
В commercial-проектах worktree часто недооценён. В большом проекте, где ребилд занимает 5 минут, два рабочих копии могут экономить десятки минут в день.
13.3.1 Подводный камень: relative refs внутри worktree
.git/ шарится между worktrees, но reflog и некоторые refs -
per-worktree:
.git/
HEAD ← per-worktree (main worktree)
worktrees/
bar-hotfix/
HEAD ← per-worktree (hotfix worktree)
index
logs/
Это значит:
HEADв основной директории !=HEADв hotfix.git stashв одной не видит stash другой (stash хранится в per-worktree-refs).reflog HEADв одной не показывает события другой.
Эти изоляции по большей части незаметны, но иногда удивляют. Когда «не нахожу stash в новом worktree» - это потому, что он на самом деле в старом.
13.4 Sparse checkout: только нужная часть
Sparse checkout управляет тем, какие файлы Git кладёт в рабочее
дерево. По умолчанию sparse checkout сам по себе не уменьшает
то, что качается с remote, - чтобы не тащить blob'ы всего репо,
его комбинируют с partial clone (--filter=blob:none, см.
ниже). Полезно, когда репо большое (монорепо), а нужна только
конкретная директория.
Включение:
git clone --filter=blob:none --no-checkout https://github.com/foo/monorepo
cd monorepo
git sparse-checkout init --cone
git sparse-checkout set apps/web libs/auth
git checkout main
После этого в рабочем каталоге будут только apps/web/* и
libs/auth/*. Файлы из apps/mobile/, libs/utils/ и т.д.
физически не на диске - только в .git/objects/. Это экономит
место и время первого checkout.
--cone - режим, в котором sparse-checkout работает быстро на
больших репах (по директориям, не по файловым паттернам).
--no-cone (старый) поддерживает gitignore-style patterns, но
медленнее.
Когда полезен
- Монорепо. Frontend-команда работает только с
apps/webи несколькимиlibs/. Sparse checkout убирает остальное с диска. - Очень большие репозитории. Linux kernel клонируется целиком ~3 GB; если нужна только определённая поддерева драйверов - sparse сокращает до сотен MB.
- CI на одну часть. Если в pipeline проверяется только
frontend/, sparse checkout сокращает время clone в разы.
Ограничения
- Не для всех команд прозрачно.
git logпоказывает всю историю (как и должен), но если кто-то спросит «покажи мне изменения в файле X» - а файл вне sparse, тебе нужно расширить sparse, чтобы увидеть его. - Не «virtualизация», как Git LFS или VFS. Файлы или есть на диске, или нет; чтобы получить - нужно поменять sparse-set.
Partial clone
Sparse checkout + --filter=blob:none (partial clone) - мощная
пара. Partial clone не качает blob'ы (содержимое файлов) до
checkout. Sparse checkout говорит, какие blob'ы понадобятся.
Вместе - clone в несколько раз быстрее на больших репах.
Поддерживается Git 2.25+. На GitHub partial clone работает, sparse checkout - локальная команда, не требует поддержки сервера.
13.5 Когда что выбирать
Шпаргалка для типичных сценариев:
| Что нужно | Что брать |
|---|---|
| Подключить read-only зависимость (vendored library) | submodule |
| Подключить зависимость, в которой будем активно править | monorepo или package manager (npm, cargo, pip) |
| Разовый вендоринг проекта | subtree (или просто cp -r + commit) |
| Несколько активных продуктов с общими библиотеками | monorepo с системой сборки |
| Срочный hotfix во время feature-работы | worktree |
| Тестировать две версии одновременно | worktree |
| Работать только с частью большого репо | sparse checkout |
| CI на часть монорепо | sparse + partial clone |
| Огромная история, нужен только последний коммит | --depth 1 (shallow clone) |
Что НЕ делать
- Не использовать submodule «потому что коллеги их видели». Это специфический инструмент, не общая практика. Если ты не уверен - скорее всего, не нужны.
- Не лечить submodule-боль ещё более глубокими submodule. Если submodule болит, рассмотри subtree или monorepo.
- Не делать worktree «временно навсегда». Через год у тебя в директории-сестре старый код, на котором не сделан pull полгода. Удаляй worktree после задачи.
- Не путать sparse checkout с .gitignore.
.gitignoreне даёт Git'у видеть файлы. Sparse checkout говорит, какие из видимых файлов класть на диск. Это разные слои.
Уроки в sandbox
lab-13.1. Worktree для hotfix без потери контекста
Цель - отработать сценарий «работаю над фичей, прилетел срочный hotfix». С worktree можно решить hotfix параллельно, не трогая фича-ветку. Лаба не требует remote, всё локально.
Создай репо с двумя коммитами:
mkdir -p /tmp/worktree-lab && cd /tmp/worktree-lab && git init && echo 'v1' > app.txt && git add . && git commit -m 'v1' && echo 'v2' >> app.txt && git commit -am 'v2'.Создай feature-ветку и сделай в ней «незаконченную работу»:
git switch -c feat/big-refactor && echo 'WIP refactor' >> app.txt && git commit -am 'wip refactor'. Это твоя текущая, длинная задача. У тебя в working tree «грязные» (в смысле работы) изменения.Прилетает срочный hotfix-запрос на main. Без worktree пришлось бы stash + switch. Сделай worktree вместо этого:
git worktree add ../hotfix main. Создаст параллельную директорию../hotfixс checkout'ом main.Проверь:
git worktree list. Увидишь оба worktree - основной (с feat/big-refactor) и хотфикс (с main). Они шарят одну.git/директорию: место на диске не дублируется.Сделай hotfix в отдельной директории:
cd ../hotfix && echo 'CRITICAL FIX' >> app.txt && git commit -am 'fix: critical' && cd -.Вернувшись в основной worktree, ты всё ещё на feat/big-refactor, твоя WIP-работа на месте. Запусти
git log --oneline --graph --all: увидишь, что на main появился коммит 'fix: critical', а твоя фича от него отделена.Удали worktree, когда hotfix задеплоен:
git worktree remove ../hotfix. Директория удалится,.git/останется.Итог для головы: ты решил две задачи параллельно, без stash, без потери контекста. Worktree - это инструмент против stash'а в сценариях «нужно временно отвлечься на другую ветку».
sandbox с автопроверкой - открыть в песочнице
Резюме
- Submodule - это указатель на конкретный SHA другого репо. Подходит для read-only вендоринга, но болит при активной разработке внутри (см. [[detached-head]] в submodule).
- Subtree копирует содержимое внутрь, без gitlink. Проще для нового разработчика, но история растёт, и push в upstream неудобен.
- Monorepo - отказ от идеи «много репо» в пользу одного дерева. Требует системы сборки с incremental builds (Bazel, Nx, Turborepo). Политическое решение, не техническое.
- Worktree даёт несколько working tree из одного .git/. Идеально для hotfix во время feature-работы и параллельного тестирования веток. Одна ветка - одна worktree.
- Sparse checkout позволяет работать только с частью большого репо. В паре с partial clone (`--filter=blob:none`) сокращает clone больших монорепо в разы.
- Шпаргалка выбора: submodule = пиннинг read-only. Subtree = разовый вендоринг. Monorepo = активная разработка нескольких компонентов. Worktree = параллельные ветки. Sparse = часть монорепо.
Контрольные вопросы
Я добавил submodule, запушил, коллега клонировал - у него директория submodule пустая. Что забыли?
Показать ответ
git submodule update --init --recursiveпосле clone. Submodule хранится в индексе родительского репо как указатель (gitlink), но содержимое не клонируется автоматически. Нужно сказать Git'у «теперь клонируй submodules».Альтернатива - клонировать сразу с
--recursive:git clone --recursive <url>
Это часто забывают. Для команд, активно использующих submodules, полезно добавить инструкцию в README или сделать
Makefiletargetmake init, который делаетgit submodule update --initза пользователя.Submodule болит: каждое изменение в нём ломает CI родительского репо. Что делать?
Показать ответ
Скорее всего, submodule не подходит к твоей задаче. Submodule проектировался под редкие обновления вендоренной зависимости, а не под активную разработку.
Если код в submodule меняется часто и связан с родительским репо логически:
- Если это один продукт в разных папках - переходи на monorepo. Один репо, один CI, atomic коммиты через всё.
- Если это общая библиотека - заверни в проперный пакет (npm, cargo, pip) и подключай через package manager.
- Если это разовый вендоринг (нужно зафиксировать конкретную
версию для воспроизводимости) - рассмотри subtree или
просто
cp -r && git commit.
submodule оправдан для read-only зависимостей: «здесь конкретный SHA внешней библиотеки, мы его не правим, только обновляем раз в полгода». Если у тебя другой сценарий - инструмент не тот.
Чем worktree лучше второго clone?
Показать ответ
Тремя вещами:
- Объекты не дублируются.
.git/objects/шарится между worktrees. На репозиториях по гигабайту это экономит место. - Один fetch обновляет всё. Не нужно ходить по двум
клонам и делать
git fetchв каждом. - Конфиг общий. Один
git config, один список remote, одни SSH-ключи. В двух клонах это два разных репо со своими настройками, которые легко рассинхронизировать.
Минусы worktree: чуть менее изолирован (одна
.git/- общая точка отказа), нельзя checkout одну ветку в двух worktree одновременно. Для большинства сценариев плюсы перевешивают.- Объекты не дублируются.
Я в monorepo, но мне нужно работать только с одной директорией. Как обойтись без скачивания всего репо?
Показать ответ
Partial clone + sparse checkout:
git clone --filter=blob:none --no-checkout <url>
cd <repo>
git sparse-checkout init --cone
git sparse-checkout set apps/web libs/auth
git checkout main
--filter=blob:noneговорит «не качай содержимое файлов до checkout».--no-checkout- не делай checkout сразу.sparse-checkout set- какие директории положить на диск.В итоге clone в разы быстрее, диск тратится только на нужные директории, история (коммиты, tree-объекты) всё равно вся на месте - нужна для
git log,git blameи т.д.Файлы вне sparse-set можно «добавить позже»:
git sparse-checkout add libs/utilsподкачает их с remote.Я случайно сделал коммит внутри submodule, теперь не могу его запушить - submodule в detached HEAD. Что делать?
Показать ответ
Создать ветку на текущей позиции и запушить с неё:
cd path/to/submodule
git switch -c fix/my-change # из detached в новую ветку
git push -u origin fix/my-change
Теперь твой коммит на ветке
fix/my-change, не висит на detached. Дальше - открыть PR в upstream submodule, ждать мержа.После того как submodule примет коммит в свой main:
cd path/to/submodule
git switch main
git pull
cd ..
git add path/to/submodule # обновить указатель в родителе
git commit -m "bump submodule to <new-sha>"
Урок: внутри submodule всегда работай на нормальной ветке. См. detached-head про общую механику.