Тема 03

Ветки, слияния и rebase

Ветки в git ничего не стоят. Их заводят на каждую задачу, и вся сложность переезжает в момент, когда ветки сводят обратно. Сначала слияние: перемотка, трёхсторонний merge относительно общего предка, стратегия ort и история, в которой общих предков оказалось два. Дальше конфликты: как читать маркеры и стадии индекса, почему при rebase ours и theirs меняются местами и как правильно разрешать конфликт в go.sum. В конце rebase, который переписывает коммиты, и сравнение merge, rebase и squash-merge по тому, что остаётся в истории и что потом удаётся откатить.

Что реально проверяют этой секцией

Можно ли пускать тебя в общую ветку. Спрашивают, чем merge отличается от rebase, можно ли перебазировать опубликованную ветку, как отменить неудачное слияние и почему при rebase --ours указывает не на твои коммиты. За всеми этими вопросами стоит один: знаешь ли ты заранее, какие коммиты появятся, какие перепишутся и что увидят коллеги после твоего push. Кто держит в голове граф, общего предка и новые хеши, тот спокойно разрешит конфликт. И не затрёт чужую работу.

3.1Слияние

Ветка в git стоит две строки в служебных файлах, так что ветки заводят на каждую задачу. Вся работа достаётся слиянию: git merge находит общего предка двух веток и дальше либо перематывает указатель, либо сводит три снимка в коммит с двумя родителями. Отсюда следуют и выбор между --no-ff и --ff-only, и странности истории, в которой у двух веток оказалось два общих предка.

Что сможешь объяснить после этой главы
  • Почему ветка ничего не стоит и когда 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 (three-way 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 (Ostensibly Recursive's Twin, «якобы близнец recursive») написан на замену 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

Два слияния крест-накрест (criss-cross) дали вершинам двух лучших общих предков: 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.

3.2Конфликты

Конфликт значит, что слияние остановилось на полпути: обе ветки поменяли одно место, и выбор git оставил тебе. Пока ты выбираешь, в индексе лежат три версии файла, а на диске версия с маркерами. Если знать, откуда взялась каждая, конфликт разрешается по смыслу, а rebase, где «наша» сторона вдруг оказывается чужой, перестаёт сбивать с толку.

Что сможешь объяснить после этой главы
  • Почему правки в соседних строках конфликтуют, а через строку сливаются сами.
  • Как читать маркеры и зачем включать merge.conflictStyle=zdiff3.
  • Что лежит в индексе во время конфликта и как достать из стадий 1, 2 и 3 базу и обе стороны.
  • Почему при rebase --ours возвращает версию main и чем опасен rebase -X ours.
  • Что делают git mergetool и rerere.
  • Как разрешать конфликты в go.mod, go.sum и сгенерированном коде, не собирая их руками.

Когда git останавливается

Как слияние сравнивает обе ветки с базой, разобрано в главе 3.1. Правку одной ветки git переносит сам, одинаковую правку обеих берёт один раз, а останавливается там, где обе ветки изменили одну область файла по-разному. Правки в соседних строках, как сказано там же, git считает одной областью: без нетронутой строки между ними не видно, где кончается одна правка и начинается другая.

Дальше работаем с сервисом shop. Аня в main подняла таймаут записи и добавила таймаут остановки, Боря в ветке limits ограничил размер запроса. Каждый дописал поле в структуру Config и строку в литерал внутри DefaultConfig.

$ git log --oneline --graph --all
* e2037fb (HEAD -> main) Поднять таймаут записи, добавить таймаут остановки
| * ae27ed7 (limits) Ограничить размер запроса
|/  
* dcbc4e9 Сервер с настройками по умолчанию
$ git merge limits
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
Automatic merge failed; fix conflicts and then commit the result.
$ git status --short
UU config.go
$ ls .git | grep MERGE
AUTO_MERGE
MERGE_HEAD
MERGE_MODE
MERGE_MSG

Коммита слияния нет, про MERGE_HEAD и MERGE_MSG рассказано в главе 3.1. AUTO_MERGE указывает на дерево с тем, что слияние успело сделать само, маркеры включительно. Файлы, которые слились чисто, уже лежат в индексе.

В Go конфликт часто раздувает gofmt. Имена новых полей длиннее старых, и gofmt сдвинул выравнивание соседних строк. Для git обе ветки переписали весь блок, хотя по смыслу каждая добавила одну строку.

Маркеры и база

От <<<<<<< HEAD до ======= стоит версия текущей ветки, дальше до >>>>>>> limits версия вливаемой. Всё вне маркеров уже слито.

$ sed -n 5,31p config.go
type Config struct {
	Addr            string
	ReadTimeout     time.Duration
	WriteTimeout    time.Duration
<<<<<<< HEAD
	ShutdownTimeout time.Duration
=======
	MaxRequestBytes int64
>>>>>>> limits
}

func DefaultConfig() Config {
	return Config{
		Addr:            ":8080",
		ReadTimeout:     5 * time.Second,
<<<<<<< HEAD
		WriteTimeout:    30 * time.Second,
		ShutdownTimeout: 20 * time.Second,
=======
		WriteTimeout:    10 * time.Second,
		MaxRequestBytes: 1 << 20,
>>>>>>> limits
	}
}

Выровненные одинаково строки Addr и ReadTimeout git вынес из конфликта. Первый кусок читается сразу: каждая ветка добавила своё поле, нужны оба. Со вторым хуже. У Ани WriteTimeout 30 секунд, у Бори 10. Кто менял значение? Если Аня подняла таймаут, верный ответ 30, если Боря снизил, то 10. Исходного текста в маркерах нет.

Настройка merge.conflictStyle добавляет в конфликт третью часть, базу. Стиль zdiff3 появился в git 2.35. Прервём слияние (merge --abort, глава 3.1) и повторим:

$ git merge --abort
$ git config merge.conflictStyle zdiff3
$ git merge limits
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
Automatic merge failed; fix conflicts and then commit the result.
$ sed -n 22,36p config.go
		Addr:            ":8080",
		ReadTimeout:     5 * time.Second,
<<<<<<< HEAD
		WriteTimeout:    30 * time.Second,
		ShutdownTimeout: 20 * time.Second,
||||||| dcbc4e9
		Addr:         ":8080",
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
=======
		WriteTimeout:    10 * time.Second,
		MaxRequestBytes: 1 << 20,
>>>>>>> limits
	}
}

Между ||||||| dcbc4e9 и ======= стоит текст из базы. В нём WriteTimeout 10 секунд, значит, таймаут подняла Аня, а Боря только выровнял строку. Оставляем 30 секунд и оба новых поля. В базу попадает исходный текст всей конфликтной области, поэтому там видны и Addr с ReadTimeout, хотя выше маркеров они уже слиты.

Включи zdiff3 один раз

git config --global merge.conflictStyle zdiff3. Старый стиль diff3 тоже показывает базу, но совпадающие строки из конфликта не выносит, и весь блок структуры повторился бы у обеих сторон. zdiff3 (zealous diff3) выносит их, как стиль по умолчанию, и оставляет базу. Уже записанный конфликт перерисует git checkout --conflict=zdiff3 путь, только метки у маркеров станут безликими: ours, base, theirs.

Три версии в индексе

Пока путь не разрешён, у config.go в индексе три записи. Колонка стадии в ls-files --stage, в главе 1.3 нулевая, теперь показывает 1, 2 и 3:

$ git ls-files --stage
100644 6343301ff95f0fcfecac7fb646890c5d127e40a4 1	config.go
100644 286fee22eaacd92caaace800638f101538dffc75 2	config.go
100644 04ebddfefb070b9dd0b15ebb736d3fe50009cc25 3	config.go
100644 00b62a663c45ccba5f2e42a733fac60a29e40c7c 0	go.mod
100644 8cd7b8fe6bba9be86255cec713e9cbc0725679b6 0	main.go
СтадияЧто лежитОткудаКак достать
1базаmerge-base, dcbc4e9git show :1:config.go
2ours, текущая веткаHEAD, e2037fbgit show :2:config.go, checkout --ours
3theirs, вливаемая веткаMERGE_HEAD, ae27ed7git show :3:config.go, checkout --theirs
0разрешённый файлgit addgit show :config.go

