voidlearn
 

3.1Слияние

Сравнишь fast-forward с трёхсторонним merge и проследишь роль общего предка. Разберёшь, когда нужен merge commit, как остановить слияние и почему повторный merge той же ветки не возвращает отменённые изменения.

Что сможешь объяснить после этой главы
  • Почему ветка ничего не стоит и когда git merge перематывает её без нового коммита.
  • Зачем слиянию общий предок и почему второе слияние той же ветки не приносит старые правки заново.
  • Что даёт истории --no-ff, где нужен --ff-only и как выйти из остановленного слияния, ничего не потеряв.
  • Чем -s ours отличается от -X ours и как ort обходится с переименованиями и с несколькими merge-base.

Ветка: два файла по одной строке

Примеры главы идут на Go-сервисе shop: Аня ведёт main, Боря пишет в отдельных ветках. Пока в репозитории два коммита, и второй, с обработчиком /health, лежит в ветке health:

$ git log --oneline --graph --all
* 9472581 (health) Добавить /health
* b22e07b (HEAD -> main) Создать модуль shop

Ветка хранит хеш одного коммита (глава 1.2). Проверим, какие файлы git меняет, когда ветку создают и когда на неё переключаются:

$ touch ../mark && git branch retry && find .git -newer ../mark -type f
.git/logs/refs/heads/retry
.git/refs/heads/retry
$ touch ../mark && git switch retry && find . -newer ../mark -type f
Switched to branch 'retry'
./.git/HEAD
./.git/logs/HEAD
./.git/index
$ git switch main && git branch -d retry
Switched to branch 'main'
Deleted branch retry (was b22e07b).

git branch записал 41 байт в файл ссылки и строку в её журнал (reflog, глава 5.2). Новых объектов нет, файлы проекта никуда не копировались. git switch переписал HEAD, его журнал и индекс, а рабочую копию не тронул: переключение обновляет файлы, которые в двух коммитах различаются (глава 1.3), а коммит здесь тот же. Ветка, под которой тысяча коммитов, стоит столько же.

Платить приходится позже: пока ветка живёт отдельно, main тоже уходит вперёд, и две линии надо свести. На входе у git merge три коммита: две вершины и их общий предок. Остальное git вычисляет по графу.

Fast-forward: перемотать указатель

Боря закончил /health, Аня вливает. Сначала спросим, что у веток общего:

$ git merge-base main health
b22e07be3256d6b7f2a23f69ccd998dd2474bf1f
$ git merge-base --is-ancestor main health; echo $?
0

merge-base, лучший общий предок двух коммитов, совпал с вершиной main. Значит, main целиком входит в историю health и сводить нечего. --is-ancestor задаёт тот же вопрос кодом возврата, 0 значит «да». Тогда git делает fast-forward, перемотку: переставляет ветку на вершину health и приводит к ней индекс и рабочую копию.

$ git merge health
Updating b22e07b..9472581
Fast-forward
 health.go | 7 +++++++
 main.go   | 1 +
 2 files changed, 8 insertions(+)
 create mode 100644 health.go
$ git reflog -2
9472581 (HEAD -> main, health) HEAD@{0}: merge health: Fast-forward
b22e07b HEAD@{1}: checkout: moving from retry to main
$ git merge health
Already up to date.

Нового коммита не появилось, обе ветки указывают на 9472581. По графу уже не скажешь, что работа шла в отдельной ветке: история линейная, будто Боря коммитил прямо в main. След остался в reflog, а reflog локальный. Повторное слияние ничего не делает, потому что теперь merge-base совпадает с вливаемой вершиной.

Весь выбор сводится к одному сравнению. merge-base равен вливаемой вершине: «Already up to date». Равен текущей: fast-forward. Не равен ни той, ни другой: истории разошлись, и нужно настоящее слияние.

Трёхсторонний merge

Ветку health Аня удалила. Второго сентября Боря завёл retry и научил сервис повторять запрос к базе, а Аня за это время сделала в main три коммита:

$ git log --oneline --graph main retry
* 7302ee9 (HEAD -> main) Переименовать db.go в store.go
* a7f2b2d Убрать флаг DebugSQL
* e51b996 Слушать порт 9090
| * cf9dac0 (retry) Поднять число повторов до 5
| * 259f46a Повторять запрос к базе
|/
* 9472581 Добавить /health
* b22e07b Создать модуль shop
$ git merge-base main retry
9472581c652970bbf7dddcc58725e64c9e3d21d2

