voidlearn
 

3.2Конфликты

Посмотришь, как конфликт оставляет три версии файла в index и маркеры в working tree. Научишься извлекать base и стороны, а при rebase правильно определять смысл ours и theirs.

Что сможешь объяснить после этой главы
  • Почему правки в соседних строках конфликтуют, а через строку сливаются сами.
  • Как читать маркеры и зачем включать 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 выносит их, как стиль по умолчанию, и оставляет базу. Уже записанный конфликт перерисует 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 запоминает разрешение и при следующей встрече применяет его сам:

$ 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 файл сразу попадает в индекс.

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, выбор минимальной версии берёт наибольшую из требуемых, а в 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 add (с rerere.autoUpdate=true добавляет сам).

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

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

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

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

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

  • Стадии 1, 2 и 3 index содержат base, ours и theirs для конфликтующего пути.
  • Файл с маркерами на диске ещё не является разрешённой версией.
  • При rebase ours обычно соответствует новой базе, на которую переносят коммит.

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

  • Не собирай go.sum руками: он должен соответствовать go.mod и доступным модулям.
  • Выбор ours/theirs при rebase может быть обратен интуитивному прочтению слов «моя ветка».

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

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

Чтение кода

1. Прочитай три стадии index

Слияние остановилось на config.yaml. Что содержат стадии и из каких файлов получить base и обе стороны?

$ git ls-files -u config.yaml
100644 3a1... 1 config.yaml
100644 7b2... 2 config.yaml
100644 9c4... 3 config.yaml
Ответ и разбор

Стадия 1 - общий предок, 2 - ours, 3 - theirs. Получи содержимое через git show :1:config.yaml, :2: и :3:. Рабочий файл с conflict markers не заменяет эти исходные три версии.

Код-ревью

2. Не бери ours вслепую при rebase

При переносе feature на main возник конфликт. Разработчик применяет:

git checkout --ours internal/store.go

Он хочет сохранить версию из feature. Почему это предположение может быть неверным и как проверить стороны?

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

Во время rebase Git воспроизводит коммиты feature поверх новой базы, поэтому ours обычно относится к текущей базе (main), а theirs - к переносимому коммиту. Проверь содержимое через stages 2 и 3 или git show :2:file/:3:file до выбора. Разреши конфликт по смыслу, а не по названию стороны.

Применение

3. Разреши конфликт go.mod

Обе ветки добавили разные прямые зависимости в go.mod, а go.sum конфликтует. Как разрешить это так, чтобы manifest и checksums остались согласованы?

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

Объедини нужные строки go.mod, не удаляя зависимость другой ветки, затем запусти go mod tidy в корректном окружении и проверь diff обоих файлов. Сверь, что версии и суммы соответствуют модульному графу. Не выбирай целиком ours или theirs, если обе стороны добавили нужные пакеты.

Применение

4. Используй rerere осознанно

Один и тот же конфликт регулярно появляется при слиянии release-ветки. Что сохранит rerere и что нужно проверить перед повторным использованием записанного решения?

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

rerere запоминает, как конфликтные hunks были разрешены, и может применить прежнее решение к повторному конфликту. Включи его для репозитория, проверь применённый результат и тесты: одинаковые маркеры не гарантируют одинаковый смысл после соседних изменений.