rebase или merge: стратегии истории команды

Урок про то, какую форму истории поддерживать в команде и как выбирать между merge, squash и rebase при слиянии Pull Request.

rebase переписывает коммиты ветки так, будто они сделаны поверх свежего основания, давая линейную историю; merge объединяет ветки merge-коммитом, сохраняя их реальное ветвление.

Зачем командам спорить про историю

История Git — это не только «как было на самом деле», но и инструмент: по ней ищут, когда и зачем появилось изменение, делают git bisect, читают changelog, откатывают релизы. Форма истории влияет на удобство всего этого. Два полюса — линейная (плоская цепочка коммитов) и ветвистая (видны все слияния и параллельная работа). Выбор между ними и есть выбор между rebase и merge.

merge: честная, но ветвистая история

Обычное слияние создаёт merge-коммит с двумя родителями — он фиксирует факт интеграции ветки. История показывает реальную топологию: вот ветка ответвилась, вот вернулась.

git checkout main
git merge feature/search
# в истории появляется merge-коммит "Merge branch 'feature/search'"

Плюс — ничего не теряется и не переписывается, видно, что делалось параллельно. Минус — при многих ветках граф превращается в «спагетти» из десятков merge-коммитов, и линейно читать его тяжело.

rebase: плоская, читаемая история

Rebase берёт коммиты вашей ветки и «переносит» их поверх свежего main, как будто вы только что начали от его вершины. Merge-коммита нет, история — прямая линия.

git checkout feature/search
git fetch origin
git rebase origin/main
# конфликты решаем по ходу:
# git add <файлы> && git rebase --continue

Получается чистая хронология, по которой удобно читать и делать bisect. Но rebase переписывает коммиты (у них меняются хеши), поэтому есть железное правило.

Золотое правило rebase

Не делай rebase веток, на которые опираются другие. Переписывать историю можно только у своей локальной, ещё не общей ветки.

Если вы перепишете уже опубликованную общую ветку, у коллег история разойдётся с вашей, и им придётся мучительно её чинить. Поэтому rebase общей ветки требует git push --force — а это сигнал «осторожно».

Слияние Pull Request: три стратегии

Платформы предлагают три способа влить PR в main:

СтратегияЧто попадает в mainИстория
Merge commitвсе коммиты ветки + merge-коммитветвистая, полная
Squash and mergeодин коммит = весь PRлинейная, по одному коммиту на PR
Rebase and mergeкоммиты ветки по одному, без merge-коммиталинейная, все коммиты сохранены

Squash особенно популярен: вся возня внутри ветки («wip», «фикс по ревью», «опечатка») схлопывается в один аккуратный коммит — и main читается как список фич. Если вы ещё и придерживаетесь Conventional Commits, заголовок такого squash-коммита идёт прямо в changelog. Удобный побочный эффект: один коммит на PR делает git revert тривиальным — откатить фичу целиком можно одной командой, не выискивая её разрозненные коммиты. Платой становится потеря промежуточных шагов: если внутри фичи была важная для bisect середина, после squash вы её уже не разглядите.

Выбор стратегии — это не вопрос вкуса одного человека, а командное решение, единое на репозиторий. Когда правило одно, история предсказуема, инструменты настраиваются один раз, а новые участники быстро понимают, как тут принято. Многие платформы позволяют зафиксировать выбор в настройках: оставить разрешённой только одну кнопку слияния и тем самым убрать соблазн «в этот раз сделаю иначе». Полезно и автоматически удалять ветку после слияния — иначе репозиторий обрастает сотнями мёртвых веток, в которых невозможно ориентироваться.

# через GitHub CLI можно явно выбрать стратегию слияния PR
gh pr merge 128 --squash --delete-branch
gh pr merge 129 --merge        # классический merge-коммит
gh pr merge 130 --rebase       # rebase-слияние

Монорепо: почему там чаще линейная история

В монорепозитории (много проектов в одном репо) сходятся изменения десятков команд. Если каждый PR оставляет merge-коммит и всю внутреннюю возню, общая история быстро становится нечитаемой. Поэтому в монорепо часто принимают squash на влив и линейную историю: один коммит на PR, по которому легко найти, что и зачем изменилось, и удобно делать git bisect по всему гигантскому дереву. Нередко это закрепляют настройкой ветки require linear history, которая запрещает merge-коммиты в защищённой ветке.

Как это работает под капотом

Коммит в Git неизменяем и идентифицируется хешем своего содержимого, включая ссылки на родителей. merge создаёт новый коммит с двумя родителями — старые коммиты не трогаются, поэтому это безопасная операция. rebase же берёт ваши коммиты и создаёт их копии поверх нового основания: содержимое то же, но родитель другой, значит и хеш другой — фактически это новые объекты, а старые становятся недостижимыми. Именно поэтому переписывание общей истории опасно: ссылки у коллег указывают на старые (теперь «осиротевшие») коммиты. squash — это вырожденный случай: все изменения ветки сворачиваются в один новый коммит с одним родителем.

Частые ошибки

  • Rebase общей ветки и force-push. Самая болезненная ошибка: переписали историю, на которую опирались другие, — у всех всё разъехалось.
  • Squash там, где важна детальная история. Для долгой фичи иногда ценны отдельные коммиты (для bisect внутри неё); тогда squash «слепляет» их и теряет гранулярность.
  • Force-push без --force-with-lease. Голый --force может затереть чужие коммиты, которые подъехали в ветку; --force-with-lease сначала проверяет, что вершина не сдвинулась.
  • Смешивать стратегии в одном проекте бессистемно. Когда часть PR мёржится merge-коммитом, часть squash, часть rebase, история становится непредсказуемой. Договоритесь об одной стратегии на репозиторий.
  • Решать конфликты rebase наугад. При rebase конфликты разрешаются для каждого коммита по очереди; если делать это бездумно, легко «потерять» правки между коммитами.

Итоги

  • merge сохраняет реальное ветвление (merge-коммит), но даёт ветвистую историю.
  • rebase даёт линейную читаемую историю, но переписывает коммиты — нельзя применять к общим веткам.
  • Слияние PR: merge commit (полная история), squash (один коммит на PR), rebase (линейно, все коммиты).
  • Squash + Conventional Commits отлично читаются и кормят changelog.
  • В монорепо обычно выбирают линейную историю (squash, require linear history) ради читаемости и bisect.
  • Force-push делайте только с --force-with-lease и только по своей неопубликованной ветке.
Проверьте себя
1. Почему опасно делать rebase ветки, на которую уже опираются другие участники?
Arebase удаляет ветку с сервера
Brebase переписывает коммиты (меняет их хеши), и история коллег расходится с переписанной
Crebase автоматически делает force-push во все ветки
Drebase ломает merge-коммиты в main
2. Что попадает в main при стратегии «Squash and merge»?
AВсе коммиты ветки плюс merge-коммит
BОдин коммит, в который схлопнут весь Pull Request
CТолько merge-коммит без содержимого
DКоммиты ветки по одному без merge-коммита
3. Почему в монорепозиториях часто предпочитают линейную историю (squash, require linear history)?
AЛинейная история занимает меньше места на диске
BИначе слияния десятков команд превращают историю в нечитаемые «спагетти», и труднее делать bisect
CGit не поддерживает merge-коммиты в больших репозиториях
DЛинейная история отключает необходимость в код-ревью
4. Какой флаг делает принудительный push безопаснее обычного --force?
A--force-now
B--force-with-lease
C--hard-force
D--safe-push