git subtree: альтернатива submodule
git subtree — менее известный, но мощный механизм Git, который решает ту же задачу, что submodule — включить чужой репо внутрь своего — но без главной боли submodule (нужно --recurse-submodules, detached HEAD, забытые push-ы). Идея subtree: встроить код external репо прямо в parent, сохранив историю. Для пользователей это выглядит как обычная директория с обычными файлами.
В этом уроке: что такое subtree, как добавить и обновлять, преимущества над submodule, и в каких случаях subtree — правильный выбор (а в каких — submodule).
Mental model: subtree merges история
После git subtree add в parent появляется реальная директория (shared-utils/) с реальными файлами. Никаких gitlink, никаких .gitmodules. С точки зрения Git — это просто файлы parent-а.
При этом subtree merge сохраняет историю external репо в parent. Все коммиты shared-utils теперь — часть git log parent-а.
Команды subtree
git subtree встроен в Git (с версии 1.7.11). Не нужна установка.
Add: подключить external репо
# Синтаксис: git subtree add --prefix=<path> <repo-url> <ref> [--squash]
$ cd my-parent-project
$ git subtree add --prefix=shared-utils https://github.com/acme/shared-utils.git main --squash
# Что произошло:
$ ls shared-utils/
main.py utils.py ... ← реальные файлы
$ git log --oneline
def456 Merge commit 'abc123' as 'shared-utils' ← merge commit
abc123 Squashed 'shared-utils/' content from commit xyz789
$ git status
# clean — всё уже commit-нуто
--squash важен: без него вся история external репо разворачивается в твою. С --squash сворачивается в один commit “Squashed shared-utils content”.
Сравни:
# Без --squash: вся история shared-utils добавляется
$ git subtree add --prefix=shared-utils https://github.com/acme/shared-utils.git main
$ git log --oneline | head -10
# Куча коммитов из shared-utils + один merge
# С --squash: только один коммит-snapshot
$ git subtree add --prefix=shared-utils https://github.com/acme/shared-utils.git main --squash
$ git log --oneline | head -3
# Squashed commit + merge
Для DE 99% случаев используй --squash. Иначе твой git log забивается чужой историей.
Pull: обновить subtree до latest
$ git subtree pull --prefix=shared-utils https://github.com/acme/shared-utils.git main --squash
Это эквивалент: fetch upstream + merge изменений в subtree, ограниченных prefix-ом shared-utils/.
Если в parent работа активная, может быть конфликт между твоими изменениями в shared-utils/ и upstream. Resolve как обычный merge conflict.
Чтобы упростить — добавь URL как remote:
# Один раз
$ git remote add shared-utils-origin https://github.com/acme/shared-utils.git
# Теперь pull проще
$ git subtree pull --prefix=shared-utils shared-utils-origin main --squash
Push: отправить локальные изменения обратно в upstream
Это главная сложность subtree. Допустим, ты в parent отредактировал shared-utils/main.py (добавил функцию). Эти изменения коммитятся в parent (вместе с твоими parent-only изменениями). Теперь хочешь отправить только эти изменения в upstream shared-utils.
$ git subtree push --prefix=shared-utils https://github.com/acme/shared-utils.git main
Эта команда extract-ит изменения в subtree (через split) и push-ит их в upstream. Под капотом:
- Делает
git subtree split --prefix=shared-utils— создаёт новый branch с историей только subtree. - Push-ит этот branch в upstream.
Под капотом subtree split проходит по всей истории и фильтрует commits, которые касаются shared-utils/. На больших проектах это медленно (минуты для middle-size репо, десятки минут для big).
# Альтернативный вариант — split + manual push
$ git subtree split --prefix=shared-utils -b shared-utils-export
$ git push https://github.com/acme/shared-utils.git shared-utils-export:main
Преимущества subtree
Самое важное — zero onboarding cost. Когда коллега клонирует parent, всё работает сразу. Нет ловушек “забыл submodule init”. В этом subtree выигрывает у submodule подавляющим преимуществом для большинства команд.
Недостатки subtree
1. Раздутая история
Если не использовать --squash, история external репо добавляется к твоей parent. Если subtree обновляется регулярно, через год у тебя в git log тысячи коммитов из чужого репо.
Решение: всегда --squash при add и pull. Тогда каждое обновление subtree — один merge commit, не сотня.
2. Push upstream — сложно
git subtree push медленный на больших репо. Под капотом split обходит всю историю.
Решение: использовать subtree в основном для одностороннего потребления (pull updates). Если ты часто пушишь обратно в upstream — submodule может быть удобнее. Или работай напрямую в upstream-репо (cd shared-utils-repo; edit там; commit + push в shared-utils; pull subtree).
3. Bumping выглядит как merge commit
В git log parent видно “Merge commit … as ‘shared-utils’” — иногда непонятно при беглом взгляде, что это subtree update vs обычный merge.
Решение: придерживайся conventional commits style. Subtree pull -> коммит с message chore(shared-utils): bump to latest. Тогда в log понятно.
4. Размер репо растёт
--squash помогает с историей, но содержимое в parent-репо увеличивается. Если subtree большой (десятки MB), это влияет на clone time.
Решение: если external репо большой — рассмотри submodule или DVC.
Когда subtree, когда submodule
Конкретные DE-сценарии:
Subtree подходит:
- Включить чужой open-source repo как vendored dependency (например, неmaintain-юмый пакет, который не на PyPI)
- Shared dbt-utils macros, которые ты иногда обновляешь, но не дорабатываешь
- Маленькая shared библиотека для нескольких проектов
Submodule подходит:
- Большая shared-library для нескольких projects (parent остаётся стройным)
- Critical: знать ТОЧНЫЙ SHA для production deploy
- Multiple teams: одни owns submodule, другие consume
Ни один не подходит:
- Package есть на PyPI -> используй pip
- Big data -> DVC
- Tightly coupled -> monorepo
Hands-on: subtree workflow
# 1. Создаём parent
mkdir parent && cd parent
git init
echo "# Main project" > README.md
git add . && git commit -m "init"
# 2. Симулируем external lib
cd ..
mkdir extlib && cd extlib
git init
echo "def hello(): return 'v1'" > main.py
git add . && git commit -m "init v1"
echo "def goodbye(): return 'v1'" >> main.py
git add . && git commit -m "add goodbye"
# 3. Добавляем как subtree в parent
cd ../parent
git subtree add --prefix=extlib ../extlib master --squash
# (или main, в зависимости от default-branch)
# 4. Что в parent?
ls
# README.md extlib/
ls extlib/
# main.py
git log --oneline
# def456 Merge commit 'abc123' as 'extlib'
# abc123 Squashed 'extlib/' content from commit ...
# 789012 init
# Видишь: история сжата (squash) + merge commit
# 5. Симулируй обновление extlib
cd ../extlib
echo "def new_func(): return 'v2'" >> main.py
git add . && git commit -m "add new_func"
# 6. Pull в parent
cd ../parent
git subtree pull --prefix=extlib ../extlib master --squash
cat extlib/main.py
# Видим new_func
git log --oneline
# Видим merge commit + squashed update
# 7. Симулируй contribution upstream
echo "def parent_modify(): pass" >> extlib/main.py
git add extlib/main.py && git commit -m "feat(extlib): add parent_modify"
# Push в upstream — слегка медленнее
git subtree push --prefix=extlib ../extlib master
# В реальной жизни URL вместо ../extlib
cd ../extlib
git log --oneline
# Видим parent_modify в истории extlib
Real-world example: vendoring через subtree
DE-сценарий: ты используешь маленькую open-source library tiny-parquet-utils, которая не на PyPI. Можно:
- Просто copy-paste файлы — теряется история, нет updates.
- Submodule — extra шаги для коллег.
- Subtree — реальные файлы + updates через
git subtree pull.
# Вначале
git subtree add --prefix=vendor/tiny-parquet-utils \
https://github.com/community/tiny-parquet-utils.git main --squash
# Раз в месяц обновляешь
git subtree pull --prefix=vendor/tiny-parquet-utils \
https://github.com/community/tiny-parquet-utils.git main --squash
git commit -m "chore(deps): bump tiny-parquet-utils to v1.5"
# Поправил bug в vendored код, хочешь contribute upstream
git subtree push --prefix=vendor/tiny-parquet-utils \
https://github.com/yourname/tiny-parquet-utils.git fix-branch
Это vendoring with maintenance — лучшее из обоих миров.
Cross-link
- Урок 01-02 — submodules как альтернатива
- Урок 04 — git worktrees (другой механизм)
- Модуль 14 — DVC для data, не subtree
- Модуль 11 — Pull Requests; subtree push требует PR в upstream
TL;DR
- Subtree — встроить external репо в parent с сохранением истории.
git subtree add --prefix=X URL branch --squash— добавить.git subtree pull --prefix=X URL branch --squash— обновить.git subtree push --prefix=X URL branch— push изменений upstream (медленно).- Преимущества: zero onboarding cost, real files, self-contained.
- Недостатки: длинная история без squash, медленный push.
- Использовать: vendoring read-mostly external code, маленькие shared utils.
- НЕ использовать: package на PyPI (используй pip), big data (DVC), tight coupling (monorepo).
dbt и Git: структура репо, .gitignore и организация макросов