В стадиях лежат целые версии файла без маркеров, и спор о таймауте решают три команды:

$ git show :1:config.go | grep 'WriteTimeout:'
		WriteTimeout: 10 * time.Second,
$ git show :2:config.go | grep 'WriteTimeout:'
		WriteTimeout:    30 * time.Second,
$ git show :3:config.go | grep 'WriteTimeout:'
		WriteTimeout:    10 * time.Second,

Чаще всего конфликт построчный, прочие виды git находит по файлам целиком. В отдельном репозитории ветки разошлись во всём сразу, и git merge limits напечатал пять строк CONFLICT разных видов. В git status --short первая буква говорит о нашей стороне, вторая об их: A добавлено, D удалено, U изменено и не слито.

Вид в выводе mergeЧто сделали веткиstatusСтадии
contentобе изменили двоичный logo.png; на диске версия HEAD, маркеров нетUU1, 2, 3
add/addобе создали retry.goAA2, 3
modify/deleteHEAD удалил legacy.go, limits изменилDU1, 3
rename/deleteHEAD переименовал util.go, limits удалилUD1, 2
rename/renameобе переименовали db.go, каждая по-своемуDD, AU, UA1 / 2 / 3

Когда нужной стадии нет, git так и говорит: git checkout --ours legacy.go ответит error: path 'legacy.go' does not have our version. Тут остаётся выбрать: git add legacy.go оставит файл, git rm legacy.go удалит.

Коммиты что сливаем Индекс .git/index во время конфликта Рабочая копия config.go на диске dcbc4e9 база, merge-base e2037fb HEAD, main (Аня) ae27ed7 MERGE_HEAD, limits (Боря) config.go стадия 1 6343301 база config.go стадия 2 286fee2 ours config.go стадия 3 04ebddf theirs go.mod, main.go стадия 0 не менялись <<<<<<< HEAD WriteTimeout: 30 * time.Second, ShutdownTimeout: 20 * time.Second, ||||||| dcbc4e9 Addr: ":8080", ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, ======= WriteTimeout: 10 * time.Second, MaxRequestBytes: 1 << 20, >>>>>>> limits кусок в структуре Config устроен так же это же дерево записано в AUTO_MERGE После разрешения git add config.go config.go стадия 0 32afad9 решено стадий 1, 2 и 3 больше нет git merge --continue dd280ef коммит слияния, родители e2037fb и ae27ed7 служебные MERGE_* удалены
Слияние, остановленное конфликтом. В каждой стадии индекса лежит целая версия config.go из своего коммита: база, HEAD и вливаемая ветка. На диске лежит результат с маркерами. git add заменяет три стадии одной записью, а коммит слияния получает двух родителей.

Как довести слияние до конца

Разрешить конфликт значит положить в индекс одну версию пути со стадией 0 (или убрать путь через git rm), а как она получена, git не проверяет. Сторону целиком берут git checkout --ours и --theirs (то же умеет git restore): стадия копируется на диск, путь остаётся конфликтным. Это не merge -X ours из главы 3.1, который решает при слиянии только спорные куски: флаги срабатывают уже после конфликта, и бесконфликтные правки другой стороны тоже пропадут. С версией Бори пропали бы правки Ани, но git checkout -m пересоздаёт конфликт из стадий:

$ git checkout --theirs config.go
Updated 1 path from the index
$ git status --short
UU config.go
$ git checkout -m config.go
Recreated 1 merge conflict

Внутрь файла git add не смотрит. Маркеры находит git diff --check, заодно с пробелами в конце строк:

$ git diff --check
config.go:9: leftover conflict marker
config.go:11: leftover conflict marker
config.go:15: leftover conflict marker
config.go:17: leftover conflict marker
config.go:24: leftover conflict marker
config.go:27: leftover conflict marker
config.go:31: leftover conflict marker
config.go:34: leftover conflict marker
$ git add config.go
$ git status --short
M  config.go
$ git checkout -m config.go
Recreated 1 merge conflict
$ git status --short
UU config.go
Файл с маркерами уходит в коммит без вопросов

После git add путь разрешён, что бы в нём ни лежало, а git commit -a добавляет конфликтные файлы сам. В .go маркеры поймает компилятор, в go.sum команда go: go build упадёт с malformed go.sum, пропускает их только go mod tidy (о нём ниже). В YAML не поймает никто. Перед add запускай git diff --check; checkout -m, как видно выше, возвращает конфликт и после add.

Разрешим по смыслу: 30 секунд и оба поля. git diff во время слияния печатает комбинированный diff с двумя колонками знаков, первая сравнивает файл с HEAD, вторая с limits:

$ git diff
diff --cc config.go
index 286fee2,04ebddf..0000000
--- a/config.go
+++ b/config.go
@@@ -6,14 -6,14 +6,16 @@@ type Config struct 
  	Addr            string
  	ReadTimeout     time.Duration
  	WriteTimeout    time.Duration
 +	ShutdownTimeout time.Duration
+ 	MaxRequestBytes int64
  }
  
  func DefaultConfig() Config {
  	return Config{
  		Addr:            ":8080",
  		ReadTimeout:     5 * time.Second,
 -		WriteTimeout:    10 * time.Second,
 +		WriteTimeout:    30 * time.Second,
 +		ShutdownTimeout: 20 * time.Second,
+ 		MaxRequestBytes: 1 << 20,
  	}
  }

MaxRequestBytes помечен в первой колонке, его не было у Ани. Таймаут на 30 секунд и ShutdownTimeout помечены во второй. Только свои правки поверх автоматического результата покажет git diff AUTO_MERGE. Проверяем и заканчиваем:

$ git diff --check
$ go vet ./...
$ git add config.go
$ git merge --continue
[main dd280ef] Merge branch 'limits'
$ ls .git | grep MERGE

merge --continue убеждается, что слияние идёт, и вызывает git commit с редактором, в котором уже текст из MERGE_MSG. После коммита файлы MERGE_* исчезают.

ours и theirs при rebase и cherry-pick

--ours означает стадию 2, а её merge, rebase, cherry-pick и revert заполняют из HEAD. Путаница начинается, когда HEAD стоит не там, где ты привык. Вернём main на коммит до слияния (режимы reset в главе 5.1) и пусть Боря перенесёт ветку rebase-ом (сам rebase в главе 3.3):

$ git reset --hard e2037fb
HEAD is now at e2037fb Поднять таймаут записи, добавить таймаут остановки
$ git switch limits
Switched to branch 'limits'
$ git rebase main                  # строки hint: убраны
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
error: could not apply ae27ed7... Ограничить размер запроса
Could not apply ae27ed7... # Ограничить размер запроса
$ grep -n -m4 '^[<|=>]\{7\}' config.go
9:<<<<<<< HEAD
11:||||||| parent of ae27ed7 (Ограничить размер запроса)
15:=======
17:>>>>>>> ae27ed7 (Ограничить размер запроса)
$ git ls-files -u
100644 6343301ff95f0fcfecac7fb646890c5d127e40a4 1	config.go
100644 286fee22eaacd92caaace800638f101538dffc75 2	config.go
100644 04ebddfefb070b9dd0b15ebb736d3fe50009cc25 3	config.go
$ git checkout --ours config.go
Updated 1 path from the index
$ grep -E 'WriteTimeout:|MaxRequestBytes:' config.go
		WriteTimeout:    30 * time.Second,

Боря попросил «свою» версию и получил версию Ани. Блобы в стадиях те же, что при слиянии на main: rebase поставил HEAD на main и применяет коммиты limits по одному. HEAD тут означает «уже построенное», база это родитель переносимого коммита, а сам коммит приходит третьей стадией. У git cherry-pick ae27ed7 на main метки те же, но стороны не перевёрнуты: HEAD и есть твоя ветка, чужой коммит приезжает как theirs.

