Подмодули и subtree: вложенные репозитории
Два способа вложить один Git-репозиторий в другой — submodule (ссылка на коммит) и subtree (физическое слияние истории) — и когда какой выбрать.
Submodule — указатель из родительского репозитория на конкретный коммит внешнего репозитория; код вложенного проекта живёт в своей истории. Subtree — приём, при котором внешний репозиторий вмёрживается в подпапку родителя как обычные файлы и коммиты.
Зачем это нужно на практике
Вы хотите переиспользовать общую библиотеку в нескольких проектах, не копируя её код руками. Или подключить чужой репозиторий (тему, SDK, набор конфигов) и при этом уметь подтягивать его обновления. Оба механизма решают эту задачу, но по-разному распределяют сложность: submodule оставляет истории раздельными (чисто, но требует дисциплины), subtree сливает их в одну (просто для потребителя, но история раздувается).
Submodule: ссылка на коммит
Подключение внешнего репозитория в подпапку:
git submodule add https://github.com/acme/shared-ui.git libs/shared-ui
git commit -m "add shared-ui as submodule"
Эта команда делает три вещи: клонирует репозиторий в libs/shared-ui, записывает в индекс не файлы, а коммит, на котором зафиксирован submodule, и создаёт файл .gitmodules с URL и путём. Поэтому при клонировании проекта подпапки submodule приходят пустыми — их надо инициализировать:
# после обычного clone родителя
git submodule update --init --recursive
# или сразу клонировать вместе с submodule
git clone --recurse-submodules https://github.com/acme/app.git
Обновить submodule до свежего коммита его ветки и зафиксировать это в родителе:
cd libs/shared-ui
git fetch && git checkout main && git pull
cd ../..
git add libs/shared-ui # фиксируем новый коммит-указатель
git commit -m "bump shared-ui"
Ключевая идея: родитель помнит точную ревизию зависимости. Это даёт воспроизводимость (все собирают один и тот же коммит библиотеки), но цена — лишний шаг: после каждого pull родителя нужно не забыть git submodule update, иначе подпапка останется на старом коммите.
Содержимое .gitmodules — это обычный текст, который коммитится вместе с проектом:
[submodule "libs/shared-ui"]
path = libs/shared-ui
url = https://github.com/acme/shared-ui.git
branch = main
Subtree: вмёрженная история
Subtree кладёт внешний репозиторий в подпапку как настоящие файлы — никаких .gitmodules и отдельных клонов. Потребителю проекта вообще не нужно знать, что это была чужая история: после clone всё уже на месте.
# добавить внешний репозиторий в подпапку
git subtree add --prefix=vendor/parser https://github.com/acme/parser.git main --squash
# подтянуть обновления апстрима
git subtree pull --prefix=vendor/parser https://github.com/acme/parser.git main --squash
# отдать свои правки обратно в апстрим
git subtree push --prefix=vendor/parser https://github.com/acme/parser.git feature/fix
Флаг --prefix задаёт подпапку, --squash схлопывает чужую историю в один коммит при добавлении и обновлении (без него вся история апстрима вольётся в вашу). subtree push умеет извлечь только ваши изменения в этой подпапке и отправить их во внешний репозиторий — так можно контрибьютить обратно.
Как это работает под капотом
У submodule в дереве родителя по пути libs/shared-ui лежит особая запись типа gitlink — это просто хеш коммита, а не дерево файлов. Поэтому в git diff родителя обновление submodule выглядит как смена одной строки-хеша. Файлы вложенного проекта Git родителя не отслеживает вовсе: за ними следит отдельный .git внутри подпапки. Отсюда и «пустые папки после clone», и необходимость --init.
Subtree не использует gitlink: при subtree add Git берёт дерево внешнего репозитория и приклеивает его под --prefix через специальное слияние. В результате чужие файлы становятся обычными отслеживаемыми файлами родителя, а в истории появляется merge-коммит (или один squash-коммит). Никакого .git внутри подпапки нет — всё в одной истории. Поэтому subtree «прозрачен» для тех, кто просто клонирует проект.
Submodule или subtree: сравнение
| Критерий | Submodule | Subtree |
| Истории | раздельные, ссылка на коммит | слиты в одну |
| После clone | нужен update --init | всё на месте сразу |
| Размер репозитория | маленький (только ссылка) | растёт (копия истории/файлов) |
| Обновление апстрима | submodule update + commit | subtree pull --squash |
| Отдать правки назад | прямо в репозитории submodule | subtree push |
| Порог входа команды | высокий (легко забыть init) | низкий для потребителя |
Эвристика: нужна жёсткая фиксация версии зависимости и команда дисциплинированна — берите submodule. Нужно, чтобы «просто работало после clone», а вендорный код правится редко — берите subtree.
Частые ошибки
- Пустые папки submodule. Забыли
git submodule update --init --recursiveпосле клонирования — подпапки пустые, сборка падает. - Не закоммичен сдвиг указателя. Обновили submodule, но не сделали
git addподпапки в родителе — у коллег он остался на старом коммите. - Detached HEAD внутри submodule. Submodule по умолчанию стоит на коммите, а не на ветке. Прежде чем коммитить внутри него, переключитесь на ветку (
git checkout main), иначе коммит «повиснет». - Subtree без
--squash. Вся история апстрима вливается в вашу и раздуваетgit log; добавляйте--squash, если не нужна полная история. - Перенос submodule в subtree «на ходу». Это не одна команда: subtree надо завести заново через
add, удалив submodule и его запись в.gitmodules.
Итоги
- Submodule — это закоммиченная ссылка на коммит внешнего репозитория; файлы живут в своей истории.
git submodule update --init --recursiveпосле clone обязателен, иначе подпапки пустые.- Subtree вмёрживает внешний репозиторий в подпапку как обычные файлы — потребителю не нужно ничего инициализировать.
--squashу subtree схлопывает чужую историю и держит вашlogчистым.- Submodule — за точную фиксацию версий и дисциплину; subtree — за простоту «работает сразу после clone».