Подмодули и 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: сравнение

КритерийSubmoduleSubtree
Историираздельные, ссылка на коммитслиты в одну
После cloneнужен update --initвсё на месте сразу
Размер репозиториямаленький (только ссылка)растёт (копия истории/файлов)
Обновление апстримаsubmodule update + commitsubtree pull --squash
Отдать правки назадпрямо в репозитории submodulesubtree 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».
Проверьте себя
1. Что именно записывается в родительский репозиторий, когда вы добавляете submodule?
AПолная копия всех файлов внешнего репозитория
BСсылка на конкретный коммит внешнего репозитория (gitlink) плюс запись в .gitmodules
CАрхив .zip внешнего проекта
DТолько URL без привязки к версии
2. Почему после `git clone` обычного репозитория папки submodule оказываются пустыми?
ASubmodule всегда приватные
Bclone скачивает только ссылку на коммит; содержимое тянется отдельно через submodule update --init
CЭто баг конкретной версии Git
DПапки пусты, пока не сделать git gc
3. Чем subtree принципиально удобнее submodule для того, кто просто клонирует проект?
ASubtree быстрее качает большие файлы
BПосле clone весь код subtree уже на месте — отдельная инициализация не нужна
CSubtree автоматически обновляет апстрим без команд
DSubtree не требует интернета вообще
4. Для чего нужен флаг --squash в командах git subtree?
AСжимает файлы для экономии диска
BСхлопывает историю внешнего репозитория в один коммит, чтобы не раздувать ваш git log
CУдаляет подпапку после слияния
DШифрует историю апстрима