Обе стороны правили config.go, и git diff main retry находит в нём четыре расхождения. Например, строка DebugSQL есть в retry и отсутствует в main. Боря её добавил или Аня удалила? По двум файлам этого не узнать. Нужна третья версия, с которой обе ветки начинали, то есть файл из merge-base. С ней каждое расхождение решается однозначно. Если от базы отошла одна сторона, берётся её версия, а одинаковую правку с двух сторон git вносит один раз. Конфликт получается, когда обе поменяли одно место по-разному (глава 3.2).

СтрокаБаза 9472581mainretryИтог
Addr":8080"":9090"как в базе":9090"
DebugSQLестьудаленакак в базеудалена
MaxRetries3как в базе55
RetryBackoffнеткак в базедобавленадобавлена

Это и есть трёхсторонний merge. Коммиты ветки по одному git не проигрывает. Он берёт три снимка, для каждой вершины считает, что в ней изменилось относительно базы, и накладывает обе разницы на базу, файл за файлом и кусок за куском:

$ git merge retry
Auto-merging config.go
Auto-merging store.go
Merge made by the 'ort' strategy.
 config.go |  3 ++-
 store.go  | 13 +++++++++++++
 2 files changed, 15 insertions(+), 1 deletion(-)
$ git log --oneline --graph -3 main
*   03180dc (HEAD -> main) Merge branch 'retry'
|\
| * cf9dac0 (retry) Поднять число повторов до 5
| * 259f46a Повторять запрос к базе

В терминале git сначала откроет редактор с сообщением коммита, а вывод появится, когда его закроешь. «Auto-merging» git печатает про файлы, которые менялись с обеих сторон и сводились построчно. Результат записан коммитом 03180dc с двумя родителями: первым стоит 7302ee9, где была main, вторым cf9dac0 (порядок разобран в главе 1.2). Сдвинулась только main. Статистика посчитана от первого родителя, то есть показывает, что слияние принесло в main. Прежнюю вершину git записал в ORIG_HEAD, и пока слияние никуда не отправлено, его отменяет git reset --hard ORIG_HEAD (глава 5.1). Откуда взялся store.go, которого в ветке Бори нет, объясняет раздел про ort.

1. Истории не разошлись git merge health merge-base совпал с вершиной main нового коммита нет, main перемотана на 9472581 до main после main HEAD b22e07b 9472581 health 2. Истории разошлись git merge retry база: merge-base 9472581 сравниваются три снимка: база, 7302ee9 и cf9dac0 итог записан коммитом 03180dc с двумя родителями retry осталась на месте 9472581 merge-base e51b996 a7f2b2d 7302ee9 259f46a cf9dac0 03180dc ^1 ^2 HEAD main retry
Два исхода git merge. Если текущая ветка целиком входит в историю вливаемой, git перематывает указатель и коммита не создаёт. Если истории разошлись, git сводит снимки обеих вершин относительно merge-base, записывает результат коммитом с двумя родителями и двигает только текущую ветку.
Правка, отменённая в ветке, возвращается

Слияние видит три снимка, отдельных коммитов для него нет. Пусть Аня и Боря оба подняли MaxRetries до 5, а потом Боря передумал и сделал в своей ветке git revert. В вершине его ветки снова 3, как в базе, и для git он эту строку не трогал. Аня её меняла, поэтому после слияния MaxRetries равно 5. git help merge-strategies описывает этот случай отдельным абзацем.

merge-base: как git ищет общего предка

Общих предков у main и retry два, 9472581 и b22e07b, а merge-base из них один. По определению из git help merge-base общий предок A лучше общего предка B, если B лежит в истории A, а merge-base называется общий предок, лучше которого нет. Проще говоря, ближайший к обеим вершинам. Таких бывает несколько, если ветки сливали друг в друга крест-накрест.

Код поиска лежит в commit-reach.c. Git идёт от обеих вершин к родителям, более новые коммиты первыми (по номеру поколения из commit-graph или по дате), и помечает, из какой вершины достижим каждый коммит. Коммит с обеими пометками становится кандидатом, а его предков git помечает как заведомо худших. Обход кончается, когда рассматривать больше нечего, обычно задолго до корня. Из нескольких кандидатов git выбрасывает тех, что достижимы из другого кандидата.

