Git и командная работа Стратегии ветвления: trunk-based, GitFlow, GitHub Flow — что когда
0%

Стратегии ветвления: trunk-based, GitFlow, GitHub Flow — что когда

Стратегии ветвления: 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 читает и то, и другое.

Ветка как ссылка на коммит в DAG

Отсюда три следствия, которые перечёркивают половину интуиций, принесённых из SVN и TFS:

  1. Ветка не стоит ничего по месту и времени. Создать сто веток — записать 4100 байт. Никакого копирования рабочей копии, никакого «дерева» на сервере. Аргумент «давайте меньше веток, чтобы не раздувать репозиторий» — бессмысленный.
  2. Удаление ветки не удаляет коммиты. git branch -D стирает файл ссылки; объекты живут в базе, пока их держит reflog (по умолчанию 90 дней для достижимых и 30 для недостижимых, см. gc.reflogExpire). Именно поэтому работает восстановление из «Восстановление: reflog и потерянные коммиты».
  3. Ветка не «содержит» изменения. Она указывает на коммит, а коммит — на полный снимок дерева. «Изменения ветки» — это всегда вычисляемая величина: разница относительно общего предка, 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, GitFlow

Верхняя полоса — trunk-based: ветки короче суток, слияния постоянны, деплой отвязан от слияния. Средняя — GitHub Flow: ветка живёт столько, сколько ревьюится PR, деплой сразу после слияния. Нижняя — GitFlow: параллельно живут develop, release/*, hotfix/* и main. Обратите внимание на асимметрию: чем ниже полоса, тем больше мест, где линии должны сойтись обратно, и каждое такое место — потенциальная точка потери изменений («забыли смержить hotfix обратно в develop» — классика жанра).

GitHub Flow: ветка на задачу

Самая распространённая модель в продуктовых командах и почти безальтернативная в open source. Исходное описание — Scott Chacon, «GitHub Flow» (2011), и с тех пор оно почти не изменилось.

Правила, если сжать до сути:

  1. main всегда деплоится. Это не пожелание, а инвариант, который обеспечивают branch protection и CI.
  2. Любая работа — ветка от main с осмысленным именем.
  3. Обсуждение идёт в Pull Request, а не в переписке. Как оформлять PR так, чтобы его ревьюили за час, а не за неделю, — в статье «Как создать хороший Pull Request».
  4. Слияние — только с зелёным CI и апрувом.
  5. Деплой — сразу после слияния (или слияние происходит через 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 — этот документ полезно прочитать даже тем, кто никогда не пришлёт патч в ядро: требования к атомарности коммитов там сформулированы жёстче, чем в любом корпоративном гайде.

Как выбрать: три вопроса вместо холивара

Выбор определяется не вкусом, а ответами на три вопроса о продукте.

Пунктирные стрелки — не украшение: это реальный маршрут эволюции. Никто не переходит с 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 создаёт новые коммиты с новыми хэшами и делает историю линейной, теряя информацию о том, когда работа велась параллельно. Обе операции корректны, обе не теряют содержимое, если ими правильно пользоваться.

Реальный предмет спора — три разных вопроса, которые склеивают в один:

  1. Что должно быть видно в истории main? Линейная история читается и бисектится проще (git bisect на линейной истории даёт понятный ответ). История с merge-коммитами честнее отражает, как шла работа, и позволяет откатить фичу целиком одним git revert -m 1.
  2. Кто платит за конфликт? При rebase автор ветки решает конфликты сам, до слияния, столько раз, сколько коммитов. При merge конфликт решается один раз, но результат видят все.
  3. Можно ли переписывать опубликованное? Здесь ответ не вкусовой: переписывать историю ветки, на которую кто-то ссылается, нельзя без явной договорённости.

Практический компромисс, который работает в большинстве команд: 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) деградирует за месяц.

Источники

Что дальше

Мы разобрали топологию: кто, куда и как долго ветвится. Осталось понять, что физически происходит в момент, когда две линии сходятся обратно, — почему merge создаёт коммит с двумя родителями, что именно делает rebase с объектами, как работает трёхсторонний алгоритм слияния и в каких случаях выбор между ними действительно меняет результат, а не только картинку в логе.

Merge и rebase: как работают на самом деле и что выбрать команде

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов