Представь: ты пишешь сервис, и тебе нужна внутренняя библиотека, которая живёт в отдельном репозитории. Команда её правит независимо, у неё свой релизный цикл, свои тесты. Тебе не нужен её весь хаос внутри своей истории - тебе нужна КОНКРЕТНАЯ версия, зафиксированная намертво, чтобы сборка была воспроизводимой и сегодня, и через год.
Скопировать файлы руками? Потеряешь связь с апстримом и историю. Подключить как пакет (composer, npm)? Отлично, если у библиотеки есть нормальный реестр и версионирование. А если это твой же приватный код, форк чужого проекта или вендоринг C-зависимости без пакетного менеджера? Вот тут на сцену выходят подмодули git.
Подмодуль - это ссылка на чужой репозиторий по конкретному коммиту, вшитая в твою историю. Не копия файлов, не ветка - именно указатель: "вот этот репозиторий, вот этот SHA". Ты фиксируешь не "последнюю версию", а ровно тот коммит, на котором всё работало. Хочешь обновиться - двигаешь указатель осознанно и коммитишь это движение.
Сразу честно: подмодули болезненны. Это самая частая тема "почему git меня ненавидит" среди мидлов. Половина боли - от непонимания механики. Поэтому разберём именно механику, а не набор заклинаний.