git merge limits стоишь на main dcbc4e9 e2037fb main HEAD Аня ae27ed7 limits Боря, вливается :2 ours e2037fb HEAD версия Ани :3 theirs ae27ed7 MERGE_HEAD версия Бори :1 base dcbc4e9 merge-base база <<<<<<< HEAD ||||||| dcbc4e9 >>>>>>> limits git rebase main стоишь на limits dcbc4e9 e2037fb main Аня HEAD отцеплен ae27ed7 limits Боря, переносится :2 ours e2037fb HEAD версия Ани :3 theirs ae27ed7 переносимый коммит версия Бори :1 base dcbc4e9 родитель ae27ed7 база <<<<<<< HEAD ||||||| parent of ae27ed7 (Ограничить размер запроса) >>>>>>> ae27ed7 (Ограничить размер запроса)
Кто ours, а кто theirs. При слиянии на main HEAD остаётся на твоей ветке. При rebase HEAD переезжает на ветку, куда переносишь коммиты, и твой очередной коммит применяется поверх неё как чужой. В стадиях в обоих случаях одни и те же блобы.

Сами -X ours и -X theirs описаны в главе 3.1. При rebase они перевёрнуты так же, и -X ours играет против переносимого коммита:

$ git rebase --abort
$ git rebase -X ours main
dropping ae27ed76ba2b9647a262b3733eb8c32e670c876a Ограничить размер запроса -- patch contents already upstream
Successfully rebased and updated refs/heads/limits.
$ git log --oneline -1
e2037fb (HEAD -> limits, main) Поднять таймаут записи, добавить таймаут остановки
$ git reset --hard ORIG_HEAD
HEAD is now at ae27ed7 Ограничить размер запроса
$ git switch main
Switched to branch 'main'

Вся правка Бори лежала в конфликтных кусках, после «разрешения» в пользу main от коммита ничего не осталось, и rebase выбросил его, отчитавшись об успехе. Ветку вернул ORIG_HEAD (главы 3.3 и 5.2). -X theirs при rebase, наоборот, молча откатил бы спорные правки Ани.

mergetool

git mergetool запускает на каждом конфликтном файле программу из merge.tool; список известных печатает git mergetool --tool-help, с git 2.47 в нём есть vscode. Программа получает BASE из стадии 1, LOCAL из стадии 2, REMOTE из стадии 3 и MERGED, рабочий файл для результата. Подставим вместо программы команду, которая печатает имена файлов и кладёт в MERGED готовое разрешение:

$ git merge limits
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
Automatic merge failed; fix conflicts and then commit the result.
$ git config merge.tool peek
$ git config mergetool.peek.cmd 'ls -1 config_*; cp ../resolved.go "$MERGED"'
$ git config mergetool.peek.trustExitCode true
$ git mergetool
Merging:
config.go

Normal merge conflict for 'config.go':
  {local}: modified file
  {remote}: modified file
config_BACKUP_71615.go
config_BASE_71615.go
config_LOCAL_71615.go
config_REMOTE_71615.go
$ git status --short
M  config.go
?? config.go.orig
$ git merge --abort

Число в именах это номер процесса, а копии git удаляет, когда программа отработает. В BACKUP лежит файл с маркерами, он остаётся как config.go.orig, если не выставить mergetool.keepBackup=false. Коду возврата git поверил из-за trustExitCode и сам сделал git add. С mergetool.hideResolved=true (git 2.31) программа видит только неслитые куски. GoLand и VS Code открывают свой трёхпанельный редактор и без mergetool.

rerere: разрешить один раз

Один конфликт порой встречается несколько раз: долгую ветку каждый день перебазируют на main, пробное слияние откатывают и через день сливают по-настоящему. rerere (reuse recorded resolution) запоминает разрешение и при следующей встрече применяет его сам:

$ git config rerere.enabled true
$ git merge limits
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
Recorded preimage for 'config.go'
Automatic merge failed; fix conflicts and then commit the result.

Разрешаем config.go как раньше и заканчиваем:

$ git add config.go
$ git merge --continue
Recorded resolution for 'config.go'.
[main dd280ef] Merge branch 'limits'
$ find .git/rr-cache -type f
.git/rr-cache/7f58145231c87ebcf836928134f5188e3aba1a3e/preimage
.git/rr-cache/7f58145231c87ebcf836928134f5188e3aba1a3e/postimage

В preimage конфликт, в postimage разрешение. Имя каталога git считает по тексту конфликта, выбросив метки и базу и отсортировав стороны, поэтому rerere узнаёт конфликт, даже когда стороны меняются местами. Слияние решили отложить, Боря переносит ветку rebase-ом:

$ git reset --hard HEAD~1
HEAD is now at e2037fb Поднять таймаут записи, добавить таймаут остановки
$ git switch limits
Switched to branch 'limits'
$ git rebase main                  # строки hint: убраны
Auto-merging config.go
CONFLICT (content): Merge conflict in config.go
error: could not apply ae27ed7... Ограничить размер запроса
Resolved 'config.go' using previous resolution.
Could not apply ae27ed7... # Ограничить размер запроса
$ git status --short
UU config.go
$ git add config.go
$ git rebase --continue
[detached HEAD 0dbf203] Ограничить размер запроса
 Author: Боря <borya@example.com>
 1 file changed, 2 insertions(+)
Successfully rebased and updated refs/heads/limits.

Метки другие, а разрешение подошло. Путь при этом остаётся UU, чтобы ты посмотрел на результат; с rerere.autoUpdate=true файл сразу попадает в индекс. Где rerere подводит и как забыть неверное разрешение, смотри в четвёртом вопросе.

go.mod и go.sum

git сливает эти файлы построчно, а строки в них связаны. В go.mod модуля с go 1.17 и новее перечислен каждый модуль, из которого собираются пакеты, с выбранной версией, в go.sum контрольные суммы того, что нужно сборке. Оба файла команда go выводит из кода.

Возьмём репозиторий catalog, в базе у него chi и uuid. Аня добавила пакет names на golang.org/x/text v0.40.0, Боря в ветке desc пакет, который чистит описание товара через golang.org/x/net/html v0.58.0. Каждый дописал строку в конец require:

$ git merge desc
Auto-merging go.mod
CONFLICT (content): Merge conflict in go.mod
Auto-merging go.sum
CONFLICT (content): Merge conflict in go.sum
Automatic merge failed; fix conflicts and then commit the result.
$ git status --short
A  desc/desc.go
UU go.mod
UU go.sum
$ cat go.mod
module example.com/catalog

go 1.27.1

require (
	github.com/go-chi/chi/v5 v5.3.2
	github.com/google/uuid v1.6.0
<<<<<<< HEAD
	golang.org/x/text v0.40.0
||||||| 50d8d3e
=======
	golang.org/x/net v0.58.0
>>>>>>> desc
)

С маркерами в go.mod любая команда go, которая читает модуль, go mod tidy тоже, падает с go: errors parsing go.mod, так что этот файл разрешают руками. Проще всего оставить обе строки в go.mod и обе пары строк с суммами в go.sum. Так и сделали:

$ go build ./...
go: updates to go.mod needed; to update it:
	go mod tidy
$ go mod graph | grep 'x/net@.* golang.org/x/text'
golang.org/x/net@v0.58.0 golang.org/x/text@v0.41.0

x/net v0.58.0 требует x/text v0.41.0, выбор минимальной версии (MVS) берёт наибольшую из требуемых, а в go.mod записана v0.40.0. Сам go build файл не поправит: с Go 1.16 команды сборки только сообщают, что менять. Если поднять версию до v0.41.0 руками, ошибка сменится:

$ go build ./...
names/names.go:4:2: missing go.sum entry for module providing package golang.org/x/text/cases (imported by example.com/catalog/names); to add:
	go get example.com/catalog/names
names/names.go:5:2: missing go.sum entry for module providing package golang.org/x/text/language (imported by example.com/catalog/names); to add:
	go get example.com/catalog/names

Сумм для x/text v0.41.0 нет ни в одной ветке: Аня собиралась с v0.40.0, а в go.sum Бори x/text не упомянут, пакет html его не импортирует. Объединённому коду нужен набор, которого не было ни у кого, поэтому go.sum не сливают, а пересчитывают. Вернём x/text в go.mod к v0.40.0, go.sum возьмём с любой стороны:

$ git checkout --theirs go.sum
Updated 1 path from the index
$ go mod tidy
go: downloading golang.org/x/text v0.41.0
$ grep x/text go.mod
	golang.org/x/text v0.41.0
$ go build ./...
$ git add go.mod go.sum
$ git merge --continue
[main cf29cf7] Merge branch 'desc'