Зачем всё это, видно на повторном слиянии. Третьего сентября Боря дописал в retry логирование неудачных попыток:

$ git merge-base main retry
cf9dac04e60bdac4d51cfd0344ac17aaa09512e7
$ git log --oneline main..retry
f27b1c4 (retry) Логировать неудачные попытки
$ git merge retry
Auto-merging store.go
Merge made by the 'ort' strategy.
 store.go | 2 ++
 1 file changed, 2 insertions(+)

База теперь cf9dac0, бывшая вершина retry: второй родитель 03180dc сделал её достижимой из main, и к вершинам она ближе, чем 9472581. Поэтому второе слияние сравнивает снимки только с этой точки и приносит одну новую правку. Останься база прежней, git сравнивал бы с 9472581 весь retry заново. Стоило бы Ане после первого слияния ещё раз поменять MaxRetries, и её новая версия столкнулась бы со старой версией Бори, которую она давно влила. Так и бывает после squash-merge, где второго родителя нет (глава 3.3). Второй родитель merge-коммита и есть запись о том, что уже слито.

--no-ff и --ff-only

Перемотка экономит коммит, но стирает из графа саму ветку. Боря сделал счётчик запросов в ветке metrics из двух коммитов, main с тех пор не двигалась, и fast-forward возможен. Аня всё равно просит коммит слияния:

$ git merge --no-ff metrics
Merge made by the 'ort' strategy.
 handlers.go |  1 +
 main.go     |  1 +
 metrics.go  | 13 +++++++++++++
 3 files changed, 15 insertions(+)
 create mode 100644 metrics.go
$ git rev-parse main^{tree} metrics^{tree}
70ce9af292a4486ab03aa34e5f7e862a22b9855a
70ce9af292a4486ab03aa34e5f7e862a22b9855a
$ git log --oneline --first-parent -4 main
9e6cbb8 (HEAD -> main) Merge branch 'metrics'
432bf6b Merge branch 'retry'
03180dc Merge branch 'retry'
7302ee9 Переименовать db.go в store.go

Деревья совпали: файлы после --no-ff те же, что дала бы перемотка, отличается только граф. У main появилась своя линия первых родителей, где каждая влитая ветка занимает один коммит, а при перемотке коммиты metrics встали бы на эту линию в ряд с коммитами Ани. Кнопка «Merge pull request» на GitHub и метод «Merge commit» на GitLab по документации равносильны git merge --no-ff.

--ff-only устроен наоборот и разрешает только перемотку. Утром второго сентября Боря начал от 9472581 ветку server, и с main она давно разошлась:

$ git log --oneline main..server
594f818 (server) Закрывать простаивающие соединения через минуту
6f78870 Слушать порт 8081
$ git merge --ff-only server
hint: Diverging branches can't be fast-forwarded, you need to either:
hint:
hint: 	git merge --no-ff
hint:
hint: or:
hint:
hint: 	git rebase
hint:
hint: Disable this message with "git config set advice.diverging false"
fatal: Not possible to fast-forward, aborting.

Git ничего не поменял. Режим нужен там, где своих коммитов быть не должно: подтянуть локальную main к серверной или влить ветку, которую уже перебазировали на main (глава 3.3). Метод «Fast-forward merge» на GitLab равносилен git merge --ff-only. Чтобы не писать флаг каждый раз, есть merge.ff: значение false действует как --no-ff, only как --ff-only, а флаг в команде сильнее настройки. Для одной ветки есть branch.main.mergeOptions.

Слияние остановилось: merge --abort

Ветку server всё же надо влить. Боря сменил в ней порт на 8081, Аня в main на 9090, и это одно место с разными правками:

$ git merge server
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
Auto-merging main.go
Automatic merge failed; fix conflicts and then commit the result.
$ ls .git | grep -E 'MERGE|ORIG|AUTO'
AUTO_MERGE
MERGE_HEAD
MERGE_MODE
MERGE_MSG
ORIG_HEAD
$ git merge --abort
$ git status --short
$ git merge --abort
fatal: There is no merge to abort (MERGE_HEAD missing).