Что физически создаёт git submodule add
Заведём суперпроект (так называют родительский репозиторий) и подключим библиотеку.
Код: Выделить всё
git init super && cd super
git commit --allow-empty -m "init"
git submodule add https://example.com/libfoo.git vendor/libfoo
Код: Выделить всё
git status
On branch main
Changes to be committed:
new file: .gitmodules
new file: vendor/libfoo
Первый - файл .gitmodules в корне. Это обычный текстовый файл под версионным контролем, карта всех подмодулей:
Код: Выделить всё
[submodule "vendor/libfoo"]
path = vendor/libfoo
url = https://example.com/libfoo.git
Второй - сам vendor/libfoo. Обрати внимание: git показывает его как один "new file", хотя это целая директория с кодом. Загляни в дерево:
Код: Выделить всё
git ls-files --stage vendor/libfoo
160000 a1b2c3d4e5f6... 0 vendor/libfoo
Где же реальный .git подмодуля? В современном git он не внутри vendor/libfoo, а вынесен в служебную папку родителя:
Код: Выделить всё
ls super/.git/modules/vendor/libfoo
HEAD config objects refs index ...
Код: Выделить всё
gitdir: ../../.git/modules/vendor/libfoo
Клонирование: git подмодуль recursive и пустые папки
Классическая первая боль новичка. Коллега склонировал твой репозиторий обычным git clone, зашёл в vendor/libfoo - а там пусто. Сборка падает. "У тебя что-то не закоммичено!"
Нет, всё закоммичено. Просто обычный clone тянет суперпроект, видит gitlink, но НЕ лезет внутрь подмодулей. Папки создаются пустыми. Лечится одним из двух способов.
Сразу при клонировании:
Код: Выделить всё
git clone --recurse-submodules https://example.com/super.git
Код: Выделить всё
git submodule update --init --recursive
- --init копирует настройки из .gitmodules в локальный .git/config. До этого подмодуль "не активирован" - git про него знает из карты, но не считает своим.
- update клонирует/фетчит подмодуль и переводит его на тот самый зафиксированный коммит из gitlink.
- --recursive делает то же самое для подмодулей внутри подмодулей. Да, они вкладываются.
Detached HEAD внутри подмодуля - это не баг, это контракт
Заходишь в подмодуль после update, делаешь git status и видишь:
Код: Выделить всё
cd vendor/libfoo && git status
HEAD detached at a1b2c3d
nothing to commit, working tree clean
Грабли отсюда: если ты внутри подмодуля что-то накоммитишь в detached HEAD, а потом сделаешь в нём checkout/switch на ветку - твои коммиты повиснут без ярлыка, и их легко потерять. Поэтому если реально правишь подмодуль, сначала встань на ветку (git switch main), а потом коммить.
Обновление подмодуля и фиксация нового указателя
Самый частый рабочий сценарий: в библиотеке вышли правки, надо подтянуть. Механика в две фазы - сначала двигаем подмодуль, потом фиксируем это движение в родителе.
Удобный способ - дать git'у самому сходить за свежими коммитами:
Код: Выделить всё
git submodule update --remote vendor/libfoo
После обновления смотрим в суперпроекте:
Код: Выделить всё
git status
modified: vendor/libfoo (new commits)
git diff
diff --git a/vendor/libfoo b/vendor/libfoo
index a1b2c3d..f6e5d4c 160000
--- a/vendor/libfoo
+++ b/vendor/libfoo
@@ -1 +1 @@
-Subproject commit a1b2c3d4e5f6...
+Subproject commit f6e5d4c3b2a1...
Код: Выделить всё
git add vendor/libfoo
git commit -m "bump libfoo to f6e5d4c"
Главная боль: забытый push подмодуля и рассинхрон версий
Вот сценарий, который ломает CI у каждого второго. Ты правишь и сам подмодуль, и родителя:
- в vendor/libfoo сделал коммит на ветке;
- в super сделал git add vendor/libfoo, закоммитил новый указатель;
- сделал git push в super.
Защита встроена в git. Перед пушем родителя проверяй подмодули:
Код: Выделить всё
git push --recurse-submodules=check
Код: Выделить всё
git push --recurse-submodules=on-demand
Код: Выделить всё
git config push.recurseSubmodules check
submodule foreach и массовые операции
Когда подмодулей несколько, бегать по папкам руками невыносимо. Есть встроенный обход:
Код: Выделить всё
git submodule foreach 'git fetch'
git submodule foreach --recursive 'git switch main && git pull'
Код: Выделить всё
git submodule foreach 'echo "$sm_path -> $(git rev-parse --short HEAD)"'
Entering 'vendor/libfoo'
vendor/libfoo -> f6e5d4c
Перемещение и удаление: те самые грабли с .git/modules
Раньше удаление подмодуля было адом из шести ручных шагов. С современным git стало терпимо, но осадок и подводные камни остались.
Переместить подмодуль в другую папку:
Код: Выделить всё
git mv vendor/libfoo libs/foo
Удаление - аккуратно, по шагам:
Код: Выделить всё
git submodule deinit -f vendor/libfoo
git rm -f vendor/libfoo
rm -rf .git/modules/vendor/libfoo
git commit -m "remove libfoo submodule"
- deinit деактивирует подмодуль - убирает его секцию из .git/config и чистит рабочую копию, но карту .gitmodules не трогает.
- git rm удаляет gitlink из индекса и запись из .gitmodules. Начиная с git 2.12 это убирает большую часть мусора автоматически.
- rm -rf .git/modules/... - вот это самое забываемое. Реальный .git подмодуля живёт в служебной папке родителя и git rm его НЕ удаляет.
Отдельно про absorbgitdirs. Если тебе достался старый репозиторий, где .git подмодуля лежит ВНУТРИ рабочей папки (а не в .git/modules родителя), команда git submodule absorbgitdirs перенесёт его в служебную папку и проставит правильный gitdir-указатель. Это лечит целый класс проблем с перемещением в наследованных проектах.
Когда подмодуль - плохая идея (мост к subtree)
Честный разговор. Подмодули хороши, когда:
- зависимость - это реально отдельный репозиторий со своей жизнью и релизами;
- нужна жёсткая фиксация конкретной версии-коммита для воспроизводимости;
- ты лишь ПОТРЕБЛЯЕШЬ библиотеку и редко её правишь.
- команда вперемешку правит и родителя, и подмодуль каждый день - двойные пуши и забытые указатели изматывают;
- много новичков - detached HEAD и пустые папки бьют по ним постоянно;
- хочется атомарных коммитов "одна правка через несколько компонентов" - с подмодулями это всегда два коммита в двух репозиториях.
Мини-лаба: подмодуль руками за 5 минут
Повтори по шагам, локально, без удалёнок (используем file://):
- 1. Сделай "удалённую" библиотеку: mkdir -p /tmp/libfoo && cd /tmp/libfoo && git init && echo "v1" > foo.txt && git add . && git commit -m "v1"
- 2. Сделай суперпроект: mkdir /tmp/super && cd /tmp/super && git init && git commit --allow-empty -m init
- 3. Подключи: git submodule add file:///tmp/libfoo vendor/libfoo
- 4. Посмотри новые объекты: открой .gitmodules и выполни git ls-files --stage vendor/libfoo (найди режим 160000 - это gitlink).
- 5. Закоммить: git add . && git commit -m "add libfoo"
- 6. В библиотеке сделай новый коммит: cd /tmp/libfoo && echo "v2" > foo.txt && git commit -am "v2"
- 7. Подтяни в суперпроект: cd /tmp/super && git submodule update --remote vendor/libfoo, затем git diff - убедись, что поменялся только Subproject commit.
- 8. Зафиксируй указатель: git add vendor/libfoo && git commit -m "bump libfoo to v2"
- 9. Сэмулируй коллегу: git clone --recurse-submodules /tmp/super /tmp/clone, проверь, что в /tmp/clone/vendor/libfoo лежит foo.txt с v2.
- 10. Удали начисто: git submodule deinit -f vendor/libfoo && git rm -f vendor/libfoo && rm -rf .git/modules/vendor/libfoo && git commit -m "drop libfoo" - и убедись, что .git/modules/vendor пуст.
Контрольные вопросы
- 1. Чем физически отличается запись подмодуля в дереве суперпроекта от обычного файла и обычной папки? Какой у неё режим и что хранится внутри?
- 2. В чём разница между git submodule update и git submodule update --remote? Какая из команд может незаметно откатить твои локальные правки в подмодуле?
- 3. Почему после git submodule update подмодуль оказывается в detached HEAD, и чем это грозит, если коммитить прямо в этом состоянии?
- 4. Какой шаг при удалении подмодуля чаще всего забывают и к какому конкретному багу это приводит при будущем добавлении подмодуля по тому же пути?
Подмодуль - это вшитый в твою историю указатель на конкретный коммит чужого репозитория: gitlink-запись (режим 160000) плюс карта .gitmodules. Всё остальное - производные от этой одной идеи. Detached HEAD - это контракт фиксации версии, а не поломка. Update без --remote возвращает к зафиксированному коммиту, с --remote - тянет свежак. Главные грабли - пустые папки без recursive, забытый push подмодуля и недочищенный .git/modules при удалении. Подмодули мощны для вендоринга независимых репозиториев с жёсткой фиксацией версий, но мучительны при ежедневной совместной правке - и там лучше смотреть в сторону subtree, чем мы и займёмся дальше.