tidy поднял x/text до выбранной версии, дописал суммы, сверив их с sum.golang.org, и убрал лишние. Перед add прогони и go test ./.... Маркеры в go.sum tidy пропускает (в исходниках Go так и написано: чтобы tidy мог починить go.sum), остальные команды на них падают с malformed go.sum.

Хуже, когда конфликта нет. Добавим в базу ещё golang.org/x/sync: его строка встанет между x/net и x/text в обоих файлах, правки веток перестанут соприкасаться, и git сольёт всё сам:

$ git merge --no-edit desc
Auto-merging go.mod
Auto-merging go.sum
Merge made by the 'ort' strategy.
 desc/desc.go | 22 ++++++++++++++++++++++
 go.mod       |  1 +
 go.sum       |  2 ++
 3 files changed, 25 insertions(+)
 create mode 100644 desc/desc.go
$ go build ./...
go: updates to go.mod needed; to update it:
	go mod tidy
$ go mod tidy -diff >/dev/null; echo $?
1

Слияние прошло, main не собирается. go mod tidy -diff (Go 1.23) файлы не трогает, но возвращает ненулевой код, если они отстали от кода. Шаг с этой командой в CI ловит такое слияние раньше коллег (про пайплайны тема CI/CD в разделе DevOps).

Сгенерированные файлы

Сгенерированный код тоже не сливают, а получают заново из слитого источника. В сервисе заказов (ещё один маленький репозиторий) строковые имена статусов делает stringer, в status.go стоит //go:generate go run golang.org/x/tools/cmd/stringer@v0.49.0 -type=Status. Аня добавила статус Refunded, Боря в ветке cancel статус Cancelled:

$ git merge cancel
Auto-merging status.go
CONFLICT (content): Merge conflict in status.go
Auto-merging status_string.go
CONFLICT (content): Merge conflict in status_string.go
Automatic merge failed; fix conflicts and then commit the result.
$ sed -n 22,36p status_string.go
<<<<<<< HEAD
const _Status_name = "NewPaidShippedRefunded"
||||||| 17c33b4
const _Status_name = "NewPaidShipped"
=======
const _Status_name = "NewPaidShippedCancelled"
>>>>>>> cancel

<<<<<<< HEAD
var _Status_index = [...]uint8{0, 3, 7, 14, 22}
||||||| 17c33b4
var _Status_index = [...]uint8{0, 3, 7, 14}
=======
var _Status_index = [...]uint8{0, 3, 7, 14, 23}
>>>>>>> cancel

Руками это не слить: имена склеены в одну строку, а _Status_index хранит смещения в ней. Разрешать надо источник, в status.go оставить обе константы. Сгенерированный файл берёшь с любой стороны и перегенерируешь:

$ git checkout --ours status_string.go
Updated 1 path from the index
$ go generate ./...
$ grep -n 'Cancelled\|_Status_name =\|_Status_index =' status_string.go
15:	_ = x[Cancelled-4]
18:const _Status_name = "NewPaidShippedRefundedCancelled"
20:var _Status_index = [...]uint8{0, 3, 7, 14, 22, 31}
$ go build ./... && git add status.go status_string.go
$ git merge --continue
[main 473f140] Merge branch 'cancel'

Cancelled сменил значение с 3 на 4. Если статусы хранятся в базе числами, это конфликт по смыслу, и git его не видит. С protoc, sqlc и mockgen порядок тот же: разрешить источник и перегенерировать. Маркеры в сгенерированные файлы не пустит атрибут merge=binary: git не сливает такой файл построчно, оставляет на диске версию HEAD и объявляет конфликт. Откатим коммит слияния (git reset --hard HEAD~1) и сольём заново, с записью в .gitattributes:

*_string.go merge=binary
$ git merge cancel
Auto-merging status.go
CONFLICT (content): Merge conflict in status.go
warning: Cannot merge binary files: status_string.go (HEAD vs. cancel)
Auto-merging status_string.go
CONFLICT (content): Merge conflict in status_string.go
Automatic merge failed; fix conflicts and then commit the result.
$ grep -c '<<<<<<<' status_string.go
0

Двоичным файл назван по имени драйвера. На диске код без маркеров, генератору остаётся его перезаписать.

Вопросы

4
Суть: коммита ещё нет. В .git лежат MERGE_HEAD и MERGE_MSG, чисто слитые файлы уже в индексе, у конфликтного пути три стадии (база, HEAD, вливаемая ветка), на диске версия с маркерами. Закончить: разрешить файлы, проверить git diff --check и сборкой, git add, git merge --continue.

Что успел сделать git

Слил всё, что смог. У конфликтного пути стадия 1 из merge-base, 2 из HEAD, 3 из MERGE_HEAD, а файл на диске собран из слитых кусков и маркеров, то же дерево лежит в AUTO_MERGE. git status --short помечает такие пути буквами UU, AA, DU, и git commit отказывается работать, пока они есть.

Порядок

  1. Разрешить файлы по смыслу, глядя на базу (zdiff3 или git show :1:путь). Удалённый одной стороной файл оставить git add или убрать git rm.
  2. git diff --check, go build ./..., go test ./....
  3. git add на каждый путь, затем git merge --continue.

Взял не ту сторону: git checkout -m путь вернёт маркеры, даже если файл уже добавлен. Хочешь начать заново: git merge --abort (глава 3.1).

commit -a и --no-edit

git commit -a добавит конфликтные файлы вместе с маркерами, а --no-edit оставит в сообщении служебные строки:

$ git commit -a --no-edit
[main 2414752] Merge branch 'limits'
$ git show HEAD:config.go | grep -c '<<<<<<<'
2
$ git log -1 --format=%B
Merge branch 'limits'

# Conflicts:
#	config.go
Суть: --ours берёт стадию 2, которую rebase заполняет из HEAD. А HEAD при rebase стоит на ветке, куда переносишь коммиты, вместе с уже перенесёнными, и твой очередной коммит применяется поверх как theirs. Поэтому --ours даёт main, --theirs твой коммит, и так же перевёрнуты -X ours и -X theirs.

Кто есть кто

Командаours, стадия 2theirs, стадия 3база, стадия 1
git merge feature на mainmainfeaturemerge-base
git rebase main на featuremain и уже перенесённые коммитыочередной коммит featureего родитель
git cherry-pick Cтекущая веткаCродитель C
git revert Cтекущая веткародитель CC

Как не запутаться

Думай не «моё и чужое», а «HEAD и то, что применяется». При rebase и cherry-pick после >>>>>>> стоит хеш применяемого коммита. git pull --rebase и LOCAL в mergetool подчиняются тому же правилу.

Чем грозит

Взять «свою» версию через git checkout --ours при rebase значит выбросить из файла свою же правку. Хуже с -X (про сами опции глава 3.1): при rebase -X ours работает против твоих коммитов, а -X theirs молча откатывает спорные правки из main.

Коммит пропал, rebase сообщил об успехе

Если вся правка коммита лежала в конфликтных кусках, git rebase -X ours main выбросит коммит и отчитается об успехе, как в разделе «ours и theirs при rebase и cherry-pick». Сразу после rebase ветку вернёт git reset --hard ORIG_HEAD, позже reflog (глава 5.2).

Суть: go.sum вычисляется из графа модулей, а после слияния граф новый. Если он выбирает версию, которой не было ни в одной ветке, как x/text v0.41.0 в примере, её сумм в склейке двух файлов нет. go.mod разрешают руками по смыслу, go.sum берут с любой стороны, затем go mod tidy, go build ./..., go test ./... и только потом git add.

Почему склейки мало

В примере из главы x/net v0.58.0 от Бори требует x/text v0.41.0, а у Ани x/text v0.40.0. Сумм v0.41.0 нет ни у кого: склеишь строки, получишь go: updates to go.mod needed, поднимешь версию руками, получишь missing go.sum entry.

Порядок

  1. go.mod: убрать маркеры, оставить требования обеих веток; если одну зависимость подняли до разных версий, обычно берут бо́льшую.
  2. go.sum: git checkout --ours go.sum или --theirs.
  3. go mod tidy: версии до выбранных, недостающие суммы со сверкой, лишние долой.
  4. go build ./..., go test ./..., git add go.mod go.sum.

