Стратегии ветвления: trunk-based, GitFlow, GitHub Flow — что когда
Спор о стратегиях ветвления — один из немногих технических споров, где обе стороны обычно правы и обе говорят не о том. «GitFlow — это оверинжиниринг» и «без release-веток мы не сможем поддерживать три версии у заказчиков» — оба утверждения верны, просто про разные команды с разными ограничениями.
Чтобы вынести из спора что-то полезное, надо перестать смотреть на картинки с разноцветными линиями и посмотреть на модель данных. Как только становится понятно, что ветка — это 41 байт, а платите вы не за ветки, а за расхождение, набор осмысленных вариантов сужается до трёх-четырёх, и выбор делается по внятным критериям: как часто вы можете деплоить, сколько версий одновременно живёт в проде и сколько стоит откат.
Статья опирается на модель объектов и DAG из «Внутри Git» и на ежедневные команды из «Ежедневная работа». Механику самих слияний мы намеренно откладываем — ей посвящена следующая статья про merge и rebase. Здесь речь про топологию и процесс: кто, куда и как долго ветвится.
Ветка — это указатель, а не копия
Начнём с фактов, которые видно в файловой системе. Возьмём любой репозиторий:
$ cat .git/HEAD
ref: refs/heads/main # HEAD — символическая ссылка на ветку
$ cat .git/refs/heads/main
e5f9a2c1d0b3f47a8c2e91d6b0a7f3c5d8e4b129
$ wc -c .git/refs/heads/main
41 .git/refs/heads/main # 40 символов хэша + перевод строки
$ git switch -c feature/login # создание ветки = запись такого же файла
Switched to a new branch 'feature/login'
$ git update-ref refs/heads/experiment $(git rev-parse main) # то же самое вручную
$ git for-each-ref --format='%(refname:short) %(objectname:short) %(committerdate:relative)' refs/heads
experiment e5f9a2c 2 hours ago
feature/login e5f9a2c 2 hours ago
main e5f9a2c 2 hours ago
После git gc файлы ссылок схлопываются в .git/packed-refs, но семантика не меняется — git for-each-ref читает и то, и другое.
Отсюда три следствия, которые перечёркивают половину интуиций, принесённых из SVN и TFS:
- Ветка не стоит ничего по месту и времени. Создать сто веток — записать 4100 байт. Никакого копирования рабочей копии, никакого «дерева» на сервере. Аргумент «давайте меньше веток, чтобы не раздувать репозиторий» — бессмысленный.
- Удаление ветки не удаляет коммиты.
git branch -Dстирает файл ссылки; объекты живут в базе, пока их держит reflog (по умолчанию 90 дней для достижимых и 30 для недостижимых, см.gc.reflogExpire). Именно поэтому работает восстановление из «Восстановление: reflog и потерянные коммиты». - Ветка не «содержит» изменения. Она указывает на коммит, а коммит — на полный снимок дерева. «Изменения ветки» — это всегда вычисляемая величина: разница относительно общего предка,
git merge-base.
Последний пункт — ключ ко всему остальному. Раз «изменения ветки» вычисляются относительно точки расхождения, то стоимость работы с веткой зависит не от того, что вы в неё положили, а от того, как далеко разъехались две линии истории.
Настоящая единица измерения: расхождение, а не количество веток
Стоимость интеграции определяется двумя величинами: сколько коммитов накопилось с обеих сторон от merge-base и насколько пересекаются затронутые области кода. Обе измеряются прямо в Git.
# Сколько коммитов "у них" и "у нас" с момента расхождения
$ git rev-list --left-right --count origin/main...feature/payments
137 42 # 137 уехали в main, пока я делал свои 42
$ git merge-base origin/main feature/payments
3f0c8ad91b2e5c7d4f8a06b13e29d5c7a4f0b8e2
# Файлы, которые изменили обе стороны, — кандидаты на конфликт
$ base=$(git merge-base origin/main HEAD)
$ comm -12 <(git diff --name-only "$base" HEAD | sort) \
<(git diff --name-only "$base" origin/main | sort)
src/billing/invoice.py
src/billing/tax.py
tests/test_invoice.py
Три файла в пересечении — это будни. Тридцать — это уже проект по интеграции на день, который никто не заложил в оценку.
Есть и вторая, невидимая для Git часть: семантические конфликты. Git сливает текст. Если коллега переименовал метод calculate_tax() в compute_tax() в одном файле, а вы добавили пятнадцать новых вызовов старого имени в другом файле, слияние пройдёт чисто, а сборка упадёт. Именно поэтому «мы просто будем аккуратно мержить раз в месяц» не работает: аккуратность не помогает против того, чего Git не видит. Помогает только частота интеграции — подробнее у Мартина Фаулера в Patterns for Managing Source Code Branches, это лучший из существующих текстов по теме; про механику и профилактику текстовых конфликтов — в «Конфликтах слияния».
Эмпирика, которую подтверждает любая команда, ведущая метрики: стоимость интеграции растёт быстрее линейного от времени жизни ветки. Ветка на день — почти бесплатно. Ветка на неделю — час на слияние и один упавший тест. Ветка на два месяца — «интеграционный спринт», где два человека неделю чинят чужой код и попутно теряют часть изменений.
Отсюда главный тезис статьи: все разумные стратегии ветвления — это разные способы ограничить расхождение. Дальше вопрос только в том, какими ограничениями вы платите.
Три топологии на одной шкале
Верхняя полоса — trunk-based: ветки короче суток, слияния постоянны, деплой отвязан от слияния. Средняя — GitHub Flow: ветка живёт столько, сколько ревьюится PR, деплой сразу после слияния. Нижняя — GitFlow: параллельно живут develop, release/*, hotfix/* и main. Обратите внимание на асимметрию: чем ниже полоса, тем больше мест, где линии должны сойтись обратно, и каждое такое место — потенциальная точка потери изменений («забыли смержить hotfix обратно в develop» — классика жанра).
GitHub Flow: ветка на задачу
Самая распространённая модель в продуктовых командах и почти безальтернативная в open source. Исходное описание — Scott Chacon, «GitHub Flow» (2011), и с тех пор оно почти не изменилось.
Правила, если сжать до сути:
mainвсегда деплоится. Это не пожелание, а инвариант, который обеспечивают branch protection и CI.- Любая работа — ветка от
mainс осмысленным именем. - Обсуждение идёт в Pull Request, а не в переписке. Как оформлять PR так, чтобы его ревьюили за час, а не за неделю, — в статье «Как создать хороший Pull Request».
- Слияние — только с зелёным CI и апрувом.
- Деплой — сразу после слияния (или слияние происходит через merge queue, который деплоит сам).
Где GitHub Flow ломается:
- Нет разделения «слито» и «выпущено». Если релиз — это подписанный артефакт, который уезжает к заказчику раз в квартал,
mainне может быть одновременно «всегда деплоится» и «то, что у клиента». - PR становится единицей планирования. Задача на две недели превращается в ветку на две недели, и вы получаете GitFlow без develop, но со всеми его болями. Лекарство — не сменить стратегию, а научиться резать задачи; см. секцию про feature flags ниже.
- Ревью превращается в узкое место. Если PR ждёт ревьюера три дня, средний возраст веток растёт, и расхождение растёт вместе с ним. Это организационная проблема, которую не чинят настройками Git; в «Совместной работе» разбираются конкретные приёмы масштабирования ревью.
Trunk-based development: что это на самом деле
Про trunk-based ходит две мифологии: «это когда все коммитят прямо в master без ревью» и «это то же самое, что GitHub Flow, только модно называется». Обе неверны.
Каноническое определение (см. trunkbaseddevelopment.com Пола Хаммента) допускает две формы:
- Committing straight to the trunk — прямые коммиты в
main, ревью пост-фактум или парным программированием. Работает в маленьких сильных командах и в некоторых крупных инженерных культурах (исторически — Google, Facebook с их собственными системами). - Short-lived feature branches — ветки, живущие менее суток, с обязательным ревью. Внешне неотличимо от GitHub Flow; отличие ровно одно и оно жёсткое: срок жизни ветки, а не «пока не будет готова фича».
То есть trunk-based — это дисциплина по времени, а не топология. Топология тривиальна: одна долгоживущая линия, всё остальное эфемерно. Задача на пять дней превращается не в ветку на пять дней, а в пять веток по дню; то, что нельзя показать пользователю, скрывается флагом или прячется за абстракцию.
Trunk-based не бесплатен. Он предъявляет требования, которые в большинстве команд и есть настоящая работа по внедрению:
| Требование | Почему без него не взлетит | Чем мерить |
|---|---|---|
| CI на PR быстрее ~10 минут | Иначе люди копят изменения, чтобы «не гонять пайплайн» | p95 времени пайплайна |
| Надёжные тесты (мало flaky) | Красный main → все останавливаются → возвращаются к длинным веткам | доля перезапусков |
| Feature flags | Иначе незаконченное нельзя мержить | число активных флагов и их возраст |
| Экспанд/контракт для схемы БД | Иначе миграция блокирует откат | наличие обратно совместимых миграций |
| Быстрый откат | Снижает цену ошибки в main | MTTR из DORA |
Последняя строка — не случайность. Исследование DORA («Accelerate», Forsgren, Humble, Kim) устойчиво связывает высокую производительность доставки с короткоживущими ветками и интеграцией в trunk минимум раз в день; актуальные отчёты — на dora.dev. Про сами метрики — в «DORA и инженерные метрики».
Feature flags и branch by abstraction — то, чем платят за короткие ветки
Ключевой размен: вы переносите незавершённость из веток в код. Вместо ветки, которая три недели держит полурабочую фичу, у вас в main лежит код, выключенный флагом.
@dataclass(frozen=True)
class Flags: # флаг — одна точка ветвления, а не if по всему коду
new_pricing_engine: bool = False # включается из env/конфига/сервиса флагов
def calculate_price(order, flags: Flags) -> Money:
if flags.new_pricing_engine:
return NewPricingEngine().price(order) # мержим в main выключенным
return LegacyPricing().price(order)
Плата за это честная и её надо назвать вслух:
- флаги — это ветвление в рантайме, то есть рост числа путей исполнения (два флага — четыре комбинации, и тестировать надо хотя бы значимые);
- каждый флаг обязан иметь владельца и дату удаления, иначе через год у вас код с сорока мёртвыми флагами;
- флаг для незаконченной фичи (release toggle) живёт дни-недели и удаляется; флаг для эксперимента или для клиента живёт долго — это разные сущности, не смешивайте их в одном механизме. Классификация — у Пита Ходжсона: Feature Toggles (aka Feature Flags).
Когда флаг неудобен (например, надо заменить целый слой доступа к данным), используют branch by abstraction: вводится интерфейс, старая реализация за ним, потом рядом появляется новая, потом переключение, потом удаление старой. Всё в main, маленькими коммитами. Описание приёма: martinfowler.com/bliki/BranchByAbstraction.html.
GitFlow: что он решал и почему стал мемом
Оригинальный пост Винсента Дриссена (2010) — самая цитируемая схема ветвления в истории. Важная деталь, которую редко читают: в 2020 году автор добавил к нему примечание, где прямо пишет, что модель придумана для софта с явными версиями и несколькими поддерживаемыми релизами, и что для непрерывно поставляемых веб-сервисов она, скорее всего, избыточна.
Структура:
Жизненный цикл release-ветки, где и живёт вся сложность:
Что GitFlow действительно даёт:
main= история релизов. Каждый коммит вmain— это выпущенная версия с тегом. Ответ на «что именно у клиента» находится за одну команду.- Стабилизация без заморозки разработки. Пока команда чинит RC в
release/1.1,developпродолжает принимать фичи для 1.2. - Явное место для hotfix. Ветка от тега, а не от текущего
developс двумя неделями непроверенных изменений.
Что он стоит:
- Минимум два обязательных обратных слияния на релиз (
release → develop,hotfix → developи во все живые release-ветки). Пропущенное обратное слияние = регрессия, которая вернётся в следующем релизе. Это самая частая авария в GitFlow-командах. develop— долгоживущая ветка, а значит расхождение сmainкопится по определению.- Комбинаторика cherry-pick при нескольких поддерживаемых версиях: один фикс надо перенести в 1.1, 1.2 и
develop, и каждый перенос — отдельный конфликт.
Честный вывод: GitFlow не «плохой». Он даёт корректный ответ на вопрос, который у большинства продуктовых команд не стоит. Если вы деплоите веб-сервис по десять раз в день, вы платите налог за проблему, которой у вас нет. Если вы отгружаете десктопное приложение, прошивку или on-premise-платформу с поддержкой трёх мажорных версий — вы платите за реальную проблему, и заменить это «просто мержьте чаще» нельзя.
Release-ветки без GitFlow: release train
Промежуточный вариант, который на практике покрывает большинство «нам всё же нужны релизы» случаев. Долгоживущая ветка ровно одна — main. Релиз — это срез, а не отдельная линия разработки.
# 1. Срез релиза: ветка от проверенного коммита main + аннотированный тег
$ git switch -c release/2.14 origin/main && git push -u origin release/2.14
$ git tag -a v2.14.0 -m "Release 2.14.0" && git push origin v2.14.0
# 2. Фикс делается в main и переносится в релиз, а не наоборот
$ git switch release/2.14
$ git cherry-pick -x 7d21b95 # -x допишет "(cherry picked from commit ...)"
$ git tag -a v2.14.1 -m "Release 2.14.1"
# 3. Проверка, что фикс доехал: где он есть и чего в релизе не хватает
$ git branch -a --contains 7d21b95
* main
release/2.14
$ git log --oneline --no-merges main..release/2.14 # в идеале пусто
$ git cherry -v main release/2.14 | head # сравнение по patch-id
+ 9b1d77f fix(billing): correct rounding for EUR # "+" — ещё не перенесён
- c08e4f1 fix(ui): tooltip overflow # "-" — эквивалент уже есть
Направление «сначала в main, потом cherry-pick в релиз» принципиально: оно гарантирует, что фикс не потеряется в следующей версии. Обратное («починим в релизе, потом смержим назад») — источник классических регрессий. При этом git cherry и git patch-id сравнивают содержимое патчей, а не хэши, поэтому перенесённые изменения находятся даже после rebase — это единственный надёжный способ ответить на вопрос «мы точно ничего не забыли в релизе».
Fork-based и patch-based: полнота картины
Всё вышеперечисленное предполагает общий репозиторий с правом записи. Есть ещё две модели, которые редко попадают в корпоративные сравнения, но именно на них работает половина инфраструктуры мира.
Fork-based — тот же GitHub Flow, но контрибьютор не имеет прав на запись и работает в собственном форке. Patch-based (ядро Linux, сам Git, проекты на mailing list) обходится вообще без сервера pull request’ов:
$ git remote -v
origin git@github.com:my-user/project.git (push) # мой форк
upstream git@github.com:org/project.git (fetch) # апстрим
$ git fetch upstream
$ git switch -c fix/typo upstream/main # ветвимся от свежего upstream, а не от форка
# patch-based: единица обмена не ветка, а патч
$ git format-patch -3 --cover-letter -o outgoing/
$ git send-email outgoing/*.patch --to=maintainer@example.org
$ git am < patch.mbox # на стороне мейнтейнера
Ревью в patch-based происходит построчно в почте. Правила подачи описаны в Submitting patches: the essential guide — этот документ полезно прочитать даже тем, кто никогда не пришлёт патч в ядро: требования к атомарности коммитов там сформулированы жёстче, чем в любом корпоративном гайде.
Как выбрать: три вопроса вместо холивара
Выбор определяется не вкусом, а ответами на три вопроса о продукте.
одновременно в проде?"} Q1 -- "одна, SaaS" --> Q2{"Можем ли деплоить
много раз в день?"} Q1 -- "несколько: on-prem,
мобилки, прошивки" --> R1["Release-ветки обязательны:
release train или GitFlow"] Q2 -- "да, CI быстрый,
тесты надёжные" --> Q3{"Готовы к feature flags
и суточным веткам?"} Q2 -- "нет: ручная приёмка,
долгий регресс" --> R2["GitHub Flow + release-ветка
для стабилизации"] Q3 -- "да" --> R3["Trunk-based"] Q3 -- "пока нет" --> R4["GitHub Flow,
возраст PR не больше 2 дней"] R4 -.-> R3 R2 -.-> R4
Пунктирные стрелки — не украшение: это реальный маршрут эволюции. Никто не переходит с GitFlow на trunk-based за один спринт; переходят через промежуточные состояния, каждое из которых само по себе улучшение. Та же логика в координатах, если удобнее видеть картину целиком:
Обратите внимание: мобильное приложение — не SaaS. Пользователь обновляется когда захочет, у вас в проде одновременно живут пять версий клиента, а релиз проходит через ревью в сторе. Поэтому даже команды, исповедующие trunk-based на бэкенде, на мобильном клиенте держат release-ветки. Это не непоследовательность, а разные ограничения.
Измерьте свою команду, а не чужие статьи
Перед тем как менять стратегию, полезно узнать, какая у вас на самом деле. Псевдокод замера: для каждого merge-коммита M взять первого родителя P1 (линия trunk) и второго P2 (ветка); найти коммиты, достижимые из P2, но не из P1; время жизни = дата(M) − дата самого раннего из них; собрать распределение.
#!/usr/bin/env python3
"""Распределение времени жизни веток по merge-коммитам основной линии."""
import subprocess
from datetime import datetime
from statistics import median
def git(*args: str) -> str:
return subprocess.run(("git", *args), capture_output=True, text=True, check=True).stdout
def ts(iso: str) -> datetime:
return datetime.fromisoformat(iso.strip())
def branch_lifetimes(ref: str = "origin/main", since: str = "6 months ago") -> list[float]:
merges = git("log", ref, f"--since={since}", "--merges", "--format=%P %cI").splitlines()
out: list[float] = []
for line in merges:
*parents, merged_at = line.split()
if len(parents) != 2: # octopus-слияния пропускаем
continue
trunk, branch = parents
uniq = git("log", f"{trunk}..{branch}", "--format=%cI").strip().splitlines()
if uniq: # uniq[-1] — самый ранний коммит ветки
out.append((ts(merged_at) - ts(uniq[-1])).total_seconds() / 86400.0)
return out
if __name__ == "__main__":
days = sorted(branch_lifetimes())
if not days:
raise SystemExit("нет merge-коммитов: возможно, у вас fast-forward-политика")
p90 = days[int(len(days) * 0.9) - 1]
print(f"веток: {len(days)} медиана: {median(days):.1f} дн p90: {p90:.1f} дн")
print(f"старше 3 дней: {sum(1 for d in days if d > 3) / len(days):.0%}")
Сложность: git log --merges — один проход по истории, O(n) по числу коммитов; внутри цикла по одному вызову git log trunk..branch на каждое из m слияний, каждый в худшем случае O(n) — итого O(m·n) по времени и O(m) по памяти под список величин. Для репозитория на 50 000 коммитов и 2000 слияний это секунды; для регулярного отчёта разумно переписать на один git rev-list и обход графа в памяти.
Интерпретация цифр без иллюзий:
- медиана до 1 дня, p90 до 2 дней — вы фактически делаете trunk-based, как бы это ни называлось в вики;
- медиана 3–5 дней — GitHub Flow в нормальном виде; узкое место, скорее всего, в скорости ревью;
- p90 больше 15 дней — у вас длинные ветки, и любые разговоры про стратегию надо начинать с вопроса, почему задачи не режутся.
Полезно посмотреть и на «мусор» — брошенные ветки, которые никто не удалил:
$ git fetch --prune
$ git for-each-ref --sort=committerdate \
--format='%(committerdate:short) %(refname:short) %(authorname)' refs/remotes/origin | head
2024-11-02 origin/feature/old-checkout Ivan Petrov
2025-01-17 origin/spike/graphql Anna Sidorova
$ git branch --merged origin/main | grep -vE '^\*|main|develop' | xargs -r git branch -d
Что настроить, чтобы стратегия работала не только на бумаге
Стратегия ветвления, не подкреплённая автоматикой, деградирует за месяц. Минимальный набор:
На стороне сервера (GitHub/GitLab/Gitea):
- защита
main: запрет прямого push, обязательный PR, обязательные проверки CI, запрет force-push; - обязательное разрешение обсуждений и минимум один апрув; для чувствительных каталогов — CODEOWNERS;
- merge queue — если в репозиторий пишет больше десятка человек. Очередь проверяет не ваш PR отдельно, а результат его слияния с уже принятыми в очереди, и тем закрывает дыру «оба PR зелёные по отдельности, вместе ломают main»; красная комбинация выкидывает PR из очереди, не трогая
main. Документация: Managing a merge queue (GitHub Docs), у GitLab это Merge Trains; - автоудаление ветки после слияния — иначе список веток превращается в свалку.
На стороне разработчика (эти настройки убирают половину бытовых аварий):
$ git config --global pull.rebase true # не плодить мусорные merge-коммиты в своей ветке
$ git config --global rebase.autoStash true # не спотыкаться о незакоммиченные правки
$ git config --global fetch.prune true # чистить ветки, удалённые на сервере
$ git config --global push.autoSetupRemote true # git 2.37+, убирает --set-upstream
# Безопасный force: не перезапишет чужой push, появившийся после вашего fetch
$ git push --force-with-lease --force-if-includes
--force-with-lease вместо --force — не педантизм, а единственная защита от затирания чужой работы в общих ветках; подробности и подводные камни — в «Переписывании истории».
Именование веток. Соглашение важно не эстетикой, а тем, что по префиксу можно автоматизировать: запускать разные пайплайны, автоматически связывать с задачами, чистить старое.
feature/PROJ-1234-short-slug # новая функциональность
fix/PROJ-1235-npe-on-checkout # багфикс
release/2.14 # релизная ветка (защищена)
hotfix/2.14.1-invoice-rounding # срочный фикс от тега
# Ограничения задаёт git check-ref-format: нельзя "..", пробелы, ~^:?*[, суффикс .lock.
# Ловушка: ветки feature и feature/login несовместимы — имя ссылки это путь в файловой системе
$ git switch -c feature && git switch -c feature/login
fatal: cannot lock ref 'refs/heads/feature/login': 'refs/heads/feature' exists;
cannot create 'refs/heads/feature/login'
Автоматические проверки имён и сообщений коммитов удобно вешать на хуки — см. «Хуки и автоматизация».
Rebase или merge: почему это спор не про Git
Этот вопрос всплывает в каждом обсуждении ветвления, поэтому обозначим позицию здесь, а механику разберём в следующей статье.
С точки зрения модели данных разница простая: merge создаёт коммит с двумя родителями и сохраняет фактический DAG; rebase создаёт новые коммиты с новыми хэшами и делает историю линейной, теряя информацию о том, когда работа велась параллельно. Обе операции корректны, обе не теряют содержимое, если ими правильно пользоваться.
Реальный предмет спора — три разных вопроса, которые склеивают в один:
- Что должно быть видно в истории
main? Линейная история читается и бисектится проще (git bisectна линейной истории даёт понятный ответ). История с merge-коммитами честнее отражает, как шла работа, и позволяет откатить фичу целиком однимgit revert -m 1. - Кто платит за конфликт? При rebase автор ветки решает конфликты сам, до слияния, столько раз, сколько коммитов. При merge конфликт решается один раз, но результат видят все.
- Можно ли переписывать опубликованное? Здесь ответ не вкусовой: переписывать историю ветки, на которую кто-то ссылается, нельзя без явной договорённости.
Практический компромисс, который работает в большинстве команд: rebase внутри своей ветки до ревью, merge (или squash) при попадании в main, никогда не rebase публичных веток. Squash merge даёт линейную историю и один коммит на задачу, но склеивает промежуточные шаги — приемлемо, если ваши PR маленькие, и разрушительно, если PR на 3000 строк. Заметьте: все три вопроса — про процесс и соглашения команды, а не про свойства Git. Именно поэтому спор не решается аргументом «rebase лучше»: у команды с PR по 50 строк и у команды с PR по 2000 строк правильные ответы разные. Разбор механики — в «Merge и rebase».
Антипаттерны, которые встречаются чаще всего
Ветки под окружения (dev, staging, production как долгоживущие линии). Кажется логичным: «код в ветке = код в окружении». На практике окружения расходятся, cherry-pick между ними становится рутиной, и никто уже не может сказать, что именно в проде. Правильно: одна линия кода, разные артефакты и конфигурации для окружений; продвижение версии — это деплой того же артефакта, а не слияние веток. См. «CD и стратегии релиза».
Ветка на разработчика (ivan, anna). Персональная ветка живёт вечно, ревью нет, интеграция происходит раз в месяц. Это распределённый SVN, а не Git.
Долгоживущий develop без release-веток. Половина GitFlow: обратные слияния уже нужны, а выгоды от стабилизации ещё нет. develop расходится с main, и «что у клиента» перестаёт быть очевидным. Если у вас нет нескольких поддерживаемых версий — удалите develop.
Release-ветка как staging. В неё начинают вливать новые фичи «раз уж всё равно тестируем». Через две недели release-ветка и есть разработка, а main мёртв.
Ветка живёт, пока не готова фича. Самый дорогой и самый распространённый. Лечится не Git’ом, а декомпозицией задач и флагами; см. секцию про trunk-based выше.
Cherry-pick вместо слияния как норма. Один-два переноса в релиз — норма. Cherry-pick как основной способ доставки изменений между ветками означает, что ветки навсегда разошлись по содержимому: одинаковые изменения имеют разные хэши, и Git больше не может помочь ни merge-base, ни git log branchA..branchB.
Долгоживущая ветка «рефакторинг». Отдельно, потому что она убивает больше времени, чем все остальные. Рефакторинг, затрагивающий много файлов, конфликтует со всем подряд; через месяц её невозможно смержить. Правильный способ — branch by abstraction маленькими шагами в main.
Как выглядит переход на практике
Миграция с GitFlow на короткие ветки — это не смена конфигурации, а последовательность технических улучшений. Порядок важен: если начать с отмены develop, не ускорив CI, команда получит красный main и вернётся обратно за неделю.
Две вещи, которые чаще всего забывают:
- Сначала измерение, потом изменение. Без базовой линии по возрасту PR и lead time вы не докажете ни себе, ни менеджменту, что стало лучше.
- Схема БД. Короткие ветки требуют, чтобы миграции были обратно совместимыми (expand/contract: сначала добавить колонку и писать в обе, потом переключить чтение, потом удалить старую). Иначе откат релиза невозможен, а без отката вся дисциплина коротких веток теряет смысл. Про эволюцию схемы — в треке про базы данных и в «Тестах в CI» про то, как это проверять.
Отдельный случай: монорепозиторий
В монорепозитории выбор фактически предопределён: когда в одном репозитории живут двести сервисов, любая ветка на неделю расходится с сотнями чужих изменений. Поэтому все известные крупные монорепы работают в trunk-based-режиме с обязательным merge queue и большими вложениями в скорость сборки и выборочное тестирование затронутых частей; инструменты (sparse-checkout, partial clone, git maintenance) — в «Больших репозиториях и монорепо». Обратная ситуация — репозиторий на сервис — снимает проблему расхождения внутри репозитория, но переносит её на уровень совместимости версий между репозиториями, где Git уже не помощник.
Мини-итог
- Ветка — файл на 41 байт со ссылкой на коммит. Ветки бесплатны; платите вы за расхождение, и оно растёт быстрее линейного от времени жизни ветки.
- Измерять расхождение можно прямо в Git:
git rev-list --left-right --count, пересечение изменённых файлов черезmerge-base, распределение времени жизни веток по merge-коммитам. - GitHub Flow — разумный дефолт для продуктовой команды: одна долгоживущая линия, ветка на задачу, PR как единица ревью.
- Trunk-based — то же самое плюс жёсткое ограничение «ветка живёт меньше суток»; требует быстрого CI, надёжных тестов, feature flags и обратимых миграций. Это дисциплина, а не топология.
- GitFlow оправдан там, где релиз — версионируемый артефакт и одновременно поддерживается несколько версий. В остальных случаях вы платите обратными слияниями за проблему, которой у вас нет.
- Release train (одна
main+ срезыrelease/*+ cherry-pick фиксов изmain) закрывает большинство ситуаций «нам нужны релизы», не заводя вторую линию разработки. - Спор rebase vs merge — про соглашения команды и размер PR, а не про Git. Рабочий компромисс: rebase внутри своей ветки, merge или squash в
main, никогда не переписывать публичные ветки. - Стратегия без автоматики (защита веток, обязательный CI, merge queue, автоудаление веток, единые настройки
pull.rebase/fetch.prune) деградирует за месяц.
Источники
- Martin Fowler. Patterns for Managing Source Code Branches — самая полная систематизация паттернов ветвления.
- Vincent Driessen. A successful Git branching model — оригинальный GitFlow вместе с авторским примечанием 2020 года.
- Scott Chacon. GitHub Flow.
- Paul Hammant. Trunk Based Development — включая разбор коротких веток и branch-by-abstraction.
- Pete Hodgson. Feature Toggles (aka Feature Flags).
- Pro Git, глава Branching Workflows; документация git-rev-list и git-cherry; GitLab Flow как вариант с ветками окружений.
- Forsgren, Humble, Kim. «Accelerate» и отчёты DORA — эмпирика связи коротких веток и производительности доставки; Submitting patches — процесс ядра Linux как эталон patch-based модели.
- Базовое введение в Git на портале: «Git»; оформление PR: «Pull Request».
Что дальше
Мы разобрали топологию: кто, куда и как долго ветвится. Осталось понять, что физически происходит в момент, когда две линии сходятся обратно, — почему merge создаёт коммит с двумя родителями, что именно делает rebase с объектами, как работает трёхсторонний алгоритм слияния и в каких случаях выбор между ними действительно меняет результат, а не только картинку в логе.
Merge и rebase: как работают на самом деле и что выбрать команде