Submodules: концепция и mental model
Submodule — один из самых спорных и при этом полезных механизмов Git. Это способ включить один Git репозиторий внутрь другого, не копируя файлы, а ссылаясь на конкретный commit. У многих junior DE submodules вызывают frustration: “детач HEAD внутри”, “забыл push”, “не обновляется само”. Эта боль реальна, но возникает обычно от непонимания базовой mental model.
В этом уроке: что именно submodule с точки зрения Git (он не “вложенный репозиторий”, а ссылка), как выглядит механика gitlinks и .gitmodules, и три ключевых вопроса при принятии решения “submodule или нет”.
Mental model: submodule — это pinned reference
Самое важное понимание: submodule — это ссылка на конкретный commit другого репозитория, замороженная во времени.
В parent репо submodule представлен:
- Запись в
.gitmodules(text file в корне parent) — какое имя, какой URL. - Gitlink в tree — специальный type объекта (mode
160000), хранит SHA коммита submodule-репо.
Это два независимых артефакта. .gitmodules — конфигурация. Gitlink — указатель.
# .gitmodules в parent
$ cat .gitmodules
[submodule "shared-utils"]
path = shared-utils
url = https://github.com/acme/shared-utils.git
# Что в tree parent-а?
$ git ls-tree HEAD
100644 blob 89abc... README.md
040000 tree def012... dags/
160000 commit 4cac19... shared-utils ← gitlink
^^^^^^^^^^^^^^^^^^^^
mode 160000 = submodule
указывает на commit 4cac19... в shared-utils репо
Mode 160000 — особенный. Git видит “это submodule, не blob, не tree”. При checkout Git идёт в submodule-репо, делает git checkout 4cac19... там. После этого в working tree parent-а есть директория shared-utils/ со своим .git (на самом деле gitfile-ссылкой на parent’s .git/modules/shared-utils/, но это техническая деталь).
Жизненный цикл: добавление submodule
# В parent репо
$ cd my-parent-project
# Добавить submodule
$ git submodule add https://github.com/acme/shared-utils.git shared-utils
Cloning into '.../shared-utils'...
remote: Enumerating objects: ...
# Что произошло:
$ ls -la
drwxr-xr-x shared-utils/ ← клонированная директория
-rw-r--r-- .gitmodules ← новый файл
$ cat .gitmodules
[submodule "shared-utils"]
path = shared-utils
url = https://github.com/acme/shared-utils.git
$ git status
new file: .gitmodules
new file: shared-utils ← gitlink (одна запись, не файлы внутри)
$ git diff --cached shared-utils
diff --git a/shared-utils b/shared-utils
new file mode 160000
+Subproject commit 4cac19622fc3ade9c373d54e8e76e85f7466bcab
Видишь — diff показывает “new file mode 160000” с указанием Subproject commit SHA. Это gitlink.
# Commit
$ git add .gitmodules shared-utils
$ git commit -m "feat: add shared-utils submodule"
С этого момента parent-репо хранит:
- В индексе: запись о submodule с SHA
4cac19... - В
.gitmodules: метаданные (URL, path) - В рабочем дереве: реальные файлы submodule в
shared-utils/
Сам submodule репо — отдельная entity. У него своя история, свой .git/, свои бранчи. Parent просто ссылается на один конкретный commit.
Жизненный цикл: clone parent с submodules
Когда коллега клонирует parent, ему нужно отдельно запустить инициализацию submodules:
# Способ 1: clone с флагом --recurse-submodules
$ git clone --recurse-submodules https://github.com/acme/parent.git
# Это эквивалент clone + submodule init + submodule update
# Способ 2: clone, потом init/update
$ git clone https://github.com/acme/parent.git
$ cd parent
$ git submodule init # читает .gitmodules, регистрирует в .git/config
$ git submodule update # клонирует submodule в нужный path, checkout pinned commit
# Или одной командой:
$ git submodule update --init --recursive
Без этого второго шага shared-utils/ будет пустой директорией. Это самый частый footgun submodules: “почему shared-utils пустой?!”. Ответ: ты забыл submodule update --init.
Запомни как мантру: git clone --recurse-submodules или после обычного clone — git submodule update --init --recursive. Без этого submodules не работают.
Жизненный цикл: обновление submodule
Сценарий: в shared-utils появился новый commit (коллега добавил функцию). Ты хочешь использовать его в parent-проекте.
# В директории submodule
$ cd shared-utils
$ git fetch
$ git checkout main
$ git pull
# Теперь shared-utils указывает на новый commit
# Но parent об этом не знает — gitlink в parent всё ещё указывает на старый SHA
$ cd .. # обратно в parent
$ git status
modified: shared-utils (new commits)
$ git diff shared-utils
- Subproject commit 4cac19622fc3ade9c373d54e8e76e85f7466bcab ← старый
+ Subproject commit 89a3f12c44e2a1... ← новый
# Commit обновление в parent
$ git add shared-utils
$ git commit -m "chore: bump shared-utils to latest"
Видишь: обновление submodule — это два commit-а: один в submodule-репо (новая функция), второй в parent (обновление gitlink).
Альтернативный shortcut:
# Сразу обновить submodule до latest на его tracked branch
$ git submodule update --remote shared-utils
$ git add shared-utils
$ git commit -m "chore: bump shared-utils"
--remote говорит “тяни последнее на tracking branch” (по умолчанию main или setting в .gitmodules через branch = ...).
Когда submodule — правильный выбор
Submodules хороши когда:
Real DE example: airflow DAGs со shared utils
acme-data-platform/
├── dags/ (airflow DAGs)
├── plugins/ (airflow plugins)
├── tests/ (integration tests)
└── shared-utils/ ← submodule
├── connectors/ (S3, Snowflake, etc.)
├── transformations/ (common pandas helpers)
└── monitoring/ (logging, alerts)
shared-utils — отдельный репо, который используется в acme-data-platform, acme-ml-pipeline, и acme-reports-generator. Все три репо имеют его как submodule.
Когда platform team обновляет shared-utils (например, добавляют S3 connector v2):
- Изменения merge в
shared-utils:main. - DE-team в
acme-data-platformрешает, когда мигрировать. Делаютgit submodule update --remote && git commit. - Test, deploy. Если всё работает — gitlink в parent обновлён.
- Если не работает — gitlink остался на старой версии, ничего не сломалось.
Это явный controlled update — главное преимущество submodules.
Когда submodule — плохой выбор
Антипатаerns:
1. Submodule на data-репо. Если у тебя data-raw/ как submodule — это плохая идея. Git не дружит с большими бинарями (модуль 15). Используй DVC.
2. Tight coupling. Если API repo и client repo меняются вместе при каждой фиче (изменил endpoint — обновил клиента), submodule создаёт extra step. Лучше — monorepo.
3. Library доступная через package manager. Если пакет есть на PyPI (pandas, requests) — используй pip install, не submodule. Submodule — для internal code без публичного package distribution.
4. Множество submodules. 1-2 submodule — терпимо. 5+ — overhead начинает превышать пользу. Coordination между ними становится pain.
Альтернативы submodule
Прежде чем выбирать submodule, рассмотри:
-
Pip package — если library shared, опубликуй её на PyPI (или private package server: pypiserver, AWS CodeArtifact, JFrog). Стандартный
requirements.txt, нет проблем submodule. -
Monorepo — если несколько компонентов tightly coupled, держи всё в одном репо. Tooling: Bazel, Pants, Turborepo для polyglot monorepos.
-
Git subtree (следующий урок) — встроенный механизм Git без боли submodule.
-
Vendoring — копирование чужого кода в свой репо. Старая практика, но иногда оправдана для legal/security изоляции.
-
DVC для data, Git LFS для бинарей (модуль 15).
Submodule — когда ничего из выше не подходит и нужен именно SHA-pinned reference на другой репо.
Hands-on: добавление submodule
# Создай parent-репо
mkdir parent && cd parent
git init
echo "main project" > README.md
git add . && git commit -m "init"
# Симулируй submodule-репо (на самом деле — отдельный локальный)
cd ..
mkdir shared-utils && cd shared-utils
git init
echo "def hello(): print('hello')" > main.py
git add . && git commit -m "init utils"
git remote add origin [email protected]:fake/shared-utils.git # фиктивный URL
# Вернись в parent, добавь submodule
cd ../parent
git submodule add ../shared-utils shared-utils
# Используем relative path для демо
ls -la
# Вижу: shared-utils/, .gitmodules
cat .gitmodules
# [submodule "shared-utils"]
# path = shared-utils
# url = ../shared-utils
git status
# new file: .gitmodules
# new file: shared-utils (gitlink)
git diff --cached
# diff --git a/.gitmodules b/.gitmodules ...
# diff --git a/shared-utils b/shared-utils ...
# new file mode 160000
# +Subproject commit <SHA>
git commit -m "feat: add shared-utils submodule"
# Внутри submodule — обычный git репо!
cd shared-utils
git log
# Видишь историю shared-utils, не parent
# Что показывает status parent?
cd ..
git status
# nothing to commit
# Обнови submodule
cd shared-utils
echo "def goodbye(): print('bye')" >> main.py
git commit -am "add goodbye"
cd ..
git status
# modified: shared-utils (new commits)
git diff shared-utils
# - Subproject commit OLD
# + Subproject commit NEW
git add shared-utils && git commit -m "chore: bump shared-utils"
Это базовая mechanika. В следующем уроке (16.02) — детальный workflow и решение common pain points (detached HEAD, forgot to push).
.git/modules: где живут submodule .git
Технический insight: когда ты добавляешь submodule, его .git директория НЕ в shared-utils/.git/ (это просто файл-указатель), а в parent/.git/modules/shared-utils/:
$ cat shared-utils/.git
gitdir: ../.git/modules/shared-utils
$ ls .git/modules/
shared-utils/
$ ls .git/modules/shared-utils/
HEAD config description hooks info objects refs
Это сделано так, чтобы parent мог управлять submodule (например, удалить целиком одной командой). С точки зрения пользователя работа с submodule-директорией идентична работе с обычным репо: cd shared-utils && git log работает.
TL;DR
- Submodule = ссылка на конкретный commit другого Git репо.
- Parent хранит: запись в
.gitmodules+ gitlink в tree (mode 160000) с SHA. - Submodule репо имеет свою историю, бранчи, .git. Работа с ним — как с любым Git репо.
- Главный footgun: после
git cloneparent — submodule пустой, нужноgit submodule update --init --recursive. - Хорошо для: shared internal libraries, pinned versions, separate ownership teams.
- Плохо для: data (используй DVC), tight coupling (monorepo), public libraries (pip install), too many (overhead).
В следующем уроке — практический workflow с submodules и решение common pain points.
Идемпотентность пайплайнов и воспроизводимость результатов