Пока конфликт не разобран (это глава 3.2), коммита нет и main стоит на месте. Незаконченное слияние git помнит по файлам в .git: MERGE_HEAD хранит хеш вливаемого коммита, и по нему следующий git commit запишет двух родителей. --abort возвращает индекс и рабочую копию к состоянию до слияния, по документации это то же, что git reset --merge. Второй вызов падает: отменять нечего. Слияние, которое уже стало коммитом, отменяют иначе: git reset --hard ORIG_HEAD, пока оно не отправлено, и git revert -m 1, если отправлено (глава 5.1).

С незакоммиченными правками тоньше. Правка в файле, который слиянию не нужен, ему не мешает, и --abort её сохраняет. А та же правка в индексе не даёт слиянию даже начаться, хотя в ветке server файл handlers.go не менялся:

$ echo "// TODO: кэшировать ответ" >> handlers.go
$ git merge server
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
Auto-merging main.go
Automatic merge failed; fix conflicts and then commit the result.
$ git merge --abort && git status --short
 M handlers.go
$ git add handlers.go
$ git merge server
error: Your local changes to the following files would be overwritten by merge:
  handlers.go
Merge with strategy ort failed.

Сообщение вводит в заблуждение: перезаписывать файл никто не собирался, просто git требует, чтобы индекс совпадал с HEAD, иначе посторонняя правка уехала бы в коммит слияния. Если убирать правки некогда, есть git merge --autostash: правки уйдут в stash и вернутся после коммита или --abort, правда, в рабочую копию, а не в индекс. Подсказку выполнить git stash pop при этом не слушай: правки git вернёт сам.

git add -A посреди конфликта, а потом --abort

Разбирая конфликт, легко отметить файлы разрешёнными через git add -A. Вместе с ними в индекс уедет и посторонняя правка, и если после этого передумать, --abort сбросит её молча. Вернём TODO из индекса в рабочую копию и повторим:

$ git restore --staged handlers.go
$ git merge server
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
Auto-merging main.go
Automatic merge failed; fix conflicts and then commit the result.
$ git add -A && git merge --abort && git status --short
$ tail -1 handlers.go
}
$ git fsck --dangling
Checking ref database: 100% (1/1), done.
Checking object directories: 100% (256/256), done.
dangling commit b87c49b74d746243d02891fbf1993442e094328a
dangling tree 695c6a241341f554fa3e499d952a88eb8598c293
dangling commit edf781a165c554fb9e22c363cae67fd27c568abe
$ git show b87c49b:handlers.go | tail -1
// TODO: кэшировать ответ

Строки с TODO нет, и reflog о ней не знает. Спасает то, что перед настоящим слиянием git сохраняет незакоммиченные правки через git stash create в коммит «WIP on main: …». Ссылок на него нет, он висит среди недостижимых объектов (глава 5.2). Документация предупреждает, что при незакоммиченных правках --abort не всегда восстанавливает исходное состояние. Перед слиянием закоммить или спрячь всё, что жалко потерять.

Стратегия ort

ort написан на замену recursive, основной стратегии с версии 0.99.9k, и стал стратегией по умолчанию в git 2.34. В 2.50 старый код удалили, и -s recursive теперь другое имя ort: git напечатает «Merge made by the 'recursive' strategy», а выполнит тот же код. Слияние ort считает целиком в памяти и только потом обновляет индекс и рабочую копию (так описаны его функции в merge-ort.h). На этом построен git merge-tree --write-tree (git 2.38): он проводит то же слияние, не трогая индекс и файлы, печатает дерево результата и возвращает 0 при чистом слиянии и 1 при конфликте. В CI им удобно проверять, вольётся ли ветка. Пересчитаем первое слияние retry:

$ git merge-tree --write-tree 03180dc^1 03180dc^2
14fb520d897de0a5b27fd45b5c701259de19ae1c
$ git rev-parse 03180dc^{tree}
14fb520d897de0a5b27fd45b5c701259de19ae1c

В том слиянии был Auto-merging store.go, хотя Боря правил db.go. Аня переименовала db.go в store.go и дописала комментарий, а переименований git не хранит (глава 1.1). ort ищет их сам, сравнивая базу с каждой стороной. Здесь он нашёл пару по похожести содержимого и применил правки Бори к новому имени. С выключенным поиском та же пара даёт конфликт:

$ git merge-tree --write-tree --name-only -X no-renames 03180dc^1 03180dc^2; echo $?
2f554d83cd8cd98c12d3405dd4ef62563e7cdb4f
db.go