Без конфликта тоже ломается

Если правки веток разделены хотя бы одной строкой, git сольёт оба файла молча, и go build может упасть уже в main, как в примере с x/sync. Ловит это go mod tidy -diff (Go 1.23): печатает нужную правку и возвращает ненулевой код, если файлы модуля отстали от кода. Эту команду ставят шагом в CI.

go.sum merge=union ничего не чинит

Иногда советуют атрибут go.sum merge=union. Он склеивает строки обеих сторон без маркеров, и git считает go.sum разрешённым (M  go.sum в статусе). Это ровно та склейка, которой мало: go.mod всё равно конфликтует, а недостающих сумм не прибавилось.

Суть: включить rerere (git config rerere.enabled true): git запомнит разрешение и применит его при следующей встрече с тем же конфликтом, даже если стороны поменялись местами. И убирать причины повторов: долгоживущие ветки и построчное слияние того, что генерируется.

Откуда повторы

Долгую ветку регулярно перебазируют, и каждый rebase заново применяет её коммиты и встречает те же конфликты. Пробное слияние откатили, чтобы слить позже. Сгенерированные файлы и go.sum конфликтуют чуть ли не при каждой встречной правке.

Как работает rerere

При конфликте git сохраняет его в .git/rr-cache/<id>/preimage, при коммите разрешение в postimage. Идентификатор считается по конфликту без меток и базы, стороны отсортированы. Встретив тот же конфликт, git пишет Resolved '…' using previous resolution., кладёт разрешение на диск и оставляет путь UU до твоего git addrerere.autoUpdate=true добавляет сам).

Где rerere подводит

rerere сверяет текст, в смысл он не вникает. Разрешение применится, даже если код вокруг конфликта с тех пор поменялся, а соберётся ли результат, rerere не проверяет. Неверное разрешение повторяется, пока его не забудешь: git rerere forget путь, затем git checkout -m путь, чтобы вернуть маркеры. Записи живут только в твоём .git, неиспользуемые 60 дней удаляет git gc.

Причину rerere не лечит

Конфликты повторяются, потому что ветки слишком долго живут отдельно. Короткие ветки и частые слияния с main сокращают их сильнее любой настройки; как к этому прийти в команде, рассказывает глава 4.2. Сгенерированные файлы лучше не разрешать вовсе, а перегенерировать после слияния.

3.3Rebase

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

Что сможешь объяснить после этой главы
  • Что git rebase main делает с коммитами ветки и почему патч прежний, а хеш новый.
  • Где rebase останавливается и как вернуть ветку через --abort и ORIG_HEAD.
  • Как пересадить часть ветки через --onto и перенести стопку веток одной командой.
  • Как склеить, переименовать и выбросить коммиты, вклеить правку через --fixup.
  • Что станет с историей, bisect и revert после merge, rebase и squash-merge.

Rebase записывает коммиты заново

У этой главы свой демо-репозиторий, Go-сервис shop. Аня ответвила retry от коммита «Добавить хранилище заказов» и сделала в ней три коммита. Боря тем временем добавил в main два своих, и первый из них поменял сигнатуру Store.Find:

$ git switch retry
Switched to branch 'retry'
$ git log --oneline --graph main retry
* 1ee0b4e (main) Описать запуск в README
* f178e64 Передавать контекст в хранилище
| * c357ae4 (HEAD -> retry) Искать заказ с повтором
| * 5058e4c Не ждать после последней попытки
| * e0377f6 Повторять запрос при ошибке
|/
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop

git rebase main переносит коммиты текущей ветки на вершину main:

$ git rebase main
Successfully rebased and updated refs/heads/retry.
$ git log --oneline --graph main retry
* 48b7729 (HEAD -> retry) Искать заказ с повтором
* cb6bb38 Не ждать после последней попытки
* 67bce9b Повторять запрос при ошибке
* 1ee0b4e (main) Описать запуск в README
* f178e64 Передавать контекст в хранилище
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop

Внутри четыре шага. Git составляет список коммитов из main..retry, то есть после merge-base 3b31e8b (как её ищут, сказано в главе 3.1). Отсоединяет HEAD на 1ee0b4e. По очереди применяет изменения каждого коммита: вычисляет их из пары «коммит и его родитель» и сливает с текущим снимком, как cherry-pick. В конце ставит retry на последний новый коммит и возвращает на неё HEAD. Старое с новым сравнивает git range-diff, где = значит «патч не изменился»:

$ git range-diff main ORIG_HEAD retry
1:  e0377f6 = 1:  67bce9b Повторять запрос при ошибке
2:  5058e4c = 2:  cb6bb38 Не ждать после последней попытки
3:  c357ae4 = 3:  48b7729 Искать заказ с повтором

Патч тот же, а хеш другой, потому что у нового коммита другой родитель и другое дерево: в снимок 67bce9b уже входят коммиты Бори. Автора и дату автора rebase переносит из старого коммита, дату коммитера ставит текущую:

$ git log --format='%h %ad %cd %s' --date=short main..retry
48b7729 2026-09-02 2026-09-04 Искать заказ с повтором
cb6bb38 2026-09-02 2026-09-04 Не ждать после последней попытки
67bce9b 2026-09-02 2026-09-04 Повторять запрос при ошибке
$ git rev-parse --short ORIG_HEAD
c357ae4

Старые коммиты целы. retry на c357ae4 больше не указывает, хеш прежней вершины записан в ORIG_HEAD, а сами коммиты пока держит вторая ветка Ани, retry-log. Когда сборка мусора удаляет недостижимое, сказано в главе 1.4.

1. До rebase a6f253d 3b31e8b f178e64 1ee0b4e merge-base e0377f6 5058e4c c357ae4 main retry HEAD rebase перенесёт main..retry 2. После git rebase main a6f253d 3b31e8b f178e64 1ee0b4e 67bce9b cb6bb38 48b7729 e0377f6 5058e4c c357ae4 тот же патч, новый хеш main ORIG_HEAD retry HEAD старые коммиты целы, но retry на них больше не указывает
Rebase записывает коммиты заново. Git применил патчи трёх коммитов Ани поверх 1ee0b4e и получил коммиты с другими хешами. Прежние остались в репозитории, retry на них больше не указывает, а хеш последнего хранит ORIG_HEAD.

Новые коммиты rebase создаёт не всегда. Если ветка уже стоит на вершине main, переносить нечего:

$ git rebase main
Current branch retry is up to date.

Интерактивный rebase тоже оставляет коммит как есть, пока тот не меняется и родитель у него прежний: хеш совпадает до знака, даже дата коммитера не обновляется. Все коммиты заново создаёт --force-rebase. Merge-коммиты внутри ветки rebase по умолчанию выбрасывает и выпрямляет историю, а сохраняет их --rebase-merges.

Отмена: ORIG_HEAD и --abort

Rebase прошёл без единого конфликта, а собрать ветку уже нельзя:

$ go build ./...
# shop
./orders.go:7:19: not enough arguments in call to s.Find
	have (int)
	want (context.Context, int)

Боря поменял сигнатуру Find и поправил все вызовы, какие были в main. Вызов в orders.go Аня написала в своей ветке, Боря его не видел, а текстового конфликта не случилось: правки лежат в разных файлах. Так появился коммит 48b7729, который никто ни разу не собирал. Вернём ветку туда, где она стояла до rebase (про режимы reset есть глава 5.1):

$ git reset --hard ORIG_HEAD
HEAD is now at c357ae4 Искать заказ с повтором
$ go build ./...
$ git rev-parse --short ORIG_HEAD
48b7729

ORIG_HEAD хранит одно значение, и его перезаписывает следующий reset, merge, rebase и даже git stash, а commit и switch не трогают. Сам reset записал туда вершину, с которой ушёл, и повторный вызов вернул бы ветку на 48b7729. Затёртое значение найдёт reflog (глава 5.2).

Такой коммит ловят прямо во время переноса флагом -x: после каждого перенесённого коммита rebase запускает команду и останавливается, если она упала:

$ git rebase -x 'go build ./...' main
Executing: go build ./...
Executing: go build ./...
Executing: go build ./...
# shop
./orders.go:7:19: not enough arguments in call to s.Find
	have (int)
	want (context.Context, int)
