Когда проект разрастается и в нём появляются общие библиотеки, внутренние SDK и вспомогательные сервисы, рано или поздно встаёт вопрос: держать всё в одном репозитории, выносить части в отдельные репозитории и связывать их через submodules, либо встраивать чужой код через subtree. Правильного ответа на все случаи жизни нет, но есть понятная механика каждого подхода, и именно она определяет, насколько болезненными будут обновления и слияния. Разберём все три варианта на реальных командах, посмотрим на типичные ошибки и выстроим рабочий workflow для каждого.

Механика submodule и указатель на коммит внутри родительского репозитория

Submodule устроен просто и при этом коварно. Родительский репозиторий хранит всего две вещи: путь к подпроекту и хеш коммита, на который тот должен указывать. Никакого особого слияния внутренностей submodule не происходит: Git сливает лишь ссылку.

Добавление выглядит так:

git submodule add https://git.example.com/libs/shared-ui.git vendor/shared-ui

Вывод после команды:

Cloning into '/home/dev/app/vendor/shared-ui'...
remote: Enumerating objects: 1320, done.
remote: Counting objects: 100% (1320/1320), done.
Receiving objects: 100% (1320/1320), 412.56 KiB | 3.20 MiB/s, done.

Разбор построчно: первая строка говорит, что Git клонирует отдельный полноценный репозиторий в каталог vendor/shared-ui. Вторая и третья строки показывают, что сервер насчитал 1320 объектов и упаковал их. Последняя строка подтверждает, что скачано 412 килобайт истории. То есть submodule привозит полную историю чужого репозитория, а не один снапшот.

После добавления в родительском репозитории появляются два изменения: файл .gitmodules и gitlink-запись, которую видно через git status:

Changes to be committed:
        new file:   .gitmodules
        new file:   vendor/shared-ui

Обратите внимание: vendor/shared-ui записан как файл, а не каталог. Это и есть тот самый указатель на коммит. Клонирование проекта с submodule требует специального флага:

git clone --recursive https://git.example.com/app/main.git

Без флага --recursive каталог vendor/shared-ui окажется пустым. Многие разработчики наступали на эти грабли после переезда на новую машину, поэтому в README стоит прямо писать команду с флагом. Если репозиторий уже склонирован, подтягиваем submodule так:

git submodule update --init --remote

Здесь --init регистрирует submodule из .gitmodules, а --remote говорит взять не зафиксированный в указателе коммит, а последний коммит отслеживаемой ветки, обычно main. Этот флаг частая причина расхождений: один разработчик обновился с --remote, зафиксировал новый указатель, а CI собрал старый, потому что работал от зафиксированного хеша.

Файл .gitmodules после добавления выглядит так:

[submodule "vendor/shared-ui"]
        path = vendor/shared-ui
        url = https://git.example.com/libs/shared-ui.git
        branch = main

Первая строка задаёт имя submodule, которое 사용уется в настройках. Параметр path фиксирует, где живёт рабочая копия. Параметр url говорит, откуда клонировать, именно его подставляет git submodule update --init на чистой машине. Строка branch появляется только если задать ветку явно через git submodule add -b main, и именно по ней работает флаг --remote. Удобная команда для диагностики текущего состояния:

git submodule status

Её вывод:

 3f9a1c2d4e5f60718293a4b5c6d7e8f901234567 vendor/shared-ui (v2.4.1)
+8ba977c1d4e5f60718293a4b5c6d7e8f907654321 vendor/legacy-widgets (heads/main)

Пробел в начале первой строки означает, что checkout соответствует зафиксированному указателю. Плюс в начале второй строки сигнализирует, что рабочая копия submodule стоит на другом коммите, чем зафиксировано в родителе: кто-то переключился внутри и забыл закоммитить указатель. Минус на этом месте означал бы, что submodule вообще не инициализирован. Эта мелочь экономит часы отладки сборки, где "вдруг пропал каталог" на деле оказывается неинициализированным submodule.

Типичные ошибки submodule и их профилактика