Auto-merging config.go
CONFLICT (modify/delete): db.go deleted in 03180dc^1 and modified in 03180dc^2.  Version 03180dc^2 of db.go left in tree.
1
Другие стратегии -sЧто делаетГде встречается
resolveтрёхсторонний merge без поиска переименований и без склейки базпочти нигде; пример в последнем разделе
octopusсливает больше двух веток одним коммитом, от сложных конфликтов отказываетсяпо умолчанию для git merge a b c
oursитоговое дерево равно текущей ветке, чужие правки выбрасываютсяпометить старую ветку влитой, не беря её изменений

Опции самой ort передают через -X: ignore-space-change, find-renames=70%, no-renames. -X ours постоянно путают со стратегией -s ours. Сравним их на server, где один кусок конфликтует (порт), а второй сливается чисто (IdleTimeout в main.go). После каждого опыта сразу откатываемся через git reset --hard ORIG_HEAD:

$ git merge -X ours server
Auto-merging config.go
Auto-merging main.go
Merge made by the 'ort' strategy.
 main.go | 2 ++
 1 file changed, 2 insertions(+)
$ grep Addr config.go
	Addr         = ":9090"
$ git merge -s ours server
Merge made by the 'ours' strategy.
$ git diff --stat HEAD^1 HEAD
-X ours и -s ours делают разное

-X ours решает в свою пользу только конфликтующие куски: порт остался 9090, а IdleTimeout пришёл. -s ours в чужую ветку не заглядывает: дерево осталось как в main, diff с первым родителем пуст, а server теперь считается влитой, и следующий git merge server ответит «Already up to date». Проверяй это после отката: даже такой merge перезаписывает ORIG_HEAD. Стратегии theirs нет, опция -X theirs есть. И конфликтующий кусок бывает шире строки: сюда попала соседняя DebugSQL, удалённая в main, и -X theirs молча вернул бы её.

Criss-cross: два общих предка

Возьмём отдельный маленький репозиторий: в main идёт разработка, в release правят выпущенную версию, и изменения переливают в обе стороны. От коммита d0f7c42 в main отошёл ead8e38 (MaxConns поднят до 20), в release коммит 3012aac (версия 1.0.1). Аня вливает release в main. Почти в то же время Боря в своём клоне вливает main в release, но его копия main о слиянии Ани не знает и стоит на ead8e38:

$ git merge release
Merge made by the 'ort' strategy.
 version.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git switch release
Switched to branch 'release'
$ git merge ead8e38
Merge made by the 'ort' strategy.
 pool.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git merge-base --all main release
3012aac962d0649031fb7f13872ffb0035195a65
ead8e385d16a1d9d07fae5b3ccadfcb5db925efd

Два слияния крест-накрест дали вершинам двух лучших общих предков: 3012aac не лежит в истории ead8e38, и наоборот. Без --all команда молча печатает одного. Боря снизил MaxConns до 15 в release, Аня вернулась в main и поменяла таймаут, и теперь выбор базы решает исход. В ead8e38 MaxConns равно 20, как у Ани, и строку менял только Боря: итог 15. В 3012aac там ещё 10, и выходит, что строку поменяли обе стороны: конфликт. Стратегия resolve базы не склеивает и при построчном слиянии взяла версию из 3012aac:

$ git merge -s resolve release
Trying simple merge.
Simple merge failed, trying Automatic merge.
Auto-merging pool.go
ERROR: content conflict in pool.go
fatal: merge program failed
Automatic merge failed; fix conflicts and then commit the result.
$ grep -A4 '^<<<<<<<' pool.go
<<<<<<< .merge_file_AH9RQj
	MaxConns    = 20
=======
	MaxConns    = 15
>>>>>>> .merge_file_LFNA1T
$ git merge --abort
$ git merge release
Auto-merging pool.go
Merge made by the 'ort' strategy.
 pool.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ grep MaxConns pool.go
	MaxConns    = 15

Имена временных файлов в маркерах у каждого прогона свои. ort на том же графе конфликта не видит. Перед основным слиянием он сливает между собой сами merge-base, взяв за базу их общего предка d0f7c42: MaxConns 20 из ead8e38 плюс версия 1.0.1 из 3012aac. Этот результат становится виртуальной базой. Относительно неё строку менял один Боря, итог 15. Если merge-base больше двух, ort склеивает их по очереди, а если у пары merge-base самой несколько общих предков, спускается рекурсивно. Отсюда и название прежней стратегии.

