Fast-forward merge — линейная история
В Git есть две принципиально разные стратегии слияния веток: fast-forward merge (линейный) и three-way merge (с merge commit). В этом уроке — про первый. Это самая простая ситуация, и понимание её важно для понимания всех остальных.
После урока вы будете спокойно понимать, когда Git делает fast-forward, когда не делает, как принудить или запретить fast-forward, и почему --ff-only — безопасный default для junior.
Когда возможен fast-forward
Fast-forward возможен, когда история двух веток линейна — то есть ветка, в которую мы мержим, не имеет никаких новых коммитов с момента, когда от неё «отпочковалась» вторая ветка.
Представим типичную ситуацию:
При git merge feature/x Git замечает: «main — это предок feature/x. Можно просто переместить указатель main на C4». Никакого нового коммита не нужно — это и есть fast-forward: «перемотка ветки вперёд».
С точки зрения файлов на ветке main: до merge на ней было состояние C1, после merge — состояние C4. Все коммиты C2, C3, C4 теперь «принадлежат» истории main.
Когда fast-forward невозможен
Если на ветке main появились коммиты после создания feature — история не линейна, fast-forward невозможен:
В этой ситуации Git создаёт merge commit с двумя parent: M1 (main) и C3 (feature/x). Это и есть three-way merge — следующий урок.
Команда git merge: что именно происходит
Полный flow для fast-forward:
# Создаём ситуацию
mkdir -p ~/git-sandbox/lesson-04-ff
cd ~/git-sandbox/lesson-04-ff
git init
echo "initial" > a.txt && git add . && git commit -m "C1: initial"
# Создаём feature-ветку
git switch -c feature/x
echo "feature" > b.txt && git add . && git commit -m "C2: add b"
echo "more" >> b.txt && git add . && git commit -m "C3: extend b"
# Смотрим — main всё ещё на C1, feature/x на C3
git log --oneline --all --graph
# * <C3> feature/x: C3: extend b
# * <C2> feature/x: C2: add b
# * <C1> main: C1: initial
Теперь делаем merge:
git switch main
git merge feature/x
# Updating <C1>..<C3>
# Fast-forward
# b.txt | 2 ++
# 1 file changed, 2 insertions(+)
Заметьте — Git явно говорит «Fast-forward». Это и есть тип merge.
git log --oneline --all --graph
# * <C3> (HEAD -> main, feature/x) C3: extend b
# * <C2> C2: add b
# * <C1> C1: initial
main теперь на C3 — тот же коммит, что feature/x. Никакого merge commit нет — история линейная.
Принуждение fast-forward: —ff-only
Иногда вы хотите гарантировать, что merge будет именно fast-forward. Если ситуация не позволяет — пусть лучше Git откажется, чем создаст merge commit.
git merge --ff-only feature/x
Если возможен FF — будет FF. Если невозможен — ошибка:
# Если main впереди от merge base:
git merge --ff-only feature/x
# fatal: Not possible to fast-forward, aborting.
В таком случае вы решаете руками: либо git rebase для линейности, либо git merge --no-ff для merge commit (об этом дальше).
--ff-only — это безопасный режим для junior. Если вы хотите гарантию, что история останется линейной — используйте.
Запрет fast-forward: —no-ff
Обратное: «никогда не делай fast-forward, всегда создавай merge commit, даже если линейность возможна».
git merge --no-ff feature/x
Зачем? Чтобы сохранить факт ветвления в истории. Когда вы делаете много мелких коммитов на feature, потом merge с —no-ff, в истории остаётся группа:
С --no-ff:
- В
git log --first-parent mainвы видите только «крупные» события: «merged feature/x», «merged feature/y» — структура продукта. - Коммиты внутри feature остаются доступными, но не засоряют main.
- При нужде можно сделать
git revertна merge commit и откатить всю фичу одной командой.
Это и есть GitHub Flow с merge commits — популярная стратегия. Подробно — модуль 13.
В Pull Request на GitHub есть три кнопки слияния: «Create a merge commit» (—no-ff), «Squash and merge» (объединить все коммиты PR в один и применить), «Rebase and merge» (rebase + fast-forward). Выбор зависит от культуры команды. Подробно — модуль 12.
Конфигурация поведения по умолчанию
Можно настроить, что Git делает при git merge без явных флагов:
# Force --ff-only: ошибка вместо merge commit
git config --global merge.ff only
# Force --no-ff: всегда merge commit
git config --global merge.ff false
# Default: fast-forward когда возможен, merge commit когда нет
git config --global merge.ff true # или unset
И для pull (мы это делали в модуле 3):
# Безопасный pull для junior
git config --global pull.ff only
При git pull --ff-only: либо чисто fast-forward, либо ошибка. Никаких автоматических merge commits.
Визуализация в git log
Включите --graph --oneline --all в git log — будет видна структура:
git log --graph --oneline --all
Fast-forward merge:
* <C4> (HEAD -> main, feature/x) Latest
* <C3> Another
* <C2> Earlier
* <C1> Initial
Three-way merge с —no-ff:
* <M> (HEAD -> main) Merge branch 'feature/x'
|\
| * <C4> (feature/x) Latest
| * <C3> Another
| * <C2> Earlier
|/
* <C1> Initial
Разница: во втором случае виден «развилка» (|\ и |/) — графически очевидно, что была feature-ветка.
Полезный alias для красивого графа:
git config --global alias.lg \
"log --graph --oneline --decorate --all"
После этого git lg показывает компактный читаемый граф.
Best practices для junior
- Используйте
pull.ff = onlyв global config (мы делали в модуле 3). Защищает от случайных merge commits при pull. - На локальной работе делайте обычный
git merge— fast-forward, когда возможен, иначе явно решайте, как мержить. - При интеграции feature-ветки в main выбирайте
--no-ff: сохраняется факт ветвления, легче делать revert при необходимости. - В PR на GitHub — следуйте policy команды. Если нет — «Create a merge commit» безопасный default.
Попробуй сам
- Создайте репозиторий и сделайте fast-forward merge:
mkdir -p ~/git-sandbox/lesson-04-ff
cd ~/git-sandbox/lesson-04-ff
git init
echo "initial" > a.txt
git add . && git commit -m "Initial"
git switch -c feature/x
echo "feat1" > b.txt && git add . && git commit -m "Add b"
echo "feat2" > c.txt && git add . && git commit -m "Add c"
git switch main
git merge feature/x
# Должно сказать "Fast-forward"
git log --oneline --graph --all
- Сделайте —no-ff merge:
git switch -c feature/y
echo "feat3" > d.txt && git add . && git commit -m "Add d"
git switch main
git merge --no-ff feature/y -m "Merge feature/y"
# Создаст merge commit, откроет редактор или возьмёт -m
git log --oneline --graph --all
# Увидите развилку
- Создайте ситуацию, где FF невозможен:
git switch -c feature/z
echo "feat4" > e.txt && git add . && git commit -m "Add e"
git switch main
echo "main-changed" > a.txt
git add . && git commit -m "Update on main"
# Теперь и main, и feature/z имеют новые коммиты от общего предка
git merge --ff-only feature/z
# fatal: Not possible to fast-forward, aborting.
# Альтернатива: обычный merge с merge commit
git merge feature/z
# Создаст merge commit (потому что FF невозможен)
Что проверять на code review dbt-моделей