Three-way merge — слияние с merge commit
В предыдущем уроке мы разобрали fast-forward merge — линейный сценарий. Здесь — про three-way merge, который происходит, когда две ветки разошлись, и нужно явно их объединить с созданием merge commit. Это основной механизм слияния в реальной командной работе.
После урока вы будете понимать: что такое merge base, как Git понимает, что мержить, что такое merge commit с двумя parent, и почему git merge --no-ff — типичный выбор для PR в проде.
Когда нужен three-way merge
Three-way merge нужен, когда обе ветки имеют коммиты, не известные другой. Их история «разошлась» от общего предка:
В этом сценарии fast-forward невозможен: чтобы main догнала feature/x, нужно «потерять» коммиты M1, M2. Вместо этого Git создаёт merge commit — новый коммит с двумя parent.
Что такое merge base
Merge base — это самый недавний общий предок двух веток. Точка, где они «разошлись».
git merge-base main feature/x
# C0 SHA
Это plumbing-команда, которая показывает merge base. На практике вы её редко вызываете напрямую, но Git использует её при каждом merge.
Для simple-случаев merge base очевиден: точка, откуда вы создали feature-ветку. Но в сложных историях (когда обе ветки несколько раз мержились друг в друга) merge base — это нетривиальный вопрос. Git использует алгоритм для поиска best common ancestor.
Как Git делает three-way merge
Алгоритм:
Три источника — merge base, версия main, версия feature. Отсюда название «three-way».
Если в одном и том же месте файла обе ветки внесли изменения — Git не может выбрать сам и просит разрешить конфликт. Подробно про конфликты — модуль 7.
Если конфликтов нет — merge проходит автоматически:
git merge feature/x
# Auto-merging README.md
# Merge made by the 'ort' strategy.
# README.md | 4 ++++
# feature.py | 12 ++++++++++++
# 2 files changed, 16 insertions(+)
ort — это default merge strategy в Git 2.34+. Раньше был recursive. Для junior разницы нет — оба делают three-way merge.
Merge commit изнутри
Merge commit — это commit с двумя (или более) parent:
git cat-file -p HEAD
# tree abc1234...
# parent f9e8d7... ← первый parent: предыдущий main (M2)
# parent c5b6a4... ← второй parent: верхушка feature/x (F3)
# author Ivan <[email protected]> 1715600000 +0300
# committer Ivan <[email protected]> 1715600000 +0300
#
# Merge branch 'feature/x' into main
Первый parent — это куда мержили (целевая ветка). Второй parent — что мержили (исходная ветка). Этот порядок важен для:
git log --first-parent— показывает только историю по первому parent, игнорирует «внутренности» merged-featuregit revert -m 1 <merge-SHA>— откатывает merge commit, оставляя версию первого parent (—m 1 = «оставить версию первой родительской ветки»)
В git log с графом видна структура:
* M3 Merge branch 'feature/x' into main
|\
| * F3 Add OAuth flow
| * F2 Refactor auth
| * F1 Start feature
* | M2 Fix bug on main
* | M1 Update README
|/
* C0 Initial
Merge commit M3 — это «развилка» сходится в одну точку.
—no-ff: явный merge commit, даже если FF возможен
Сценарий из предыдущего урока: на main не было изменений, можно сделать fast-forward. Но мы хотим видеть факт merge в истории:
git merge --no-ff feature/x
Это создаст merge commit с двумя parent, даже если технически возможен fast-forward. Зачем:
Плюсы —no-ff:
- Группировка: видно, какие коммиты относятся к одной фиче.
- Atomic revert: можно откатить целую фичу одной командой
git revert -m 1 <merge-SHA>(модуль 10). - PR-style history: GitHub в опции «Create a merge commit» делает именно —no-ff.
Минусы:
- Больше merge commits в истории.
- Когда коммитов в feature мало — может выглядеть избыточно.
Большинство командных workflow используют —no-ff для merge feature-веток в main (см. GitHub Flow, модуль 13).
—squash: альтернатива merge commit
Третий вариант — объединить все коммиты feature-ветки в один коммит на main:
git merge --squash feature/x
git commit -m "Add OAuth flow (squashed)"
Это не настоящий merge — это применение всех изменений feature/x как один новый коммит на main. У результата:
- Один parent (как у обычного commit, не два)
- Все изменения feature/x объединены
- Коммиты feature/x не попадают в историю main
Когда что:
- Fast-forward: личная работа, простая линейная история.
- —no-ff: командная работа, явная история фич (рекомендуется для PR).
- —squash: когда коммиты на feature-ветке мусорные («wip», «fix typo», «try again») и хочется одного чистого коммита в main. Часто используется в GitHub PR через «Squash and merge».
Прерывание merge
Если merge запущен, и вы хотите его отменить:
git merge --abort
Это вернёт состояние до начала merge: HEAD на исходном месте, working tree без изменений merge.
--abort особенно полезен при конфликтах: вы видите масштаб проблемы, понимаете «не сейчас» — откатываетесь, делаете что-то ещё.
Просмотр истории с merge
# Граф с ветвлениями
git log --graph --oneline --all
# Только основная линия (без коммитов внутри feature-веток)
git log --first-parent main
# Полная подробная история включая merge
git log --merges # только merge commits
git log --no-merges # всё КРОМЕ merge commits
--first-parent особенно полезен для просмотра «крупных событий» в проекте:
git log --first-parent --oneline
# M3 Merge feature/x (OAuth flow)
# M2 Fix critical bug
# M1 Update docs
# C0 Initial
Это «как развивался продукт». А внутрь каждого merge — git log <merge-SHA>^1..<merge-SHA>^2 смотрите конкретные коммиты фичи.
Best practices для junior
- При работе в команде: feature-branches + Pull Request +
--no-ffmerge. Это стандартный паттерн. - Локально на personal-feature: можно
--ff-onlyдля линейности, можно--no-ffесли хочется видеть факт ветвления. - Никогда не делайте
git mergeна shared ветке без обсуждения с командой — это создаёт merge commit, и в монорепо со 100 разработчиками может быть культурой считаться плохим тоном. - При конфликтах — модуль 7. Сейчас если конфликт случайно —
git merge --abortи спросите помощи.
Попробуй сам
- Создайте ситуацию для three-way merge:
mkdir -p ~/git-sandbox/lesson-04-3way
cd ~/git-sandbox/lesson-04-3way
git init
echo "common" > shared.txt
git add . && git commit -m "C0: initial"
git switch -c feature/x
echo "feature stuff" > feature.txt
git add . && git commit -m "F1: add feature.txt"
echo "more feature" >> feature.txt
git add . && git commit -m "F2: extend feature.txt"
git switch main
echo "main update" > main-only.txt
git add . && git commit -m "M1: add main-only.txt"
# Состояние: обе ветки имеют свои коммиты от общего предка
git log --oneline --all --graph
- Merge — Git автоматически выберет three-way (FF невозможен):
git merge feature/x
# Git откроет редактор для merge commit message
# Или: git merge feature/x -m "Merge feature/x"
- Посмотрите результат:
git log --oneline --all --graph
# Должно показать развилку:
# * M2 Merge branch 'feature/x' into main
# |\
# | * F2 extend feature.txt
# | * F1 add feature.txt
# * | M1 add main-only.txt
# |/
# * C0 initial
- Посмотрите merge commit изнутри:
git cat-file -p HEAD
# Увидите 2 parent
- Попробуйте —first-parent log:
git log --first-parent --oneline
# Покажет только M2 (merge) и M1 — основная линия main
- Создайте ещё одну ветку и сделайте —squash:
git switch -c feature/y
echo "y1" > y1.txt && git add . && git commit -m "Y1"
echo "y2" > y2.txt && git add . && git commit -m "Y2"
git switch main
git merge --squash feature/y
git commit -m "Add y feature (squashed)"
git log --oneline --all --graph
# Squashed: один новый commit с объединёнными изменениями. feature/y коммиты в боковой линии (доступны, но не в main)
Идемпотентность пайплайнов: повторный запуск не ломает результат