d0f7c42 MaxConns 10 merge-base MaxConns 20 ead8e38 3012aac MaxConns 10 merge-base виртуальная база: 20 MaxConns 20 28a41b1 782a93d MaxConns 20 крест-накрест MaxConns 20 b8819b4 db2ff80 MaxConns 15 MaxConns 15 7ea11ed main HEAD release resolve: одна база, 3012aac MaxConns: база 10, main 20, release 15 строку меняли обе стороны: конфликт ort: виртуальная база из обеих ead8e38 (20) и 3012aac (10) над d0f7c42 (10): 20 база 20, main 20, release 15: итог 15
Criss-cross и виртуальная база. Два встречных слияния сделали общими предками сразу ead8e38 и 3012aac. Стратегия resolve сравнивает строку с версией из 3012aac и получает конфликт на MaxConns, ort сначала сливает обе базы между собой и сводит ветки уже относительно результата.
Когда merge-base конфликтуют между собой

Конфликт при склейке merge-base ort не останавливает: маркеры так и остаются в виртуальной базе. При merge.conflictStyle=diff3 в файле потом видна база с подписью merged common ancestors и маркерами подлиннее внутри (глава 3.2). Criss-cross рождается из слияний туда и обратно между долгоживущими ветками. Помогает порядок: вливать в одну сторону, release в main, а обратно переносить отдельные коммиты через cherry-pick (глава 3.3).

Вопросы

4
Суть: git ищет merge-base текущей вершины и feature. Если это сама feature, сливать нечего. Если это текущая вершина, ветка перематывается без нового коммита. Иначе ort сводит три снимка и при чистом результате пишет коммит с двумя родителями, а при конфликте останавливается и ждёт.

Три исхода

merge-base совпалЧто делает gitЧто печатает
с вершиной featureничего: всё из feature уже в историиAlready up to date.
с текущей вершинойпереставляет ветку на feature, обновляет индекс и файлыFast-forward
ни с однойтрёхсторонний merge и новый коммитMerge made by the 'ort' strategy.

--no-ff заставляет делать коммит и во втором случае, --ff-only запрещает третий.

Настоящее слияние по шагам

  1. Запоминает текущую вершину в ORIG_HEAD.
  2. Проверяет, что индекс совпадает с HEAD, а незакоммиченные правки не лежат в файлах, которые слияние перепишет. Иначе отказывается, не трогая ни индекс, ни файлы.
  3. ort в памяти сводит снимки базы и двух вершин, ищет переименования, сливает построчно файлы, изменённые с обеих сторон, и только потом обновляет индекс и рабочую копию.
  4. Без конфликтов пишет коммит: первый родитель текущая вершина, второй feature. Двигает только текущую ветку.
  5. С конфликтами коммита нет. Остаются MERGE_HEAD, MERGE_MSG, AUTO_MERGE, а дальше либо разбор и git commit, либо git merge --abort.
Сливается в текущую ветку, а не в названную

git merge feature меняет ветку, на которой стоит HEAD, а feature остаётся на своём коммите. После git merge retry в main ветка retry по-прежнему указывает на cf9dac0. Чтобы влить main в feature, сначала переключаются на feature.

Суть: две версии показывают, где файлы различаются, но не говорят, кто что менял. Строка есть в одной версии и нет в другой: её добавили или удалили? База отвечает: изменила та сторона, что от неё отличается. Без базы каждое различие превращается в конфликт.

Опыт с git merge-file

git merge-file сливает три файла тем же движком, что и git merge, но строки по умолчанию сравнивает алгоритмом myers, а ort histogram, и на запутанных правках итог бывает разным. Возьмём config.go из обеих вершин и из merge-base:

$ git show 9472581:config.go > ../base.go
$ git show 7302ee9:config.go > ../main.go
$ git show cf9dac0:config.go > ../retry.go
$ git merge-file -p -L main -L base -L retry ../main.go ../base.go ../retry.go
package main

import "time"

const (
	Addr         = ":9090"
	ReadTimeout  = 5 * time.Second
	WriteTimeout = 10 * time.Second
	MaxRetries   = 5
	RetryBackoff = 200 * time.Millisecond
)