warning: execution failed: go build ./...
You can fix the problem, and then run

  git rebase --continue
$ git branch -v
* (no branch, rebasing retry) a33e906 Искать заказ с повтором
  main                        1ee0b4e Описать запуск в README
  retry                       c357ae4 Искать заказ с повтором
  retry-log                   e078aaa Логировать неудачные попытки

Третий коммит не собрался (хеш у него не 48b7729: перенос шёл позже, и дата коммитера другая). git status пишет interactive rebase in progress: под капотом у -x интерактивный rebase, хотя редактор не открывался. HEAD отсоединён и стоит на новом коммите, а retry всё ещё на c357ae4: ветку rebase переставляет только в конце. Выхода два. git rebase --abort возвращает ветку, индекс и рабочую копию как было:

$ git rebase --abort
$ git log --oneline -1
c357ae4 (HEAD -> retry) Искать заказ с повтором

Или починить коммит на месте. Аня снова запускает тот же git rebase -x, на остановке передаёт контекст в вызов в orders.go, дописывает правку в коммит и продолжает:

$ go build ./...
$ git commit -a --amend --no-edit
[detached HEAD 0bb7055] Искать заказ с повтором
 Date: Wed Sep 2 12:00:00 2026 +0300
 1 file changed, 13 insertions(+)
 create mode 100644 orders.go
$ git rebase --continue
Successfully rebased and updated refs/heads/retry.
$ git log --oneline main..retry
0bb7055 (HEAD -> retry) Искать заказ с повтором
ce27cb2 Не ждать после последней попытки
dbe542f Повторять запрос при ошибке

На конфликте rebase останавливается так же, и к двум выходам добавляется --skip: выбросить текущий коммит. Как разрешать конфликт и почему ours и theirs при rebase меняются местами, объясняет глава 3.2.

--onto: пересадить только свои коммиты

Ветку retry-log Аня начала поверх старой retry. Rebase двигал только retry, и retry-log осталась на коммитах, которых в retry больше нет. Обычный git rebase retry возьмёт всё, что есть в ветке и чего нет в retry, то есть четыре коммита вместе со старыми копиями:

$ git switch retry-log
Switched to branch 'retry-log'
$ git log --oneline retry..
e078aaa (HEAD -> retry-log) Логировать неудачные попытки
c357ae4 Искать заказ с повтором
5058e4c Не ждать после последней попытки
e0377f6 Повторять запрос при ошибке
$ git rebase retry                 # строки hint: убраны
warning: skipped previously applied commit e0377f6
warning: skipped previously applied commit 5058e4c
Auto-merging orders.go
CONFLICT (add/add): Merge conflict in orders.go
error: could not apply c357ae4... Искать заказ с повтором
Could not apply c357ae4... # Искать заказ с повтором

Первые два коммита git пропустил сам. Перед переносом он сравнивает patch-id (хеш диффа без номеров строк и пробелов) коммитов ветки с коммитами, которые есть в retry, но не в ветке: совпал патч, и git выбрасывает коммит. Третий коммит Аня чинила, его патч изменился, и git попытался применить старую версию поверх новой. Отменяем и говорим прямо, что переносить:

$ git rebase --abort
$ git rebase --onto retry c357ae4
Successfully rebased and updated refs/heads/retry-log.
$ git log --oneline main..
7a20d88 (HEAD -> retry-log) Логировать неудачные попытки
0bb7055 (retry) Искать заказ с повтором
ce27cb2 Не ждать после последней попытки
dbe542f Повторять запрос при ошибке

Полная форма: git rebase --onto <новая база> <старая база> [<ветка>]. Команда берёт коммиты из <старая база>..<ветка> и записывает их поверх новой, а без --onto базы совпадают. Старой базой стала прежняя вершина retry, и переехал один коммит. Так же пересаживают ветку, начатую по ошибке не от main.

--onto сверяет патчи со старой базой, а не с новой

В копии репозитория Боря по ошибке начал ветку readme-env от retry, а Аня забрала его коммит к себе через cherry-pick. Боря пересаживает ветку на main:

$ git rebase --onto main retry readme-env
warning: skipped previously applied commit 8086a14
hint: use --reapply-cherry-picks to include skipped commits
hint: Disable this message with "git config set advice.skippedCherryPicks false"
Successfully rebased and updated refs/heads/readme-env.
$ git log --oneline -1
1ee0b4e (HEAD -> readme-env, main) Описать запуск в README

Патчи ветки git сверил с коммитами retry, которых в ветке нет, нашёл там копию и выбросил коммит, хотя в main этой правки нет. Ветка стала равна main. Видишь skipped previously applied commit при --onto, проверь результат: откат через ORIG_HEAD и повтор с --reapply-cherry-picks вернут коммит.

Интерактивный rebase: план в редакторе

Боря собрал в ветке metrics пять коммитов, и в таком виде показывать их на ревью не стоит:

$ git log --oneline main..metrics
63862be (HEAD -> metrics) опечатка в имени метрики
ef79a30 отладка
768088a Отдавать метрики по HTTP
96a8725 wip
f58c0cf счётчик

git rebase -i main сначала открывает редактор с планом: в строке команда и коммит, сверху самый старый, заголовок после # (так с git 2.50) служит комментарием. Ниже идёт справка по командам, её пересказывает таблица:

pick f58c0cf # счётчик
pick 96a8725 # wip
pick 768088a # Отдавать метрики по HTTP
pick ef79a30 # отладка
pick 63862be # опечатка в имени метрики

# Rebase 1ee0b4e..63862be onto 1ee0b4e (5 commands)

Строки выполняются сверху вниз, их можно переставлять и менять в них команду. Боря пишет так:

reword f58c0cf # счётчик
fixup 63862be # опечатка в имени метрики
pick 96a8725 # wip
squash 768088a # Отдавать метрики по HTTP
drop ef79a30 # отладка
КомандаЧто делает
pickберёт коммит как есть
rewordберёт коммит, открывает редактор с сообщением
editберёт коммит и останавливается для commit --amend или разбиения
squashвклеивает в предыдущий коммит, открывает редактор с обоими сообщениями
fixupвклеивает в предыдущий коммит, сообщение остаётся от предыдущего; -C берёт сообщение этого
dropвыбрасывает коммит
execзапускает команду оболочки, -x ставит её после каждого коммита
breakостанавливает rebase здесь
update-refв конце ставит сюда ветку (стопка веток ниже)

Первым идёт reword: git открывает редактор с сообщением счётчик, и Боря пишет вместо него «Считать запросы к заказам». fixup молча вклеивает исправление опечатки в тот же коммит. На squash в редакторе оба сообщения, начало файла такое:

# This is a combination of 2 commits.
# This is the 1st commit message:

wip

# This is the commit message #2:

Отдавать метрики по HTTP

Строки с # git выбросит сам, Боря стирает wip. После редакторов в терминале остаётся вот что:

$ git rebase -i main
[detached HEAD c6bea21] Считать запросы к заказам
 Date: Sat Sep 5 10:00:00 2026 +0300
 1 file changed, 7 insertions(+)
 create mode 100644 metrics.go
[detached HEAD 04006db] Отдавать метрики по HTTP
 Date: Sat Sep 5 11:00:00 2026 +0300
 1 file changed, 15 insertions(+)
 create mode 100644 metrics_http.go
Successfully rebased and updated refs/heads/metrics.
$ git log --oneline main..metrics
04006db (HEAD -> metrics) Отдавать метрики по HTTP
f17a06f Считать запросы к заказам

Из пяти коммитов осталось два. c6bea21 промежуточный: его создал reword, а fixup заменил на f17a06f. Вместо drop строку можно было стереть, git молча выбросит и такой коммит.

--fixup и --autosquash: правка старого коммита

На ревью Боре предложили считать ещё и ошибки. Новый счётчик orderErrors относится к первому коммиту ветки, а --amend дотягивается только до последнего (глава 2.1). Коммит, в заголовке которого записано, куда его вклеить, создаёт --fixup:

$ git commit -a --fixup=f17a06f
[metrics efc8c94] fixup! Считать запросы к заказам
 1 file changed, 2 insertions(+)