Самая распространённая ошибка это закоммиченный в родителе указатель на коммит, которого нет на удалённом сервере submodule. Разработчик сделал коммит внутри vendor/shared-ui, поднялся на уровень выше, закоммитил новый указатель и запушил только родителя. Коллега делает pull и получает:

error: Server does not allow request for unadvertised object 3f9a1c2...
Fetched in submodule path 'vendor/shared-ui', but it did not contain 3f9a1c2...

Первая строка означает, что сервер отказался отдавать объект, о котором не объявлял в рекламе ссылок. Вторая строка прямо говорит: нужный хеш в submodule не найден. Лечение простое: сначала push изнутри submodule, потом push родителя. Профилактика надёжная и короткая:

git config --global submodule.recurse true
git config --global push.recurseSubmodules on-demand

Первая настройка заставляет команды checkout и pull автоматически обновлять submodule, вторая откажется пушить родителя, пока его submodule не запушены. Вторая частая боль всплывает при слияниях: два разработчика сдвинули указатель на разные коммиты, и Git при merge честно сообщает о конфликте указателя. Разруливается он вручную: заходим в каталог submodule, выбираем нужный коммит через git checkout, возвращаемся и фиксируем. Хорошая профилактика: ограничить число людей, которые имеют право двигать указатель, и обновлять submodule только отдельными осмысленными коммитами с понятным сообщением.

Subtree и встраивание чужого кода без внешних ссылок

Subtree решает ту же задачу иначе: код чужого репозитория физически вливается в историю родителя. Для коллег проект выглядит как обычный монолитный репозиторий, никаких флагов при clone не нужно.

Добавление:

git subtree add --prefix=vendor/shared-ui https://git.example.com/libs/shared-ui.git main --squash

Вывод:

git fetch https://git.example.com/libs/shared-ui.git main
From https://git.example.com/libs/shared-ui
 * branch            main       -> FETCH_HEAD
Added dir 'vendor/shared-ui'

Здесь первая строка показывает, что Git сначала выполняет обычный fetch из чужого репозитория. Звёздочка во второй строке означает, что ссылка FETCH_HEAD создана заново. Последняя строка сообщает, что каталог добавлен в рабочее дерево. Флаг --squash схлопывает всю историю библиотеки в один коммит слияния, благодаря чему история родителя не раздувается сотнями чужих коммитов.

Обновление до новой версии библиотеки:

git subtree pull --prefix=vendor/shared-ui https://git.example.com/libs/shared-ui.git main --squash

Subtree создаёт настоящий merge-коммит, поэтому конфликты решаются привычными инструментами слияния, и в этом его главная прелесть. Обратная сторона: каждый такой merge тащит всё дерево библиотеки в объекты родителя, и размер репозитория растёт. Типичная ошибка новичков: ручное редактирование файлов в vendor/shared-ui прямо в родителе, после чего очередной subtree pull превращается в многочасовое разбирательство. Правило выживания простое: либо библиотека в subtree только для чтения и патчи уходят вверх в отдельный репозиторий, либо изменения вносятся через git subtree push и согласованный процесс.

Если subtree-каталог всё же нужно поправить локально, принято отделять такие правки отдельными коммитами со своим сообщением и префиксом пути, чтобы потом git subtree push мог выцепить их в апстрим. Команда возврата изменений выглядит так:

git subtree push --prefix=vendor/shared-ui https://git.example.com/libs/shared-ui.git hotfix-local

Git перебирает историю, отфильтровывает коммиты, затрагивающие заданный prefix, и пушит их как новую ветку hotfix-local в исходный репозиторий. Операция небыстрая на длинной истории, потому что фильтрация идёт по всем коммитам, и это ещё один аргумент в пользу коротких, частых push вместо одного огромного в конце квартала.

Монорепо и выборочная проверка через sparse-checkout

Третий путь: вообще не делить репозиторий. Монорепо избавляет от указателей, внешних ссылок и проблем синхронизации версий, но расплачиваться приходится размером. Клон на несколько гигабайт и долгий checkout быстро выводят из себя. Смягчает ситуацию встроенный механизм sparse-checkout, который оставляет в рабочем дереве только нужные каталоги, хотя история остаётся общей:

git clone --filter=blob:none --no-checkout https://git.example.com/app/monorepo.git
cd monorepo
git sparse-checkout init --cone
git sparse-checkout set services/billing libs/shared-ui
git checkout main

Разбор по шагам: --filter=blob:none клонирует историю без содержимого файлов, докачивая объекты по мере надобности, что резко ускоряет первый клон. --no-checkout не распаковывает рабочее дерево вообще. Команда init --cone включает конусный режим, который работает быстро на больших деревьях, а set перечисляет каталоги, которые реально нужны разработчику. В списке это выглядит так:

  1. Сначала оценивается ритм обновлений библиотеки, если она меняется раз в квартал, subtree почти безболезнен;
  2. Затем считается число потребителей кода, если библиотеку использует одна команда, проще держать её в монорепо;
  3. Дальше проверяется зрелость CI, потому что submodule требует пересборки связки репозиториев;
  4. После этого прикидывается размер истории, если клон уже превышает несколько гигабайт, монорепо потребует sparse-checkout и частичных клонов;
  5. В финале принимается решение по владению, чужой код со своим релизным циклом логичнее держать submodule.

Проверить, что cone-режим действительно сократил рабочее дерево, помогает git sparse-checkout list, которая печатает активные каталоги. Дисковый эффект замеряют парой команд du -sh .git и du -sh рабочего каталога до и после: на крупных монорепо частичный клон вместе с cone-режимом сокращает время первой выгрузки с десятков минут до пары минут, а статусные операции перестают подвисать, потому что Git сканирует только нужное подмножество файлов.

Сложность слияний в трёх подходах на практике

По опыту команд, которые прошли все три модели, картина такая. При submodule слияние почти никогда не конфликтует кодом, зато регулярно конфликтует указателем, и разрешить конфликт может только человек, понимающий, какой коммит в submodule правильный. При subtree слияния проходят как обычные текстовые merge, конфликты видны сразу, но любой subtree pull добавляет в историю целый чужой коммит со squash, и при десятке таких библиотек git log начинает тонуть. В монорепо слияния самые честные: весь код лежит рядом, рефакторинг затрагивает потребителей атомарным коммитом, зато конфликты случаются чаще просто из-за плотности изменений.

Маленький проект на три-пять человек почти всегда выигрывает от монорепо: накладных расходов минимум. Средний проект на пару десятков разработчиков с независимой библиотекой, живущей своим релизным циклом, разумно смотрится с одним-двумя submodule. Крупная организация с десятками команд обычно приходит к монорепо с sparse-checkout плюс дисциплиной, либо к пакетному менеджеру, который вообще убирает задачу из Git. Честно говоря, худший результат даёт смесь: submodule для одной библиотеки, subtree для другой и копипаст для третьей. Единообразие тут важнее идеального выбора, потому что именно кейсы слияния и обновления люди вынуждены повторять каждую неделю, и каждый лишний режим работы умножает число способов ошибиться.

Пошаговый workflow обновления зависимого кода в каждом подходе

Для submodule рабочий цикл выглядит так. Заходим в каталог submodule, делаем git fetch и git checkout нужного тега или коммита, поднимаемся в родителя, запускаем тесты, фиксируем указатель отдельным коммитом с сообщением вида "Bump shared-ui to v2.4.1", пушим submodule и только затем родителя. Для subtree цикл иной: сначала убеждаемся, что рабочее дерево чистое, затем выполняем git subtree pull с тем же prefix, что был при add, разрешаем возможные конфликты, гоняем тесты и пушим. Для монорепо отдельного обновления нет вовсе: код меняется вместе с потребителями в одном коммите, и ревью охватывает всё разом.

Профилактика бед одинакова почти везде: автоматическая проверка в CI, что указатель submodule ссылается на опубликованный коммит; запрет прямых правок внутри subtree-каталога на уровне code owners; регулярный git gc и настройка partial clone для большого монорепо. Если выстроить эти три мелочи, любой из подходов перестаёт быть источником сюрпризов и становится просто инструментом, который делает ровно то, что от него ждут.