С настоящей базой все расхождения решились сами. Подставим вместо базы пустой файл, как будто общего предка нет:

$ : > ../empty.go
$ git merge-file -p -L main -L empty -L retry ../main.go ../empty.go ../retry.go
package main

import "time"

const (
<<<<<<< main
	Addr         = ":9090"
	ReadTimeout  = 5 * time.Second
	WriteTimeout = 10 * time.Second
	MaxRetries   = 3
=======
	Addr         = ":8080"
	DebugSQL     = true
	ReadTimeout  = 5 * time.Second
	WriteTimeout = 10 * time.Second
	MaxRetries   = 5
	RetryBackoff = 200 * time.Millisecond
>>>>>>> retry
)

Одинаковые строки в начале и в конце сошлись, а всю середину git отдал человеку. С пустой базой ort сливает и файлы, которые обе стороны добавили независимо, например при --allow-unrelated-histories.

Правило для каждого куска

  • Изменила одна сторона, другая совпадает с базой: берётся изменение.
  • Обе стороны изменили одинаково: берётся это изменение.
  • Обе изменили одно место по-разному: конфликт (глава 3.2). Правки в соседних строках, между которыми нет ни одной нетронутой, тоже считаются одним местом.

Чего слияние не видит

Оно сравнивает текст, а не смысл. Боря переименовал listProducts в handleProducts, Аня в это время добавила файл v1.go с вызовом listProducts. Правки не пересекаются, слияние чистое, а сборка нет:

$ git merge rename
Merge made by the 'ort' strategy.
 handlers.go | 2 +-
 main.go     | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)
$ go build ./...
# shop
./v1.go:6:38: undefined: listProducts
Чистое слияние не значит рабочий код

После слияния прогоняй go build и тесты, как после своей правки. Проверять стоит результат слияния с целевой веткой: зелёной вершины ветки задачи мало.

Суть: файлы в итоге одинаковые, отличается граф. С --no-ff каждая ветка становится одним merge-коммитом на первой линии main: фичу откатывают одним revert, а --first-parent даёт журнал слияний. Перемотка оставляет линейную историю без границ фичи. --ff-only гарантирует, что нового коммита не будет.

Что видно на первой линии

Ветку metrics влили в одну и ту же main (432bf6b) двумя способами. После git merge --no-ff metrics она занимает на первой линии один коммит:

$ git log --oneline --first-parent -4 main
9e6cbb8 (HEAD -> main) Merge branch 'metrics'
432bf6b Merge branch 'retry'
03180dc Merge branch 'retry'
7302ee9 Переименовать db.go в store.go

После перемотки git merge metrics оба её коммита стоят в ряд с работой Ани, и где кончается фича, не видно. Дерево в обоих случаях одно, 70ce9af.

$ git log --oneline --first-parent -4 main
77c5ad8 (HEAD -> main, metrics) Отдавать счётчик на /metrics
70b2e32 Считать запросы к /products
432bf6b Merge branch 'retry'
03180dc Merge branch 'retry'

Что даёт merge-коммит и чего стоит

  • Всю фичу откатывает один git revert -m 1 9e6cbb8 (глава 5.1).
  • git bisect start --first-parent находит слияние, после которого сломалось, не заходя внутрь веток.
  • В сообщении остаётся имя ветки, на платформах ещё и номер MR или pull request.
  • Цена: ветка из одной правки даёт два коммита, и при активной работе граф становится широким.

Поэтому часть команд перебазирует ветку на main и вливает перемоткой или делает squash-merge (глава 3.3). Промежуточный вариант на GitLab, «Merge commit with semi-linear history», всегда пишет merge-коммит, но вливает ветку, только если её можно было бы перемотать.

Суть: две ветки влили друг в друга крест-накрест, каждая до того, как увидела слияние другой. После этого у их вершин два лучших общих предка, и ни один не лучше другого. Стратегия resolve сливает относительно одного и может получить лишний конфликт. ort сначала сливает merge-base между собой в виртуальную базу и уже относительно неё сводит ветки.

Как получается

В main коммит ead8e38, в release коммит 3012aac. Аня вливает release в main, Боря в это же время вливает свою старую копию main в release. Оба merge-коммита содержат оба исходных коммита, а исходные не лежат в истории друг друга, поэтому лучших общих предков два:

$ git merge-base --all main release
3012aac962d0649031fb7f13872ffb0035195a65
ead8e385d16a1d9d07fae5b3ccadfcb5db925efd