$ git log --oneline main..metrics
efc8c94 (HEAD -> metrics) fixup! Считать запросы к заказам
04006db Отдавать метрики по HTTP
f17a06f Считать запросы к заказам

git rebase -i --autosquash main находит целевой коммит по заголовку после fixup! и сам ставит исправление в план следующей строкой. Боря закрывает редактор без правок:

pick f17a06f # Считать запросы к заказам
fixup efc8c94 # fixup! Считать запросы к заказам
pick 04006db # Отдавать метрики по HTTP
$ git rebase -i --autosquash main
Successfully rebased and updated refs/heads/metrics.
$ git log --oneline main..metrics
f2eae8f (HEAD -> metrics) Отдавать метрики по HTTP
b4e4e49 Считать запросы к заказам

С git 2.44 --autosquash работает и без -i, а rebase.autoSquash=true включает его по умолчанию только для интерактивного rebase. Сообщение правят через --fixup=reword:<коммит>, сообщение и содержимое через --fixup=amend:<коммит> (оба с git 2.32): они открывают редактор и создают коммит amend! …. Готовую ветку Боря влил в main перемоткой, git merge --ff-only metrics (глава 3.1).

Стопка веток: --update-refs

retry-log стоит на retry: это стопка, вторая ветка поверх первой, у каждой свой merge request. main опять ушёл вперёд. Обычный git rebase main из retry-log перенёс бы все четыре коммита, но retry осталась бы на старых, и снова понадобился бы --onto. Флаг --update-refs (git 2.38) двигает и ветки, указывающие на переносимые коммиты. Аня переключается на retry-log и запускает git rebase -i --update-refs main, в плане такая ветка видна строкой update-ref:

pick dbe542f # Повторять запрос при ошибке
pick ce27cb2 # Не ждать после последней попытки
pick 0bb7055 # Искать заказ с повтором
update-ref refs/heads/retry

pick 7a20d88 # Логировать неудачные попытки
$ git rebase -i --update-refs main
Successfully rebased and updated refs/heads/retry-log.
Updated the following refs with --update-refs:
	refs/heads/retry
$ git log --oneline -5
adf9d5b (HEAD -> retry-log) Логировать неудачные попытки
a1de955 (retry) Искать заказ с повтором
a9378c9 Не ждать после последней попытки
f30b966 Повторять запрос при ошибке
f2eae8f (metrics, main) Отдавать метрики по HTTP

Обе ветки переехали. Флаг работает и без -i, а насовсем его включает git config --global rebase.updateRefs true.

cherry-pick: копия одного коммита

git cherry-pick делает с одним коммитом то же, что rebase с каждым: вычисляет его патч и записывает новый коммит поверх HEAD. Боря исправил в main печать копеек, а в ветке release/0.1, начатой от старого коммита, та же ошибка:

$ git log --oneline -1
81a63fb (HEAD -> main) Печатать копейки двумя знаками
$ git switch -c release/0.1 3b31e8b
Switched to a new branch 'release/0.1'
$ go run .
заказ 1: 500.5 ₽ <nil>
$ git cherry-pick -x main
[release/0.1 95849ba] Печатать копейки двумя знаками
 Date: Tue Sep 8 10:00:00 2026 +0300
 1 file changed, 1 insertion(+), 1 deletion(-)
$ go run .
заказ 1: 500.05 ₽ <nil>
$ git log -1 --format=%B
Печатать копейки двумя знаками

(cherry picked from commit 81a63fb7cb0b0198b51a08e525169e1c22e8f60f)

-x дописал в сообщение полный хеш исходного коммита, и по нему потом выясняют, откуда пришло исправление и во все ли релизные ветки оно попало. Документация советует -x для переноса между опубликованными ветками: хеш из локальной ветки никому ничего не скажет. git cherry-pick A..B переносит несколько коммитов по порядку, без самого A. У копии тот же patch-id, поэтому rebase на ветку с копией пропустит оригинал, как пропустил два коммита в разделе про --onto.

Опубликованную историю не переписывают

Всё выше меняло только локальный репозиторий. Когда старые коммиты уже на сервере, на них опираются чужие клоны. Обычный push переписанной ветки git отклонит как non-fast-forward, а после принудительного у коллег окажутся две истории с одинаковыми патчами. Слияние склеит обе, и каждое изменение попадёт в историю дважды (как с amend в главе 2.1). Кто строил на твоей ветке, будет пересаживать свои коммиты.

Что можно переписывать, а что нет

Неотправленные коммиты переписывай как угодно. Свою ветку merge request, на которой никто не строит, тоже можно: её отправляют через git push --force-with-lease, который откажет, если на сервере появилось чужое (глава 4.1). main, релизные и другие общие ветки не переписывают: ошибку в них исправляют новым коммитом или git revert (глава 5.1).

merge, rebase и squash-merge на одной ветке

Вернёмся к исходному состоянию: в main новая сигнатура Find, в retry три коммита Ани до починки. Так бывает, когда CI проверил ветку раньше, чем main ушёл вперёд. Вольём retry в три копии репозитория по очереди: слиянием, rebase с перемоткой и squash-merge, который кладёт всю работу ветки в main одним коммитом.

$ git merge -q retry
$ git log --oneline --graph
*   6f84f94 (HEAD -> main) Merge branch 'retry'
|\
| * c357ae4 (retry) Искать заказ с повтором
| * 5058e4c Не ждать после последней попытки
| * e0377f6 Повторять запрос при ошибке
* | 1ee0b4e Описать запуск в README
* | f178e64 Передавать контекст в хранилище
|/
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop
$ git rebase -q main retry
$ git switch -q main
$ git merge -q --ff-only retry
$ git log --oneline --graph
* 48b7729 (HEAD -> main, retry) Искать заказ с повтором
* cb6bb38 Не ждать после последней попытки
* 67bce9b Повторять запрос при ошибке
* 1ee0b4e Описать запуск в README
* f178e64 Передавать контекст в хранилище
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop
$ git merge -q --squash retry
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested
$ git commit -q -m 'Повторять поиск заказа при ошибке'
$ git log --oneline --graph
* 8848d58 (HEAD -> main) Повторять поиск заказа при ошибке
* 1ee0b4e Описать запуск в README
* f178e64 Передавать контекст в хранилище
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop

Ни одна из трёх копий не собирается. Виноватый коммит ищет git bisect: хороший 3b31e8b, плохой main, проверка go build ./... (как устроен bisect run, смотри в главе 5.3). В копии со слиянием:

$ git bisect start main 3b31e8b
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[c357ae48749e42794b8607ac570c0e8c23e4aaaa] Искать заказ с повтором
$ git bisect run go build ./...
running 'go' 'build' './...'
Bisecting: 0 revisions left to test after this (roughly 1 step)
[1ee0b4e913593137fbaf58f01293fe2c8b5aac8e] Описать запуск в README
running 'go' 'build' './...'
6f84f94f943383ad5fe8606be46a204ff5dec1fa is the first bad commit
commit 6f84f94f943383ad5fe8606be46a204ff5dec1fa
Merge: 1ee0b4e c357ae4
Author: Боря <borya@example.com>
Date:   Fri Sep 4 10:00:00 2026 +0300

    Merge branch 'retry'

 orders.go | 11 +++++++++++
 retry.go  | 16 ++++++++++++++++
 2 files changed, 27 insertions(+)
 create mode 100644 orders.go
 create mode 100644 retry.go
bisect found first bad commit

Если прогнать то же в других копиях, выйти через git bisect reset, откатить найденный коммит и удалить ветку, выйдет так:

mergerebase и fast-forwardsquash-merge
Что нашёл bisect6f84f94, слияние; обе стороны по отдельности собираются48b7729, один файл и 11 строк8848d58, вся ветка: два файла, 27 строк
git revert HEADотказ без -m; с -m 1 уходит вся веткауходит только orders.go, сборка снова проходитуходит вся ветка
git branch -d retryудаляетудаляетотказ: not fully merged
merge git merge retry 3b31e8b f178e64 1ee0b4e 6f84f94 e0377f6 5058e4c c357ae4 bisect: слияние 6f84f94 revert только с -m 1, уходит вся ветка branch -d retry: удалит rebase и fast-forward git rebase main retry 3b31e8b f178e64 1ee0b4e 67bce9b cb6bb38 48b7729 bisect: 48b7729, один файл revert HEAD убирает только orders.go branch -d retry: удалит squash-merge git merge --squash retry 3b31e8b f178e64 1ee0b4e 8848d58 e0377f6 5058e4c c357ae4 коммиты retry не в main bisect: 8848d58, вся ветка revert HEAD убирает всю ветку разом branch -d retry: откажет
Одна ветка, три истории. Отмечен коммит, на который указал git bisect: слияние, один небольшой коммит или вся ветка разом. От этого зависит и то, что уберёт один git revert.

