Submodules и subtrees
Submodule хранит закреплённый указатель (gitlink) на один внешний коммит через .gitmodules; subtree сливает внешний код прямо в твоё дерево. Submodule лёгкий, но отслеживает коммит, а не ветку; subtree проще для потребителей, но раздувает историю.
Твоё приложение зависит от внутреннего репо design-system, и ты хочешь его исходники внутри проекта — не как опубликованный пакет, а как код, который можно читать и править на месте. Можно скопировать файлы и потерять всякую связь с upstream, а можно позволить Git отслеживать эту связь за тебя. Git предлагает два встроенных способа вложить один репозиторий в другой, и они делают противоположные компромиссы.
К концу этого урока ты сможешь отличить submodule от subtree, выполнять ключевые команды каждого и объяснить, почему модель submodule «отслеживает коммит, а не ветку» — это подвох, который кусает команды чаще всего.
После этого урока ты сможешь вшить внешний репозиторий в свой через submodule или subtree, объяснить, что каждый хранит в твоей истории, и выбрать между ними по тому, кто несёт сложность — твои разработчики или твои потребители.
Submodule — это закреплённый указатель на один точный коммит внешнего репо, а не копия его файлов. Когда ты добавляешь submodule, твой репозиторий записывает особую запись под названием gitlink: одну строку в дереве, говорящую «по этому пути живёт внешний репо X на коммите abc123». Внешний репо клонируется в подкаталог, но твой репо хранит лишь указатель плюс файл .gitmodules, сопоставляющий путь с URL upstream.
git submodule add https://example.com/design-system.git vendor/design-system
git commit -m "Add design-system submodule"Твой коммит теперь содержит gitlink (закреплённый SHA) и .gitmodules — а не реальные файлы design-system. Именно это держит родительский репо маленьким.
Клон репо с submodules по умолчанию не подтягивает их — нужно их инициализировать. Свежий клон оставляет каталоги submodule пустыми; родитель знает только указатель, а не содержимое. Закреплённые коммиты подтягиваются явно:
# Одним шагом, при клонировании:
git clone --recursive https://example.com/app.git
# Или после обычного клона:
git submodule update --init --recursive--init читает .gitmodules, update выгружает каждый submodule на точный коммит, который закрепляет родитель (не верхушку его ветки). Это сердце модели: родитель точно контролирует, какую версию зависимости получает каждый чекаут.
Подвох: submodule отслеживает коммит, и его обновление — это двухшаговый коммит, который легко забыть. Чтобы сдвинуть зависимость вперёд, ты заходишь внутрь submodule, выгружаешь более новый коммит, затем возвращаешься и коммитишь сдвинутый указатель в родителе:
cd vendor/design-system
git checkout main
git pull # submodule теперь на более новом коммите
cd ../..
git add vendor/design-system # подготовить новый указатель
git commit -m "Bump design-system to latest main"Если пропустить этот последний git add + commit, твой локальный submodule обновлён, но родитель всё ещё закрепляет старый SHA — и коллеги, делающие submodule update, получают старую версию. Обратная ловушка: подтянуть изменение родителя, сдвинувшее указатель, и забыть запустить submodule update — и твоя рабочая копия тихо работает на устаревшей зависимости. Submodules точны, но остры.
Subtree сливает содержимое внешнего репо прямо в твоё дерево — без указателя, без лишнего шага для потребителей. Вместо ссылки git subtree копирует внешнюю историю в подкаталог твоего репо как настоящие файлы и настоящие коммиты:
# Добавить внешний репо под vendor/design-system
git subtree add --prefix=vendor/design-system \
https://example.com/design-system.git main --squash
# Позже подтянуть изменения upstream
git subtree pull --prefix=vendor/design-system \
https://example.com/design-system.git main --squashТеперь любой, кто клонирует твой репо, получает файлы design-system сразу — без --recursive, без submodule update, без .gitmodules. Цена — в твоей истории: коммиты subtree (или сжатый снимок) сливаются в твой репо, делая его объёмнее, а отправка изменений обратно в upstream (git subtree push) возни́стее, чем чистое разделение submodule.
▸Частая ошибка
Самый частый провал с submodule — путаница «оторванного указателя». После submodule update submodule находится в detached HEAD на закреплённом коммите — не на ветке. Разработчики заходят внутрь, правят, коммитят, а позже обнаруживают, что работа «пропала», потому что они закоммитили не на ветку, а затем поздний update увёл HEAD. Внутри submodule всегда делай git checkout <branch> перед правкой и помни, что любое изменение там — это изменение в отдельном репозитории со своим push.
Одна зависимость, две команды, два выбора.
Платформенная команда, submodule. Они активно ведут design-system и нуждаются, чтобы их двенадцать приложений закрепляли точную, проверяемую версию. Они добавляют его как submodule в каждое приложение. Подъём версии — намеренное действие: кто-то обновляет коммит submodule и коммитит новый указатель в PR, так что подъём ревьюится, и каждое приложение точно заявляет, какой SHA отгружает. Цена, которую они принимают: каждый разработчик должен помнить про git submodule update --init после pull, а CI должен клонировать --recursive, иначе сборки падают на пустом каталоге.
Команда тулинга, subtree. Они публикуют CLI, который бандлит маленький репо templates. Их пользователи — не эксперты по Git и не должны запускать вторую команду после клона. Они вшивают templates как subtree, так что один git clone даёт рабочий чекаут — без .gitmodules, без сюрпризов. Когда шаблоны upstream меняются, мейнтейнер запускает git subtree pull --squash и коммитит слитый результат. Их история объёмнее, но их потребители несут ноль сложности submodule. Та же задача, противоположное распределение боли — submodule перекладывает усилие на потребителей, subtree — на мейнтейнеров.
▸Почему это работает
Зачем нужно то или другое, когда менеджеры пакетов (npm, pip, Go modules) решают вендоринг зависимостей? Потому что некоторые зависимости — не опубликованные пакеты: внутренняя общая библиотека, форк, который ты патчишь локально, репо конфигов или вшитые исходники, которые надо собирать изнутри твоего дерева. Submodules и subtrees позволяют самому Git версионировать связь, без реестра и без шага релиза — ты закрепляешь или встраиваешь конкретный коммит любого достижимого репо. Когда подходит настоящий реестр пакетов — предпочти его; эти приёмы для случаев, когда он не подходит.
Разработчик клонирует репо с submodules обычным `git clone` (без --recursive) и обнаруживает, что каталог vendor/design-system пуст. Почему?
Чтобы вложить один репо в другой, submodule записывает gitlink — указатель на один точный внешний коммит — плюс файл .gitmodules, держа твой репо лёгким, но отслеживая коммит, а не ветку; обновление означает зайти в submodule и затем закоммитить новый указатель в родителе, а свежий клон требует --recursive или git submodule update --init, иначе каталог пуст. Subtree же сливает внешний код в твоё дерево как настоящие файлы, так что потребители получают всё из одного git clone без лишнего шага — ценой объёмнее истории и сложнее push в upstream. Submodule перекладывает сложность на потребителей; subtree — на мейнтейнеров. Дальше ты возьмёшься за самую тяжёлую хирургию истории: переписать всё прошлое репозитория, чтобы вычистить утёкший секрет.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.