Почему выбор базы важен

С git 2.40 git merge-tree принимает базу явно. Сольём вершины b8819b4 и db2ff80 с каждой из двух баз:

$ git merge-tree --write-tree --name-only --merge-base=3012aac b8819b4 db2ff80; echo $?
1d20e8438b570d427a5d970521b459950dd5fa02
pool.go

Auto-merging pool.go
CONFLICT (content): Merge conflict in pool.go
1
$ git merge-tree --write-tree --merge-base=ead8e38 b8819b4 db2ff80; echo $?
d8da1f4c97c247dfc673ae4ccfef0546157f3fce
0

С базой 3012aac, где MaxConns ещё 10, обе стороны выглядят изменившими строку. С базой ead8e38 её менял только release. Без --merge-base ort приходит к тому же дереву d8da1f4 своим путём: сливает сами merge-base в виртуальный коммит, который живёт только в памяти, и сводит вершины относительно него. По документации стратегий, на слияниях из истории ядра Linux 2.6 такой приём давал меньше конфликтов и не давал неверных слияний.

Одна база из нескольких в diff и в скриптах

git merge-base A B без --all печатает одну базу и о других молчит. Diff с тремя точками, которым смотрят, что принесёт ветка, хотя бы предупреждает:

$ git diff --stat b8819b4...db2ff80
warning: b8819b4...db2ff80: multiple merge bases, using 3012aac962d0649031fb7f13872ffb0035195a65
 pool.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

Этот diff покажет MaxConns 10 → 15, хотя слияние сравнивало с виртуальной базой, где уже 20.

Что стоит запомнить

  • Fast-forward двигает указатель, когда текущая история является предком вливаемой ветки.
  • Three-way merge сравнивает обе версии с merge-base и может записать двух родителей.
  • --ff-only запрещает создавать merge commit при невозможности перемотки.

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

  • -s ours и -X ours - разные уровни выбора.
  • Второй merge той же ветки не переигрывает старые коммиты, уже достижимые через merge.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Определи, будет ли fast-forward

Текущая ветка указывает на A, feature выросла из A до F. Что сделает обычный merge и что гарантирует --ff-only?

A---B---F  feature
 \
  main=A
Ответ и разбор

Так как A - предок F, обычный merge может выполнить fast-forward: передвинуть main на F без нового commit. --ff-only тоже позволит это перемещение, но завершится ошибкой, если текущая ветка разошлась и fast-forward невозможен.

Код-ревью

2. Различи стратегию и опцию ours

В ревью предлагают заменить конфликтное слияние командой git merge -s ours feature, считая, что Git возьмёт все файлы из текущей ветки, кроме конфликтных. Что на самом деле делает команда и какая опция ближе к заявленному поведению?

git merge -s ours feature
Ответ и разбор

-s ours создаёт merge, который полностью игнорирует дерево вливаемой ветки и оставляет дерево текущей. Для выбора нашей стороны только в конфликтах используй стратегическую опцию -X ours, но проверь каждый конфликт: это не заменяет осмысленное разрешение остальных изменений.

Применение

3. Останови ошибочный merge

Merge остановился на конфликте, но стало ясно, что ветку вливать рано. Как вернуться к состоянию до merge и не потерять уже существовавшую незакоммиченную работу?

Ответ и разбор

Сначала проверь git status, сохрани копии затронутых файлов вне репозитория и запиши git diff --binary и git diff --cached --binary туда же. При unmerged файлах эти diff не содержат полного набора стадий и не являются полным backup. После этого выполни git merge --abort, проверь status и вручную восстанови нужные незакоммиченные правки из копий. Abort обычно возвращает состояние до merge, но не обещает сохранить любые изменения, особенно если во время разрешения конфликта их уже добавили в index.

Применение

4. Объясни повторное вливание после revert

Merge feature вошёл в main, затем merge commit отменили через revert. После новых коммитов в feature повторный merge не приносит старые изменения. Почему и как включить дальнейшую работу ветки?

Ответ и разбор

Git считает старые коммиты feature уже достижимыми через merge commit, несмотря на revert содержимого; повторный merge не применяет их заново. Создай новую ветку от актуального main и перенеси новые коммиты, либо отдельно revert-ом отмени предыдущий revert после проверки, что это безопасно.