8.1 Three-way merge: что внутри
Сценарий: на main есть свои коммиты после ветвления feat. На feat - тоже свои. Fast-forward невозможен (см. главу 6) - нужно настоящее слияние.
main: A → B → E ← на main был коммит E после развилки
\
feat: C → D ← на feat был C, D после развилки
Алгоритм three-way merge:
- Найти общего предка двух веток (
merge base). Здесь - коммит B. - Сравнить B с E: какие правки сделали на main.
- Сравнить B с D: какие правки сделали на feat.
- Объединить оба набора правок. Если правки на разных строках/файлах - без конфликта. Если на одних - конфликт, нужно разрешить руками.
- Создать merge-коммит M с двумя родителями: E и D.
main: A → B → E → M ← merge-коммит, два родителя: E, D
\ /
feat: C → D
Merge-коммит - единственный тип коммита в Git, у которого больше одного родителя. У обычного two-way merge их два, у octopus merge (слияние трёх и более веток за раз) - три и больше. По нему всегда видно, что здесь несколько историй слились.
git switch main
git merge feat
# Merge made by the 'ort' strategy.
Ort - это стратегия слияния по умолчанию с Git 2.34. Раньше
была recursive. Разница в скорости и обработке сложных
случаев; на 99% слияний результат идентичный.
См. merge.
8.1.1 Копнуть глубже: алгоритм слияния по строкам
Three-way merge на уровне отдельного файла сравнивает три версии: base (общий предок), ours (текущая ветка), theirs (сливаемая).
Для каждого блока строк решается:
- base = ours = theirs → нет изменений, оставить как есть.
- base = ours, theirs ≠ base → правка только на theirs, взять theirs.
- base = theirs, ours ≠ base → правка только на ours, взять ours.
- ours = theirs, оба ≠ base → обе ветки внесли одну и ту же правку, конфликта нет, взять любую.
- ours ≠ theirs, оба ≠ base → конфликт, нужно решать руками.
В пятом случае Git ставит маркеры:
<<<<<<< HEAD
print("из main")=======
print("из feature")>>>>>>> feature
Это сырой материал - выбирай нужный вариант, удаляй маркеры,
git add файл, git commit. Полезные опции:
git checkout --ours file.txt- принять полностью версию текущей ветки.git checkout --theirs file.txt- принять версию сливаемой.git merge --abort- отменить начатый merge, вернуться в исходное состояние.
8.2 Rebase: переписать историю
Rebase решает ту же задачу - «совместить две ветки» - но по-другому. Вместо создания merge-коммита, он переписывает коммиты одной ветки, делая вид, что они с самого начала ехали поверх другой.
до: main: A → B → E
\
feat: C → D
git switch feat
git rebase main
после: main: A → B → E
\
feat: C' → D' ← новые SHA, та же логика
Что произошло:
- Git нашёл общего предка (B), как и для merge.
- Запомнил коммиты ветки feat после развилки: C, D.
- Откатил feat на main (точку E).
- Применил каждый запомненный коммит по очереди. Получились новые коммиты C' и D' - с другими SHA (потому что у них другой родитель), но с той же логикой правок.
- Передвинул указатель feat на верхний из новых коммитов.
История ветки feat теперь линейна - выглядит так, будто её разработчик ждал, пока main обновится до E, и только потом начал свою работу.
Если в процессе rebase возник конфликт на каком-то коммите -
Git останавливается, дать разрешить, потом git rebase --continue. Можно --abort и вернуться в состояние до
rebase'а.
См. rebase.
8.3 Правило: не rebase'ить публичную ветку
После rebase у коммитов новые SHA. Для Git это другие объекты. Для тебя - те же изменения, переписанные поверх нового базиса.
Это безопасно, пока ветку никто не видел. После push коммиты становятся «публичными» - кто-то их склонил, у кого-то они базис для своей работы. Если ты теперь сделаешь rebase и force-push, у этого «кого-то» возникнет каша:
У него локально: A → B → C → D
Ты переписал: A → B → E → C' → D'
Что делать с C и D на его машине? Никто не знает.
Поэтому правило: rebase'ить только ту ветку, на которую, кроме тебя, никто не смотрит. Обычно это твоя локальная feature-ветка, ещё не запушенная, или запушенная только для ревью.
На main, master, develop, любую публичную ветку - rebase
запрещён. Хочешь обновить эти ветки - merge (fast-forward
или three-way, если расходится).
Параллельно: на GitHub/GitLab у этих веток обычно включено branch protection, которое блокирует force-push. Это техническая страховка от случайного rebase.
8.4 Какую брать
Прагматичный набор правил:
Перед PR - rebase'ь свою feature-ветку на свежий main. Это даёт линейную, легко ревьюимую историю. И снимает с ревьюера головную боль «здесь конфликт с main, разруливай».
При merge PR - стратегия зависит от культуры команды:
- Merge commit - стандартный three-way merge. История
показывает «вот тут была отдельная фича-ветка». Подходит
большим командам, где видеть факт ветвления полезно.
- Squash - все коммиты PR сливаются в один на main. История чистая, на каждую PR - один коммит. Подходит, если хочется видеть на main только «единицы поставки», без процессуальной мелочи.
- Rebase merge - rebase'ить коммиты PR на main без merge-коммита. Каждый коммит ветки оседает на main отдельно. Подходит, если коммиты внутри PR атомарные и интересны сами по себе.
Внутри своей работы - rebase разрешён и поощряется. Чем чище local history, тем приятнее работать.
Никогда не rebase'ь публичные ветки. Если случайно
рестейтнул main, и git pull не помогает разрулить - звони
коллегам, не пушь force.
Самый частый рабочий поток:
# Начало работы
git switch main
git pull --ff-only
git switch -c feat/something
# ... коммиты ...
# Перед push'ем - обновить main и rebase'ить себя
git switch main
git pull --ff-only
git switch feat/something
git rebase main
# Если был конфликт - разрулить, git rebase --continue
# Force-push (после rebase нужен force)
git push --force-with-lease
# Создать PR, дождаться merge - обычно через squash или
# rebase-merge на GitHub
8.5 Interactive rebase
git rebase -i <base> открывает редактор со списком
коммитов от <base> до HEAD. Можно их перенести, объединить,
переименовать, удалить.
git rebase -i main
Откроется примерно такое:
pick a1b2c3d add login form skeleton
pick d4e5f6a fix typo
pick 7a8b9c0 wire up backend
pick b1c2d3e address review comments
pick f4e5d6c another fix
# p, pick = использовать коммит как есть
# r, reword = использовать коммит, поменять сообщение
# e, edit = использовать коммит, остановиться для правки
# s, squash = слить с предыдущим коммитом (объединить сообщения)
# f, fixup = слить с предыдущим, отбросить сообщение
# d, drop = удалить коммит
Самое частое:
- Squash «процессуального шума» перед PR. Коммиты «fix typo», «address review», «another fix» сливаются в фикс по теме.
- Reword. Поправить сообщение коммита, который уже не
последний (последний правится через
--amend). - Drop. Удалить случайный коммит (например, debug-print).
- Перестановка. Если порядок коммитов нелогичен - переставить строки в редакторе. Git применит в новом порядке.
Пример: было 5 «грязных» коммитов, хочется получить 2 чистых:
pick a1b2c3d feat: add login form
squash d4e5f6a fix typo
squash 7a8b9c0 wire up backend
pick b1c2d3e fix: handle null in user
fixup f4e5d6c another fix
Результат: один коммит «feat: add login form» (с объединёнными правками всех трёх первых) и один «fix: handle null in user» (с подмешанным fixup, без отдельного сообщения).
После i-rebase у тебя новая локальная история. Если ветка
запушена - нужен push --force-with-lease.
См. interactive-rebase.
8.6 cherry-pick: перенести один коммит
Иногда нужно взять один коммит из другой ветки. Не merge'ить, не rebase'ить, не тащить всю историю - только этот один.
git switch main
git cherry-pick <sha-коммита-из-feat>
Git возьмёт правку этого коммита и применит на текущей ветке, создав новый коммит с тем же содержимым, но другим SHA (как при rebase). Сообщение коммита скопируется как есть.
Типичные сценарии:
- Hotfix в нескольких ветках. Починил баг на main - cherry-pick в release/v1.4 и release/v1.5.
- «Достать» полезный коммит из заброшенной ветки.
- Перенести коммит, ошибочно сделанный не в ту ветку. Из примера в главе 6.
Полезные опции:
git cherry-pick -n <sha> # применить правку, но не коммитить
# (--no-commit) - потом можно собрать
# с другими в один коммит
git cherry-pick <sha1>..<sha3> # диапазон коммитов
git cherry-pick --abort # отменить начатый cherry-pick
# (если был конфликт)
Конфликт при cherry-pick разрешается так же, как при merge:
разруливаешь, git add, git cherry-pick --continue.
Опасности:
- Дубль коммита. После cherry-pick один и тот же набор правок лежит в двух коммитах с разными SHA. Если потом merge'ить ветки между собой - Git это обычно понимает и не дублирует, но иногда вылетает конфликт «обе стороны внесли одинаковую правку».
- Зависимости. Cherry-pick переносит один коммит без его родителей. Если коммит опирается на функцию, добавленную в предыдущем коммите ветки-источника, - на ветке-цели её может не быть, и cherry-pick сломает сборку.
См. cherry-pick.
8.7 Сравнительная таблица
Для быстрого выбора:
| Сценарий | Использовать |
|---|---|
| Обновить main свежими коммитами от другого разработчика | git pull (fetch + merge fast-forward) |
| Слить feature в main | merge commit или squash (зависит от культуры команды) |
| Обновить свою feature свежим main перед PR | git rebase main |
| Переименовать или сжать коммиты в своей ветке | git rebase -i |
| Поправить только что сделанный коммит | git commit --amend |
| Перенести один коммит на другую ветку | git cherry-pick |
| Перенести цепочку коммитов на другую ветку | git rebase --onto или серия cherry-pick |
| Откатить коммит в публичной истории | git revert (см. главу 9) |
Эту таблицу полезно держать как шпаргалку первые пару недель, пока выбор не станет автоматическим.
8.7.1 Подводный камень: --onto для сложных пересадок
Базовый git rebase main берёт коммиты от merge-base до HEAD.
Иногда нужно пересадить только часть коммитов на другую
ветку. Для этого --onto:
git rebase --onto <new-base> <old-base> [<branch>]
Пример. У тебя:
main: A → B
\
feat-base: C
\
feat-detail: D → E
Хочется забрать D и E себе на main, оставив C ветке feat-base:
git switch feat-detail
git rebase --onto main feat-base
Git возьмёт коммиты от feat-base до HEAD (это D и E) и
применит их на main. Получится:
main: A → B → D' → E' ← HEAD теперь здесь
--onto редко нужен, но в сложных историях с цепочкой
зависимых веток - единственный чистый способ. Если в команде
используют stacked PRs (несколько связанных PR друг над другом)
--ontoприходится использовать часто.
Уроки в sandbox
lab-8.1. Один и тот же конфликт через merge и через rebase
Цель - увидеть разницу между merge и rebase глазами. Один и тот же сценарий проигрывается двумя путями, видно, что итог одинаковый, но история разная. Также - потренироваться в разрешении конфликтов.
Создай репозиторий и базовый коммит:
mkdir merge-rebase-lab && cd merge-rebase-lab && git init && echo "line 1" > note.txt && echo "line 2" >> note.txt && echo "line 3" >> note.txt && git add note.txt && git commit -m "base".Создай две ветки. main: поменяй
line 2наline 2 main, коммить. feat: создай заранее:git switch -c feat, поменяйline 2наline 2 feat, коммить. Сейчас обе ветки разошлись на одном и том же месте - гарантированный конфликт при слиянии.Прогон 1 - merge. Вернись на main, помержи feat:
git switch main && git merge feat. Будет конфликт. Открой файл, увидь<<<<<<<маркеры. Выбери, как должен выглядеть итог (например,line 2 main + feat), сохрани.git add note.txt && git commit(Git предложит готовое сообщение). Запустиgit log --oneline --graph --all- увидишь развилку и merge-коммит.Сбрось всё для повтора: запомни SHA коммита «base» (
git log --oneline), сделайgit reset --hard <sha>и удали ветку feat:git branch -D feat. Воссоздай feat от base:git switch -c feat <sha-base>, тот же коммит сline 2 feat.Воссоздай и состояние main:
git switch main && git reset --hard <sha-base>, новый коммит сline 2 main.Прогон 2 - rebase. Вместо merge, теперь rebase feat'a на main:
git switch feat && git rebase main. Будет тот же конфликт. Разреши так же, как в первом прогоне.git add note.txt && git rebase --continue. Запустиgit log --oneline --graph --all- увидишь линейную историю без merge-коммита, feat'овый коммит «приклеен» поверх main.Посмотри
git reflog- увидишь оба прогона как историю позиций HEAD. Этот лог спасает, если что-то пошло не так.Итог для головы: финальное содержимое
note.txtодинаковое, история разная. Merge оставил факт развилки. Rebase спрятал его, дав линейную цепочку.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Three-way merge ищет общего предка двух веток, объединяет правки относительно него, создаёт merge-коммит с двумя родителями.
- Rebase переписывает коммиты одной ветки, делая вид, что они сделаны поверх другого базиса. SHA коммитов меняются, история становится линейной.
- Главное правило: не rebase'ить публичные ветки. Force-push после rebase ломает работу всех, кто склонил эти коммиты.
- Перед PR - rebase своей feature-ветки на свежий main. При merge PR - стратегия зависит от культуры команды (merge commit / squash / rebase merge).
- Interactive rebase (`git rebase -i`) позволяет менять, объединять, переименовывать, удалять коммиты пачкой. Используется для очистки истории перед PR.
- Cherry-pick переносит один коммит на другую ветку, создавая дубликат с тем же содержимым, но новым SHA. Полезен для hotfix'ов в нескольких релиз-ветках.
- При конфликте в любой команде (merge / rebase / cherry-pick) разрешение одинаковое: править маркеры в файле, `git add`, `git <command> --continue`. Или `--abort` для отмены.
Контрольные вопросы
Я сделал rebase своей feature-ветки на main, попытался push - отказ. Почему?
Показать ответ
Потому что после rebase у твоих коммитов новые SHA. Для Git это другие объекты. Удалённая ветка содержит старые SHA, и обычный push отказывается перезаписать историю. Решение -
git push --force-with-lease(не--force!). Lease-вариант проверит, что remote не успел уйти вперёд (кто-то другой не запушил туда свои коммиты), иначе откажет. Это и есть штатный сценарий для feature-ветки: rebase → force-with-lease. Условие - на ветку никто, кроме тебя, не смотрит.В чём смысловая разница между merge-коммитом, squash и rebase-merge при закрытии PR на GitHub?
Показать ответ
Merge-коммит создаёт классический three-way merge: все коммиты PR прилетают на main как есть, плюс ещё один merge-коммит сверху с двумя родителями. История нелинейная, зато видно «здесь была ветка». Squash сжимает все коммиты PR в один на main: чистая линейная история, но детали PR (если они были атомарными) теряются. Rebase-merge применяет каждый коммит PR поверх main без merge-коммита: история линейная, коммиты сохраняются. Выбирай по тому, что важнее: видеть факт ветвления (merge), видеть «единицы поставки» (squash), или сохранять атомарные коммиты внутри PR (rebase-merge).
Я cherry-pick'нул коммит из feat в main. Теперь при merge feat → main возникает конфликт. Почему, если правка та же?
Показать ответ
Потому что после cherry-pick на main лежит коммит с новым SHA - он содержит те же правки, что и оригинал на feat, но для Git это другой объект. Когда ты пытаешься помержить feat, Git видит «обе ветки сделали правку в одних и тех же строках», и не всегда может автоматически догадаться, что это одна и та же правка. Чаще всего ort-стратегия с этим справляется, и конфликта нет. Если возникает - разруливай как обычный конфликт, выбирая любую из идентичных версий. Чтобы избегать ситуации: после cherry-pick в обе стороны делать осознанный rebase либо удалять одну из веток после cherry-pick.
В чём разница между `git rebase main` и `git rebase --onto main`?
Показать ответ
Да,
git rebase --abortполностью отменит rebase и вернёт ветку в состояние до начала операции. Никаких следов не останется, кроме записи в reflog (которой обычно никто не пользуется, кроме как для восстановления). Это полная страховка - если запутался, всегда можно--abortи попробовать заново, или вместо rebase сделать merge. Никогда не редактируй файлы.git/rebase-merge/или.git/rebase-apply/руками - это внутренние файлы rebase'а, их трогать только через--continueи--abort.