После слияния bisect указал на merge-коммит, и ответ честный: каждая сторона собирается, сломало их сочетание, искать придётся в обоих родителях. Revert слияния с -m 1 убирает всю ветку (глава 3.1), а повторно влить её после починки так просто не выйдет: старые коммиты Ани git считает влитыми, и retry.go не вернётся (ловушка разобрана в главе 5.1). После rebase виноват маленький коммит, и откатить можно его одного. Но 48b7729 создал rebase, и никто его не собирал, пока не сломался main. Переносишь ветку, гоняй тесты на каждом коммите: git rebase -x 'go test ./...' main.

После squash-merge история короче всех и revert убирает ветку одной командой, зато bisect не пойдёт глубже коммита на всю ветку. Для git retry не влита: в main лежит сумма её изменений, а не её коммиты, и git log main..retry по-прежнему выводит три коммита. Если поменять в retry паузу в retry.go и влить ветку снова, merge-base останется 3b31e8b: правка столкнётся со squash-копией старых коммитов, и выйдет конфликт add/add в retry.go. На GitHub три способа называются Merge pull request, Rebase and merge и Squash and merge, а GitLab при squash и методе Merge commit положит поверх squash-коммита ещё merge-коммит.

Вопросы

4
Суть: merge оставляет коммиты ветки как есть и добавляет merge-коммит с двумя родителями. Rebase записывает коммиты ветки заново поверх main, история остаётся прямой. Squash-merge кладёт в main один новый коммит с суммой изменений. От выбора зависит, насколько точно bisect укажет виноватого и что уберёт один revert.

Что остаётся в истории

После merge в main исходные коммиты ветки и merge-коммит, а --first-parent показывает влитую ветку одним коммитом (глава 1.2). Rebase ставит коммиты в линию с новыми хешами. После squash-merge исходные коммиты живут только в ветке, пока её не удалили.

bisect и revert

  • merge: bisect указывает на merge-коммит, если виновато сочетание сторон; revert требует -m 1 и убирает ветку целиком (ловушки в главе 5.1);
  • rebase: bisect доходит до маленького коммита, и откатить можно его одного;
  • squash-merge: коммит на всю ветку, revert простой, но точнее bisect не скажет.

Как выбирают

Свою ветку перед вливанием переносят на свежий main и гоняют тесты на каждом коммите. Осмысленные коммиты сохраняют: rebase с перемоткой или merge, если нужна граница ветки в истории. Ветку из «wip» и «правок по ревью» вливают squash-merge. Обычно команда закрепляет один способ в настройках проекта.

Чистая история из коммитов, которых никто не собирал

Rebase без -x не проверяет ни один перенесённый коммит. В демо-репозитории перенос прошёл без конфликтов, а 48b7729 не компилируется. Кто хвалит rebase за линейную историю и молчит про -x или CI после переноса, получает лог с несобираемыми коммитами, которые потом мешают bisect.

Суть: пока rebase не закончен (конфликт, edit, упавший -x), git rebase --abort возвращает всё как было. Сразу после окончания помогает git reset --hard ORIG_HEAD: там прежняя вершина. Старые коммиты целы.

Rebase ещё идёт

На остановке HEAD отсоединён, а ветка стоит на старом коммите, её rebase переставит только в конце. Отсюда три пути: --continue после исправления, --skip, чтобы выбросить текущий коммит, и --abort, чтобы отменить всё.

Rebase закончен

--abort уже ответит fatal: no rebase in progress. Прежняя вершина лежит в ORIG_HEAD, сравнить её с новой можно через git range-diff main ORIG_HEAD retry, а откат такой:

$ git reset --hard ORIG_HEAD
HEAD is now at c357ae4 Искать заказ с повтором

Делать это нужно сразу: ORIG_HEAD перезаписывают reset, merge, следующий rebase и git stash, а второй такой же reset вернёт перенесённую версию. Затёртое значение хранит reflog ветки (глава 5.2).

reset посреди rebase его не отменяет

Если на остановке сделать git reset --hard ORIG_HEAD, рабочая копия вернётся к старому коммиту, но rebase не закончится: git status всё так же пишет interactive rebase in progress, а следующий git rebase откажет с fatal: It seems that there is already a rebase-merge directory. На остановке выходят через --abort.

Суть: правку содержимого коммитят с --fixup=<коммит> и вклеивают через git rebase -i --autosquash <база>. Сообщение меняют командой reword в плане или через --fixup=reword:<коммит>. Сложную правку делают на остановке edit. Переписываются этот коммит и все после него.

Правка содержимого

git commit --fixup=f17a06f создаёт коммит fixup! Считать запросы к заказам, а --autosquash по заголовку ставит его в план за целевым коммитом с командой fixup: изменения вклеятся, сообщение останется прежним.

Остановиться на коммите

В плане меняют pick на edit, и rebase останавливается сразу после этого коммита:

$ git rebase -i main
Stopped at b4e4e49...  # Считать запросы к заказам
You can amend the commit now, with

  git commit --amend

Once you are satisfied with your changes, run

  git rebase --continue

Дальше правят файлы, делают git commit -a --amend и git rebase --continue. Разбить коммит можно так: git reset HEAD^, несколько коммитов по частям (add -p, глава 2.1), потом --continue.

Что поменяется

Коммиты до исправленного сохраняют хеши, исправленный и все после него получают новые. Ветки поверх этой перенесёт --update-refs, а отправленную ветку придётся пушить с --force-with-lease (глава 4.1).

fixup! без --autosquash так и останется в истории

Обычный git rebase -i main fixup-коммиты не переставляет: они остаются на своих местах с pick и после rebase живут отдельными коммитами fixup! …. Не забывай флаг или включи rebase.autoSquash=true, она действует на интерактивный rebase.

Суть: rebase создаёт вместо коммитов новые, а старые уже есть у всех, кто сделал fetch. Обычный push не пройдёт, после принудительного у коллег разойдутся истории: слияние задвоит коммиты, ветки поверх твоей придётся пересаживать. Исключение: своя ветка merge request, на которой никто не строит, её отправляют с --force-with-lease.

Что видит сервер

Обычный push проходит, только если новая вершина потомок старой, поэтому после rebase он отклонён как non-fast-forward. После git push --force-with-lease origin retry у Бори git fetch напечатает строку с + c357ae4...48b7729 и пометкой (forced update): плюс значит принудительное обновление, слева старая вершина.

Что происходит у коллеги

Боря начал ветку retry-metrics поверх старой retry и поправил в ней retry.go. Если Аня только перенесла коммиты и патчи не поменялись, хватит обычного rebase: git узнает старые копии по patch-id и пропустит их.

$ git rebase origin/retry          # строки hint: убраны
warning: skipped previously applied commit e0377f6
warning: skipped previously applied commit 5058e4c
warning: skipped previously applied commit c357ae4
Successfully rebased and updated refs/heads/retry-metrics.

Если патчи менялись (склейка, починка, разрешённый конфликт), git rebase origin/retry старые версии не узнает и попробует применить их второй раз. Тогда переносят только свои коммиты, указав старую вершину из строки fetch: git rebase --onto origin/retry c357ae4. git pull --rebase находит её сам (глава 4.1).

Подсказка «use git pull» ведёт не туда

После fetch git status у Бори пишет, что ветка и origin/retry «have diverged, and have 4 and 5 different commits each», и предлагает git pull. git pull --no-rebase сольёт старые копии коммитов Ани с новыми: у Бори это кончилось конфликтом add/add в retry.go, а в ветке без правок в тех же файлах обе копии молча легли в историю рядом. git pull --rebase переносит только коммит Бори.