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, хотя выше маркеров они уже слиты.
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, dcbc4e9 | git show :1:config.go |
| 2 | ours, текущая ветка | HEAD, e2037fb | git show :2:config.go, checkout --ours |
| 3 | theirs, вливаемая ветка | MERGE_HEAD, ae27ed7 | git show :3:config.go, checkout --theirs |
| 0 | разрешённый файл | git add | git 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, маркеров нет | UU | 1, 2, 3 |
| add/add | обе создали retry.go | AA | 2, 3 |
| modify/delete | HEAD удалил legacy.go, limits изменил | DU | 1, 3 |
| rename/delete | HEAD переименовал util.go, limits удалил | UD | 1, 2 |
| rename/rename | обе переименовали db.go, каждая по-своему | DD, AU, UA | 1 / 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 удалит.
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.
Сами -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 отказывается работать, пока они есть.
Порядок
- Разрешить файлы по смыслу, глядя на базу (zdiff3 или
git show :1:путь). Удалённый одной стороной файл оставитьgit addили убратьgit rm. git diff --check,go build ./...,go test ./....git addна каждый путь, затемgit merge --continue.
Взял не ту сторону: git checkout -m путь вернёт маркеры, даже если файл уже добавлен.
Хочешь начать заново: git merge --abort (глава 3.1).
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, стадия 2 | theirs, стадия 3 | база, стадия 1 |
|---|---|---|---|
git merge feature на main | main | feature | merge-base |
git rebase main на feature | main и уже перенесённые коммиты | очередной коммит feature | его родитель |
git cherry-pick C | текущая ветка | C | родитель C |
git revert C | текущая ветка | родитель C | C |
Как не запутаться
Думай не «моё и чужое», а «HEAD и то, что применяется». При rebase и cherry-pick после
>>>>>>> стоит хеш применяемого коммита. git pull --rebase
и LOCAL в mergetool подчиняются тому же правилу.
Чем грозит
Взять «свою» версию через git checkout --ours при rebase значит выбросить из файла свою же
правку. Хуже с -X (про сами опции глава 3.1): при rebase -X ours
работает против твоих коммитов, а -X theirs молча откатывает спорные правки из main.
Если вся правка коммита лежала в конфликтных кусках, 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.
Порядок
go.mod: убрать маркеры, оставить требования обеих веток; если одну зависимость подняли до разных версий, обычно берут бо́льшую.go.sum:git checkout --ours go.sumили--theirs.go mod tidy: версии до выбранных, недостающие суммы со сверкой, лишние долой.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. Он склеивает строки обеих сторон без
маркеров, и git считает go.sum разрешённым (M go.sum в статусе).
Это ровно та склейка, которой мало: go.mod всё равно конфликтует, а недостающих сумм не
прибавилось.
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.
Конфликты повторяются, потому что ветки слишком долго живут отдельно. Короткие ветки и частые слияния с 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 были разрешены, и может применить прежнее решение к повторному конфликту. Включи его для репозитория, проверь применённый результат и тесты: одинаковые маркеры не гарантируют одинаковый смысл после соседних изменений.