Submodules workflow: clone, update, common pain
В предыдущем уроке мы разобрали, что submodule — это pinned reference на commit другого репо. В теории всё понятно. На практике submodules знамениты тем, что junior DE регулярно ловят footgun-ы: пустые директории после clone, detached HEAD внутри submodule, забытый push, потерянные изменения. Большинство этих болей — от непонимания workflow.
В этом уроке: практический workflow для работы с submodules в команде. Полные сценарии: clone-and-go, update до latest, переключение branches, разработка прямо в submodule. И конкретные решения для каждого pain point.
Workflow 1: Clone parent project
Самый частый scenarios — клонировать существующий parent-репо со submodules. Есть три способа.
# Способ 1: один shot
$ git clone --recurse-submodules https://github.com/acme/parent.git
# Эквивалент: clone + submodule init + submodule update --recursive
# Способ 2: clone, потом init/update
$ git clone https://github.com/acme/parent.git
$ cd parent
$ git submodule update --init --recursive
# --init нужен потому что после clone submodule "не активирован" в .git/config
# --recursive — для submodules внутри submodules (rare but exists)
# Способ 3: настроить чтобы все clone делали --recurse-submodules по дефолту
$ git config --global submodule.recurse true
# Теперь git clone и git pull автоматически работают с submodules
Для повседневной работы сразу настрой git config --global submodule.recurse true. Это глобальная настройка, которая включает recursive поведение для clone, checkout, pull. Снимает 90% болей submodule-ов.
Что значит “submodule init”
Без init у тебя в .gitmodules есть запись, но в .git/config parent-а submodule не зарегистрирован. Submodule директория есть, но пустая.
# После обычного clone (без --recurse-submodules)
$ ls shared-utils/
# (пусто)
$ cat .gitmodules
[submodule "shared-utils"]
path = shared-utils
url = https://github.com/acme/shared-utils.git
$ cat .git/config
# нет submodule секции
# init копирует данные из .gitmodules в .git/config
$ git submodule init
Submodule 'shared-utils' (https://github.com/acme/shared-utils.git) registered for path 'shared-utils'
$ cat .git/config
[submodule "shared-utils"]
url = https://github.com/acme/shared-utils.git
active = true
# Теперь update может склонировать
$ git submodule update
Submodule path 'shared-utils': checked out 'abc123...'
$ ls shared-utils/
# теперь файлы есть
init отделено от update потому что иногда хочется регистрировать submodule, но не клонировать его (например, если он не нужен для текущей работы).
В 99% случаев — делай init --update одной командой:
$ git submodule update --init --recursive
Workflow 2: Update submodule до latest
Сценарий: ты работаешь в parent, submodule shared-utils обновился в upstream (platform team добавила новую функцию). Ты хочешь использовать.
# Способ 1: ручной через submodule репо
$ cd shared-utils
$ git fetch origin
$ git checkout main
$ git pull origin main
$ cd .. # обратно в parent
$ git status
modified: shared-utils (new commits)
$ git add shared-utils
$ git commit -m "chore: bump shared-utils to latest"
# Способ 2: через parent-команду
$ git submodule update --remote shared-utils
# --remote означает "тяни последнее с tracked branch"
# По умолчанию tracked branch = origin/HEAD (обычно main)
$ git add shared-utils
$ git commit -m "chore: bump shared-utils"
Какой branch tracking?
По умолчанию git submodule update --remote тянет с origin/HEAD (обычно main). Если хочется другой branch — настрой через .gitmodules:
# .gitmodules
[submodule "shared-utils"]
path = shared-utils
url = https://github.com/acme/shared-utils.git
branch = develop ← теперь --remote будет тянуть develop
После изменения .gitmodules команда:
$ git submodule sync # синхронизирует .git/config с .gitmodules
$ git submodule update --remote
Workflow 3: Detached HEAD внутри submodule
Самый частый source confusion в submodules. После git submodule update внутри submodule HEAD detached:
$ cd shared-utils
$ git status
HEAD detached at abc123...
# Это нормально для submodule!
Почему: parent ссылается на конкретный SHA, не на branch. Когда parent делает checkout submodule, он делает git checkout <SHA>, что приводит к detached HEAD.
Это работает для read-only использования (просто использовать функции из shared-utils). Если ты хочешь изменять код прямо в submodule:
# Внутри submodule перейти на бранч ДО изменений
$ cd shared-utils
$ git checkout main # или другой branch
$ git pull origin main
# Теперь работай как с обычным репо
$ vim main.py
$ git add . && git commit -m "feat: add new helper"
$ git push origin main
# Вернись в parent и обнови gitlink
$ cd ..
$ git status
modified: shared-utils
$ git add shared-utils
$ git commit -m "chore: bump shared-utils with new helper"
Если ты commit-ишь внутри submodule без переключения на branch (в detached HEAD), коммит создастся, но не на ветке. После следующего git checkout он “потеряется” (станет dangling object). reflog поможет восстановить (модуль 10), но это болезненный опыт.
Workflow 4: Forgot to push submodule
Самая частая ошибка: ты сделал коммит в submodule, обновил gitlink в parent, запушил parent — но забыл запушить submodule. Коллега тянет parent, gitlink указывает на коммит, который существует только у тебя локально.
# Что делает коллега
$ git pull
$ git submodule update --init --recursive
Submodule path 'shared-utils': checked out 'abc123...'
fatal: reference is not a tree: abc123abc123...
Unable to checkout 'abc123' in submodule path 'shared-utils'
# Это значит — gitlink указывает на commit, которого нет в remote shared-utils
Решение:
# Ты возвращаешься, делаешь push submodule
$ cd shared-utils
$ git push origin main
# Теперь коллега может работать
Профилактика — настрой:
# Сделать push в parent проверять, что submodule тоже pushed
$ git config push.recurseSubmodules check
# или check = "abort if submodule not pushed", on-demand = "auto push submodule"
Опции push.recurseSubmodules:
no(default) — не проверять, можно забытьcheck— abort if submodule has unpushed commitson-demand— авто-push submodule перед parent push
Для team workflow рекомендую on-demand. Каждый push parent автоматически делает push submodule, если нужно.
Workflow 5: Switching branches в parent
Когда ты git checkout другую ветку в parent, submodule не обновляется автоматически:
# На feature-branch использовалась shared-utils v2
$ git checkout feature/new-pipeline
$ cd shared-utils
$ git log --oneline -1
abc123 feat: add v2 connector ← v2
# Переходишь на main, где shared-utils ещё на v1
$ cd ..
$ git checkout main
# Но в submodule всё ещё v2!
$ cd shared-utils
$ git log --oneline -1
abc123 feat: add v2 connector ← всё ещё v2!
$ cd ..
$ git status
modified: shared-utils (uncommitted changes — gitlink mismatch)
Это путает: ты переключил branch в parent, но submodule по факту — из старой ветки. Решение — submodule.recurse=true глобально:
$ git config --global submodule.recurse true
# Теперь:
$ git checkout main
# Автоматически обновит submodule к pinned commit ветки main
Альтернативно — каждый раз вручную:
$ git checkout main
$ git submodule update --recursive
Помнить вручную — рецепт катастрофы. Лучше включить config.
Workflow 6: Удаление submodule
Удалить submodule не так просто, как добавить. Нужно три действия:
# 1. Deinit (убирает из .git/config и working tree)
$ git submodule deinit -f shared-utils
# 2. Удалить из tree (gitlink)
$ git rm -f shared-utils
# 3. Удалить .git/modules/shared-utils
$ rm -rf .git/modules/shared-utils
# 4. Закоммить (удаление из .gitmodules произошло автоматически на step 2)
$ git commit -m "chore: remove shared-utils submodule"
Это известный гудок submodules — удаление через 3-4 шага. Есть alias-ы в community:
git config --global alias.rmsub '!f() { git submodule deinit -f "$1" && git rm -f "$1" && rm -rf .git/modules/"$1"; }; f'
# Теперь:
$ git rmsub shared-utils
Workflow 7: CI с submodules
GitHub Actions и аналоги по умолчанию НЕ клонируют submodules. Нужно явное указание:
# .github/workflows/ci.yml
- uses: actions/checkout@v4
with:
submodules: recursive # 'true' для one level, 'recursive' для глубокого
token: ${{ secrets.GH_TOKEN }} # если submodule private
Без submodules: recursive CI получит пустую shared-utils/, и тесты упадут с ImportError.
Если submodule private (другой репо в той же организации с разными permissions), нужен PAT (personal access token) с правами на оба репо, передаваемый через secrets.
Common pitfalls и решения
1. “shared-utils пустой после clone”
Причина: забыл --recurse-submodules или submodule update --init.
Решение: git submodule update --init --recursive или git config --global submodule.recurse true навсегда.
2. “detached HEAD внутри submodule, я случайно сделал коммит, теперь не могу его найти”
Причина: коммит был в detached HEAD, после следующего checkout стал dangling.
Решение:
cd shared-utils
git reflog # найди SHA своего коммита
git checkout -b recover <SHA> # сохрани как ветку
Профилактика: всегда перед коммитом в submodule git checkout main (или нужную ветку).
3. “Pull parent — submodule не обновляется”
Причина: pull обновляет gitlink в индексе, но не обновляет sам submodule.
Решение: после git pull сделать git submodule update --recursive. Или submodule.recurse=true глобально.
4. “Запушил parent с обновлением submodule, коллеги жалуются на ‘reference is not a tree’”
Причина: gitlink указывает на твой локальный коммит submodule, который не запушен.
Решение: cd submodule && git push. Профилактика: push.recurseSubmodules=on-demand.
5. “PR review submodule — что reviewer должен смотреть?”
Причина: PR на parent показывает только изменение gitlink (-Subproject commit X +Subproject commit Y). Что внутри изменилось — нужно смотреть в submodule отдельно.
Решение:
- Сделать
git log <old-sha>..<new-sha>в submodule для просмотра commits - Или открыть submodule на GitHub:
https://github.com/acme/shared-utils/compare/<old>...<new> - Использовать
git diff --submodule=logилиgit diff --submodule=diffдля inline просмотра
$ git config --global diff.submodule log
# Теперь git diff показывает commits в submodule:
$ git diff
Submodule shared-utils abc123..def456:
> feat: add new connector
> fix: edge case in parser
Полный survival checklist
После всего этого — короткий cheat sheet для повседневной работы:
# 1. Глобальные настройки — один раз
git config --global submodule.recurse true
git config --global push.recurseSubmodules on-demand
git config --global diff.submodule log
# 2. Clone parent с submodules
git clone --recurse-submodules <URL>
# 3. Pull — submodule обновится автоматически (если submodule.recurse=true)
git pull
# 4. Изменения в submodule
cd shared-utils
git checkout main # ВСЕГДА before editing
# (внести изменения, commit)
git push
cd ..
git add shared-utils && git commit -m "chore: bump submodule"
git push # автоматически запушит submodule (если push.recurseSubmodules=on-demand)
# 5. Обновить submodule до latest на tracked branch
git submodule update --remote
git add . && git commit -m "chore: bump submodule"
# 6. Удалить submodule
git submodule deinit -f <name>
git rm -f <name>
rm -rf .git/modules/<name>
git commit -m "chore: remove <name>"
С этим cheat sheet 90% проблем submodule исчезнут.
Hands-on: эксперимент
# Создаём всё локально, чтобы можно было погонять команды
# 1. Submodule репо (внутренняя library)
mkdir lib-repo && cd lib-repo
git init
echo "def hello(): return 'v1'" > main.py
git add . && git commit -m "init v1"
cd ..
# 2. Parent репо
mkdir parent && cd parent
git init
echo "# Parent" > README.md
git add . && git commit -m "init"
git submodule add ../lib-repo lib
git commit -m "feat: add lib submodule"
# 3. Симулируй коллегу — клонируй parent (без --recurse-submodules)
cd ..
git clone parent colleague-clone
cd colleague-clone
ls lib/
# (empty)
git submodule update --init --recursive
ls lib/
# main.py есть
# 4. Внутри lib обнови
cd ../lib-repo
echo "def goodbye(): return 'bye'" >> main.py
git commit -am "add goodbye"
# 5. В parent обнови gitlink
cd ../parent
git submodule update --remote
git status
git diff --submodule=log
# Submodule lib abc..def:
# > add goodbye
git add lib && git commit -m "chore: bump lib"
# 6. Симулируй колегу — pull
cd ../colleague-clone
git pull
# С submodule.recurse=true — submodule обновится сам
# Без него — нужен git submodule update --recursive
Cross-link
- Урок 03 — git subtree, альтернатива submodule без боли
- Урок 04 — git worktrees, другой механизм (multiple working trees)
- Модуль 14 — DVC для data (вместо submodule на data-репо)
- Модуль 18 — CI с submodules (actions/checkout submodules: recursive)
pip и venv: изолированные Python окружения