Git и командная работа Конфликты слияния: почему возникают, как разрешать, как предотвращать
0%

Конфликты слияния: почему возникают, как разрешать, как предотвращать

Конфликты слияния: почему возникают, как разрешать, как предотвращать

Слово «конфликт» пугает сильнее, чем заслуживает, — и виновата терминология. Оно звучит как авария: что-то сломалось, данные под угрозой, зови старшего. На деле это самое честное сообщение, которое система контроля версий может выдать: «две стороны изменили одно и то же место, я не знаю, какой вариант правильный, — решай ты». Ни один байт не потерян, ни одна ветка не пострадала, и выход из конфликтного состояния всегда есть в одну команду.

Страшно не от конфликтов, а от непонимания, что происходит: файл наполняется угловыми стрелками, git status пишет незнакомые формулировки, и кажется, что любая команда сделает хуже. Лечится это моделью данных — объекты и DAG разобраны в «Внутри Git», индекс и три дерева в «Ежедневной работе», стоимость расхождения в «Стратегиях ветвления», механика самих операций — в «Merge и rebase». Здесь — что происходит, когда автоматика сдаётся: как выглядит конфликт изнутри, какие виды бывают помимо «оба правили строку», как разрешать не наугад и как сделать так, чтобы конфликтов было в разы меньше. Последнее — почти целиком про процесс и структуру кода, а не про команды Git: конфликт не свойство инструмента, а измеримое следствие того, как долго и как широко расходятся линии работы.

Конфликт — это отказ угадывать

Конфликт возникает, когда Git не может механически построить результат, не выбрав между двумя несовместимыми изменениями одного места. Не «когда файлы разные» и не «когда ветки разошлись»: важно, что правки несовместимы относительно общего предка.

Git никогда не сравнивает две версии файла между собой. Он берёт три: базу (общий предок), нашу и их, — и применяет простое правило. Изменилась только наша сторона — берём нашу. Только их — берём их. Обе изменили одинаково — берём результат (Git смотрит на содержимое, а не на историю). Обе изменили один участок по-разному — конфликт.

# База — это merge-base, ближайший общий предок в DAG
$ git merge-base main feature/timeouts
9c1b7d4a2f8e5b3c0d6a4e8f1b2c3d4e5f6a7b8c

# Достаём ровно те три версии, которые видит алгоритм слияния
$ git show $(git merge-base main feature/timeouts):src/client.py > /tmp/base.py
$ git show main:src/client.py                                    > /tmp/ours.py
$ git show feature/timeouts:src/client.py                        > /tmp/theirs.py

# Трёхсторонний алгоритм в чистом виде: ни индекса, ни коммитов, только файлы
$ git merge-file --diff3 -L ours -L base -L theirs /tmp/ours.py /tmp/base.py /tmp/theirs.py
$ echo $?
1     # 1 = остались конфликтные участки; 0 = слилось чисто

git merge-file полезен именно «голостью»: если непонятно, почему кусок конфликтует, воспроизведите его на трёх временных файлах — станет видно, что всё зависит только от текста, а не от того, кто первый запушил.

Трёхпутевое слияние: база решает, что считать изменением

Единица сравнения — не файл и не строка, а hunk: непрерывный блок изменённых строк вместе с контекстом. Отсюда два следствия, объясняющих половину «странностей». Правки, разнесённые на десяток строк, сольются молча — это разные ханки. Правки в соседних строках дадут конфликт, хотя логически не пересекаются: ханки склеились через общий контекст. Поэтому «я всего лишь добавил импорт» регулярно превращается в конфликт — импорты живут плотным блоком.

Что происходит с репозиторием: stage 1, 2 и 3

Здесь модель данных окупается мгновенно. Индекс, описанный в «Ежедневной работе» как плоский список «путь → режим → хеш блоба», хранит для каждого пути ещё и номер стадии. В обычной жизни у всех записей stage 0. Во время конфликта запись stage 0 для конфликтного пути исчезает, а вместо неё появляются до трёх: stage 1 — база (из merge-base), stage 2 — ours (из HEAD), stage 3 — theirs (из MERGE_HEAD или переносимого коммита).

Отсюда два факта, которые обычно заучивают как заклинания. git commit во время конфликта отказывается работать, потому что коммит строится из индекса через git write-tree, а дерево нельзя собрать, пока есть записи со стадией больше нуля. А git add <файл> во время конфликта означает не «добавить изменения», а «я разрешил этот путь»: он схлопывает три записи в одну со stage 0.

$ git merge feature/timeouts
Auto-merging src/client.py
CONFLICT (content): Merge conflict in src/client.py
CONFLICT (modify/delete): src/legacy.py deleted in feature/timeouts and modified in HEAD.
Version HEAD of src/legacy.py left in tree.
Automatic merge failed; fix conflicts and then commit the result.

$ git status --short
UU src/client.py
DU src/legacy.py

$ git ls-files -u
100644 6f3d2a1cbb0e4f7a9d2c5b8e1f0a3d6c9b2e5f81 1	src/client.py
100644 a91b0c4e7d2f5a8b3c6e9f1d4a7b0c3e6f9a2d55 2	src/client.py
100644 3cc7e51d8a4b7c0e3f6a9d2b5e8f1a4c7b0d3e66 3	src/client.py
100644 5d8e1f4a7b0c3e6f9a2d5b8e1f4a7c0d3e6f9a21 1	src/legacy.py
100644 5d8e1f4a7b0c3e6f9a2d5b8e1f4a7c0d3e6f9a21 2	src/legacy.py

$ git show :1:src/client.py > /tmp/base.py     # любую версию можно вытащить,
$ git show :2:src/client.py > /tmp/ours.py     # не трогая рабочее дерево
$ git show :3:src/client.py > /tmp/theirs.py

У src/legacy.py нет stage 3 — файл удалён на их стороне. Это не сломанный вывод, а точное описание ситуации. Двухбуквенные коды git status --short для неслитых путей прямо кодируют, какие стадии присутствуют: UU — оба изменили содержимое, AA — оба добавили файл с одним путём, DD — оба удалили, AU — добавлено нами, UA — добавлено ими, DU — удалено нами и изменено ими, UD — изменено нами и удалено ими.

Само «состояние конфликта» — это несколько обычных файлов в .git, и знание их имён снимает магию: MERGE_HEAD (что вливаем), MERGE_MSG (заготовка сообщения со списком конфликтов), MERGE_MODE, ORIG_HEAD (где был HEAD до операции), AUTO_MERGE. При rebase вместо MERGE_HEAD появляется каталог .git/rebase-merge/ с onto, git-rebase-todo и счётчиками msgnum/end; при cherry-pick и revert — CHERRY_PICK_HEAD и REVERT_HEAD. Универсальное правило: не понимаете, в какой вы операции, — посмотрите, какой из этих файлов существует; git status делает ровно это.

Отдельно про AUTO_MERGE — подарок стратегии ort (по умолчанию с Git 2.34). Это ссылка на дерево, которое Git собрал автоматически, включая файлы с маркерами. Пока вы разрешаете конфликт, git diff AUTO_MERGE показывает только ваши ручные правки относительно автоматики — лучший способ проверить себя перед завершением слияния.

Маркеры конфликта и три стиля

Когда автоматика не справилась, Git пишет в рабочее дерево версию файла с маркерами. Это обычный текст: <<<<<<<, =======, >>>>>>> и (в расширенных стилях) |||||||, каждый на отдельной строке, по семь символов.

Анатомия маркеров конфликта в стилях merge, diff3 и zdiff3

Почему выбор стиля — не косметика. В стиле merge (умолчание) видны два готовых варианта, но не видно, от чего каждая сторона отталкивалась. Различить «коллега поменял 30 на 45» и «коллега вообще не трогал это место, просто ханки склеились» невозможно, а решения это требует разных. diff3 добавляет базу. zdiff3 делает то же самое и дополнительно выносит за пределы конфликтного блока строки, которые обе стороны написали одинаково: конфликт из девяти строк сжимается до одной действительно спорной. Включайте zdiff3 глобально и не выключайте — единственная цена — несколько лишних строк базы, ровно та информация, ради которой всё затевалось.

$ git config --global merge.conflictStyle zdiff3   # требует Git >= 2.35 (январь 2022)

# Вернуть конфликтное состояние файла, если вы уже «замазали» маркеры
$ git checkout --conflict=zdiff3 -- src/client.py
$ git restore --merge -- src/client.py             # современный эквивалент

# Не дать маркерам уехать в коммит: в pre-commit-хук
$ git diff --cached --check
src/client.py:42: leftover conflict marker
$ git grep -nE '^(<{7}|={7}|>{7}|\|{7})( |$)'      # точечный поиск по дереву

Обе команды восстановления берут данные из индекса (те самые стадии), поэтому работают, пока вы не сделали git add. После add стадии схлопнулись: остаётся git checkout -m, который заново прогонит слияние, или git merge --abort и повтор. Проверку --check стоит вынести в хук — как это сделать надёжно, разбирают «Хуки и автоматизация».

Таксономия: конфликты бывают не только «оба правили строку»

Content-конфликт — самый частый, но далеко не единственный, и остальные пугают сильнее именно потому, что встречаются реже.

Вид Когда возникает Чем разрешают
content обе стороны правили один ханк правкой руками, маркеры в файле
add/add обе стороны создали файл с одним путём слияние с пустой базой: любое различие спорно
modify/delete одна правила, другая удалила git rm <путь> или git add <путь> — это вопрос к автору удаления, а не текстовый
rename/rename, rename/delete обе переименовали по-разному либо одна переименовала, другая удалила выбрать имя и перенести правки обеих сторон
directory/file у одной файл config, у другой каталог config/ Git добавит суффикс ~HEAD; чинится git mv и git rm
binary оба правили PNG, PDF, архив маркеров нет, выбирается версия целиком
submodule, режим файла указатель сдвинут на разные коммиты; 100644 против 100755 указать SHA явно (см. «Монорепо»); git update-index --chmod=+x
семантический текстово чисто, но код не собирается Git не видит вообще; только сборка и тесты

Отдельно про переименования: Git их не хранит, а вычисляет при сравнении деревьев по схожести содержимого (порог 50%, настраивается). Когда одна сторона переименовала client.py в http_client.py, а другая правила client.py, стратегия ort честно перенесёт правки в новое имя.

CONFLICT (rename/rename): src/client.py renamed to src/http_client.py in HEAD
and to src/net/client.py in feature/x.

$ git merge -X find-renames=30% feature/x   # Git «не заметил» переименование
$ git merge -X no-renames feature/x         # обратное: склеил два независимых файла
$ git checkout --theirs -- assets/logo.png && git add assets/logo.png   # бинарник

Рабочий цикл разрешения

Хороший алгоритм разрешения выглядит скучно и почти не содержит творчества. Именно поэтому он работает.

Шаг 1 — понять, где вы. git status во время конфликта единственный источник правды: он всегда пишет, в какой вы операции и как из неё выйти, и подсказки в скобках меняются для merge, rebase, cherry-pick и revert.

$ git status
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)
Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   src/client.py

Шаг 2 — посмотреть, а не гадать. Здесь большинство смотрит только на маркеры в редакторе и теряет главное — зачем каждая сторона сделала своё изменение.

$ git diff                              # комбинированный diff: только спорные ханки
$ git diff --base   -- src/client.py    # что поменялось относительно предка
$ git diff --ours   -- src/client.py    # чем файл отличается от нашей версии
$ git diff --theirs -- src/client.py    # ...от их версии

# Коммиты обеих сторон, трогавшие неслитые пути
$ git log --merge --oneline --left-right
< 4a1c2e9 main: увеличить таймаут до 60 после инцидента INC-412
> 8f3b7d1 feature/timeouts: снизить таймаут до 45 по требованию SLA

$ git log --merge -p -- src/client.py   # то же с патчами — обычно этого достаточно

В примере видно, что конфликт не текстовый, а содержательный: два инженера решают взаимоисключающие задачи. Правильное действие — не выбрать «покрасивее», а написать обоим. Это не редкость: заметная часть конфликтов вскрывает расхождение в требованиях, а не в коде.

Шаг 3 — разрешить. Правьте файл руками, убирая все маркеры, включая ||||||| и базовую секцию. Результат — не «наше», не «их» и не механическая склейка, а корректный код, отвечающий обоим намерениям (или одному, если второе отменено осознанно).

$ git checkout --ours   -- package-lock.json   # взять файл ЦЕЛИКОМ из нашей версии
$ git checkout --theirs -- package-lock.json   # взять файл ЦЕЛИКОМ из их версии
$ git restore --source=:2 -- src/client.py     # то же через явную стадию

$ git mergetool --tool-help                    # что доступно в системе
$ git config --global merge.tool meld          # или kdiff3, nvimdiff, vscode, p4merge
$ git config --global mergetool.keepBackup false   # не плодить файлы *.orig

Главная ловушка. --ours и --theirs в git checkout работают на уровне файла, а не ханка. Если конфликтен один ханк из десяти, а девять слились автоматически, git checkout --ours выбросит все девять успешно слитых изменений другой стороны — тихо, без предупреждений, с чистым git status. Применяйте это только там, где построчное слияние бессмысленно: lock-файлы, сгенерированные схемы, бинарники. Для остального — руками или через -X ours при самом слиянии (оно влияет только на спорные ханки). Трёхпанельные инструменты (meld, kdiff3, встроенный в VS Code) полезны тем, что показывают базу отдельным окном — визуальный аналог zdiff3; настройку редактора разбирает трек «Редакторы».

Шаг 4 — пометить и проверить.

$ git add src/client.py     # стадии 1/2/3 схлопнулись в stage 0
$ git diff AUTO_MERGE       # что я изменил относительно автоматики Git
$ git diff --cached --check # не осталось ли маркеров и мусора
$ git ls-files -u           # пусто = неслитых путей больше нет
$ git merge --continue      # или git commit; сообщение уже лежит в MERGE_MSG

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

Шаг 5 — если пошло не так. git merge --abort (или rebase --abort, cherry-pick --abort) возвращает HEAD, индекс и рабочее дерево ровно туда, где они были; при необходимости то же грубее делает git reset --hard ORIG_HEAD. --abort не «теряет работу» — он отменяет операцию слияния, а коммиты обеих веток неизменяемы и никуда не делись. Если что-то ценное всё же пропало, это тема следующей статьи: reflog помнит больше, чем кажется.

Как потом читать чужое разрешение. Merge-коммит по умолчанию выглядит пустым: git show для слияния печатает комбинированный diff, скрывающий всё, что совпало хотя бы с одним родителем. Ручные правки момента разрешения почти невидимы — так рождаются «evil merges», протаскивающие изменения мимо ревью.

$ git show --remerge-diff <merge-sha>   # Git >= 2.36: разница с АВТОМАТИЧЕСКИМ слиянием
$ git show -m <merge-sha>               # два обычных diff'а, отдельно к каждому родителю
$ git log --cc -p                       # комбинированный diff по истории

--remerge-diff — самый недооценённый флаг в Git: он заново выполняет слияние родителей в памяти и показывает разницу между автоматикой и тем, что реально попало в коммит. Всё, что там видно, — ручные правки автора. Как встроить это в ревью, обсуждают «Совместная работа» и статья «Как создать хороший Pull Request».

Rebase и cherry-pick: почему «наше» и «их» переворачиваются

Это причина номер один неправильных разрешений, и она чисто механическая. При merge вы стоите на своей ветке и втягиваете чужую: ours — ваша ветка. При rebase Git сначала переставляет HEAD на ветку-приёмник, а затем проигрывает ваши коммиты поверх неё как патчи. Для каждого применения патча «текущее состояние» — это приёмник, а «применяемое» — ваш коммит. Стороны меняются местами.

Операция ours / stage 2 / <<<<<<< theirs / stage 3 / >>>>>>>
git merge feature ваша текущая ветка вливаемая feature
git rebase main main — то, НА что переносим ваш переносимый коммит
git cherry-pick X текущий HEAD коммит X
git revert X текущий HEAD обратный патч к X
git stash pop состояние рабочего дерева содержимое стеша

Практический вывод: при rebase -X theirs означает «предпочесть мои изменения», а -X ours — «предпочесть то, что уже в main». Написано в документации, но противоречит интуиции ровно настолько, чтобы регулярно приводить к молчаливой потере работы. Сомневаетесь — не угадывайте: посмотрите git log --merge или содержимое стадий через git show :2: и git show :3:.

Второе следствие: при rebase один и тот же конфликт может встретиться на каждом переносимом коммите. Вы правите файл в коммите 1, коммит 2 меняет то же место — Git снова спрашивает. Ветка из двадцати коммитов, конфликтующая с main в одном файле, даёт до двадцати разрешений подряд. Лекарств два: rerere (следующий раздел) и здравый смысл — перед долгим rebase часто дешевле сделать merge, а историю причесать одним rebase -i уже после, как описано в «Переписывании истории». Выходы из середины: --continue (разрешил), --skip (этот коммит целиком не нужен — он исчезнет), --abort (вернуть всё), --quit (бросить операцию, оставив HEAD там, где он оказался).

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

rerere — сокращение от reuse recorded resolution. Если вы уже разрешали ровно этот конфликт, Git подставит ваше решение автоматически. Звучит как магия, устроено предельно просто.

Как работает rerere: нормализация, хеш конфликта и кэш решений

Ключевой трюк — нормализация. Git берёт конфликтный участок, срезает маркеры и приводит стороны к детерминированному порядку. Полученный текст хешируется, и хеш становится идентификатором конфликта. Из-за сортировки сторон слияние A в B и B в A дают один и тот же идентификатор — поэтому решение, найденное при merge, переиспользуется при rebase, и наоборот.

$ git config --global rerere.enabled true
$ git config --global rerere.autoUpdate true   # сразу делать git add за вас

# Первый rebase: конфликт, разрешаем руками
$ git rebase main
CONFLICT (content): Merge conflict in src/client.py
Recorded preimage for 'src/client.py'        # запомнил, КАК выглядел конфликт
$ vim src/client.py && git add src/client.py
Recorded resolution for 'src/client.py'      # ...и КАК вы его решили

# Через день, повторный rebase на уехавший main — тот же конфликт
$ git rebase main
Resolved 'src/client.py' using previous resolution.

$ git rerere status / remaining / diff   # где записан preimage, что не разрешено, что изменилось
$ git rerere forget src/client.py        # выбросить неверное решение и решить заново

Кэш живёт в .git/rr-cache/<хеш>/ и содержит preimage (нормализованный конфликт), postimage (ваше решение) и thisimage (текущая попытка). Он локальный, не пушится и подчищается сборщиком мусора: gc.rerereResolved (60 дней) для использованных решений, gc.rerereUnresolved (15 дней) для записанных, но не применённых. Кэш можно «натренировать» на уже существующих merge-коммитах — в поставке Git есть contrib/rerere-train.sh, который воспроизводит указанные слияния и записывает решения: ./contrib/rerere-train.sh --overwrite origin/main~200..origin/main. Полезно перед долгим бэкпортом релизной ветки — вы заранее заряжаете кэш решениями, которые команда уже принимала.

Честные ограничения. rerere сопоставляет preimage побайтово: изменился отступ, переименовалась переменная в контексте, добавилась соседняя строка — совпадения не будет, и это правильно. Хуже другое: если конфликт совпал текстуально, но семантика изменилась, rerere бодро подставит устаревшее решение и промолчит. Поэтому rerere.autoUpdate true удобен ровно до момента, когда он молча закоммитит вам старый вариант. Правило: держите rerere включённым всегда, но никогда не завершайте слияние без прогона тестов.

Инструменты, снижающие частоту конфликтов

Стратегии и опции. Стратегия (-s) — это алгоритм, опция (-X) — его настройка; путать их дорого.

Что Смысл
-s ort умолчание с Git 2.34: быстрее и корректнее старой recursive, особенно на переименованиях
-s resolve простое трёхстороннее слияние без рекурсии по нескольким базам
-s octopus больше двух веток сразу; не умеет разрешать конфликты и просто откажется
-s ours / -s subtree результат = ровно наше дерево (вклад второй стороны отброшен) / слияние поддерева, см. «Монорепо»
-X ours / -X theirs у конфликтных ханков взять указанную сторону; неконфликтные правки обеих сторон сохраняются
-X ignore-space-change, -X ignore-all-space не считать конфликтом различия в пробелах
-X renormalize привести переводы строк к каноническому виду перед сравнением
-X patience, -X diff-algorithm=histogram другие алгоритмы diff — часто дают более осмысленные ханки
-X find-renames=30%, -X no-renames порог обнаружения переименований

Разница между -s ours и -X ours — источник настоящих аварий. Первое выбрасывает всю работу другой ветки, оставляя в графе коммит, выглядящий как честное слияние. Второе трогает только спорные ханки. Отличаются одной буквой, последствия — на порядки. -s ours осмысленно ровно в одном сценарии: пометить ветку «учтённой», чтобы Git не предлагал её сливать (например, устаревшую релизную после ручного бэкпорта).

.gitattributes и merge-драйверы. Часть конфликтов не имеет смысла разрешать руками, потому что файл машинно-генерируемый. Встроенные драйверы: text (обычное трёхстороннее слияние), binary (слияние запрещено, конфликт по файлу), union (взять строки обеих сторон подряд, без маркеров).

CHANGELOG.md            merge=union     # порядок строк не важен, потеря строк критична
*.png                   -merge -diff    # не пытаться сливать и не показывать в diff
package-lock.json       merge=npm-lock  # слить нельзя, надо перегенерировать
* text=auto                             # нормализация переводов строк
*.sh text eol=lf                        # для конкретных типов можно задать явно

Осторожно с union: он честно склеивает обе стороны, но не понимает синтаксис. В CHANGELOG.md это даёт разумный результат, требующий лёгкой правки; в JSON или YAML — гарантированно сломанный файл. Никогда не вешайте union на структурированные форматы.

Кастомный драйвер объявляется в конфиге (в .gitattributes его положить нельзя — иначе клонированный репозиторий исполнял бы чужие команды). Драйвер получает три временных файла и обязан записать результат в %A; код возврата 0 означает «слито», ненулевой — «конфликт, оставь маркеры».

[merge "npm-lock"]
	name = перегенерировать package-lock.json вместо слияния
	driver = git-merge-npm-lock %O %A %B    # база, наша версия и вывод, их версия
	recursive = binary
#!/usr/bin/env bash
set -euo pipefail
ours="$2"                                        # %A: наша версия и файл для результата
git ls-files -u -- package.json | grep -q . && exit 1   # package.json ещё не слит — сдаёмся
cp "$ours" package-lock.json
npm install --package-lock-only --ignore-scripts >/dev/null
cp package-lock.json "$ours"

Тот же приём применяют к .po-файлам локализации и сгенерированным клиентам API. Главное правило рядом: если файл генерируется, обсудите, нужен ли он в репозитории вообще — часть команд хранит только манифест, а lock собирает в CI (со всеми известными минусами для воспроизводимости, см. «Основы CI»).

Переводы строк. Классика смешанных команд Windows/Linux: файл выглядит идентичным, а Git считает изменёнными все строки, потому что CRLF превратился в LF. Такое не разрешается вручную — только нормализацией: правила в .gitattributes выше, затем git add --renormalize . одним коммитом, а старые ветки сливаются через git merge -X renormalize. SHA шумного коммита добавьте в .git-blame-ignore-revs и включите git config --global blame.ignoreRevsFile .git-blame-ignore-revs, чтобы git blame его игнорировал.

Семантические конфликты: то, чего Git не увидит никогда

Самый опасный класс конфликтов не даёт ни одного маркера.

# Ветка A: переименовали функцию и обновили все вызовы, какие были на тот момент
def send_request(url, timeout):   # было: def request(url, timeout)
    ...

# Ветка B, параллельно, в ДРУГОМ файле: добавили новый вызов старого имени
def refresh_token():
    return request(TOKEN_URL, timeout=5)   # такой функции после слияния не будет

Ханки не пересекаются, merge-base ни при чём, Git сливает молча и радостно. Результат не импортируется. Мартин Фаулер называет это semantic conflict — конфликт смысла при отсутствии текстового конфликта. Git не может его поймать по построению: он работает с байтами и не знает, что такое «вызов функции». Ловят такое два механизма.

Первый — тесты и сборка на результате слияния, а не на ветке. Именно поэтому платформы создают служебные ссылки вроде refs/pull/<N>/merge: это заранее посчитанное слияние PR с целевой веткой. Если ваш CI собирает refs/pull/<N>/head (саму ветку), вы не проверяете то, что попадёт в main. Второй — merge queue, очередь, прогоняющая тесты на реальной последовательности слияний: GitHub merge queue, submit-очередь Gerrit, спекулятивные слияния Zuul, исторический bors. Дороже по машинному времени, зато main перестаёт «внезапно краснеть». Третий, более слабый, но бесплатный, — компилятор и статические анализаторы в pre-merge проверках: они ловят несуществующие имена раньше тестов; как это выстраивается, разбирают «Тесты в CI».

Профилактика: конфликты рождаются задолго до merge

Всё предыдущее — навыки разрешения. Но количество конфликтов задаётся не ими, а тремя вещами: временем расхождения, пересечением по файлам и структурой кода.

Вероятность конфликта растёт нелинейно по времени жизни ветки: чем дольше она живёт, тем больше коммитов в main и тем шире её собственный диапазон правок. Ветка на день конфликтует редко; ветка на три недели — почти гарантированно, и разбирать её приходится, когда автор уже не помнит, зачем правил половину файлов. Это и есть continuous integration в исходном значении — «интегрировать непрерывно», а не «иметь сервер сборки». Практический минимум: ветка живёт дни, а не недели; PR — сотни строк, а не тысячи; git fetch && git rebase origin/main делается ежедневно, а не в день сдачи. Выбор топологии — тема «Стратегий ветвления», а почему маленькие PR ревьюятся быстрее — «Pull Request» и «Код-ревью и стандарты».

Измерить, а не спорить

Ощущение «у нас много конфликтов» стоит превратить в число. Риск между двумя ветками грубо предсказывается пересечением множеств изменённых файлов относительно общей базы — дешёвый, но на удивление точный индикатор.

для каждой пары активных веток (A, B):
    base <- merge_base(A, B)
    FA <- файлы, изменённые на пути base..A
    FB <- файлы, изменённые на пути base..B
    если FA ∩ FB не пусто: риск = |FA ∩ FB| и возраст расхождения
"""Предсказание конфликтов между активными ветками по пересечению изменённых файлов.
Время O(B^2 * (M + F)), память O(B * F): B — число веток, M — стоимость merge-base,
F — среднее число изменённых файлов на ветку."""
import subprocess
from itertools import combinations

def git(*args: str) -> str:
    return subprocess.run(["git", *args], capture_output=True,
                          text=True, check=True).stdout.strip()

def active_branches(days: int = 21) -> list[str]:
    """Ветки origin, в которые коммитили за последние `days` дней."""
    raw = git("for-each-ref", "--format=%(refname:short) %(committerdate:unix)",
              "refs/remotes/origin")
    now = int(git("log", "-1", "--format=%ct", "HEAD"))
    pairs = (line.rsplit(" ", 1) for line in raw.splitlines())
    return [n for n, ts in pairs
            if not n.endswith(("/HEAD", "/main")) and now - int(ts) <= days * 86400]

def changed(base: str, tip: str) -> set[str]:
    return set(git("diff", "--name-only", f"{base}..{tip}").splitlines())

for a, b in combinations(active_branches(), 2):
    base = git("merge-base", a, b)
    overlap = changed(base, a) & changed(base, b)
    if overlap:   # чем больше пересечение и старше база, тем выше риск
        age = (git("rev-list", "--count", f"{base}..{a}"),
               git("rev-list", "--count", f"{base}..{b}"))
        print(f"{a} (+{age[0]}) x {b} (+{age[1]}): {len(overlap)} общих файлов")
        print("    " + ", ".join(sorted(overlap)[:5]))

Квадратичность по числу веток не страшна для десятков и становится проблемой на сотнях — там сравнивают каждую ветку не со всеми, а только с main, и держат инвертированный индекс «файл → ветки», что даёт O(B·F). Второй отчёт, который стоит построить один раз, — файлы-магниты конфликтов: пути с наибольшим числом разных авторов. Обычно это routes.py, DI-контейнер, index.ts с реэкспортами, гигантский settings и каталог миграций. Список почти всегда короткий, и почти всегда за ним стоят одни и те же архитектурные проблемы.

Архитектурные приёмы

  • Реестры и списки. Файл, куда каждая фича дописывает строку (регистрация роутов, DI-контейнер, список плагинов, __all__), конфликтует по определению. Лечение — автообнаружение: сканирование каталога, декораторы-регистраторы, entry points. Одна фича — один новый файл, нулевое пересечение.
  • Миграции БД. Последовательные номера 0042_add_index.sql дают конфликт при каждом параллельном PR; временные метки или UUID в имени — не дают. Порядок применения всё равно требует внимания, но это уже вопрос к БД, а не к Git.
  • Форматирование. Половина «конфликтов» в командах без форматтера — спор о пробелах. Один автоформаттер в хуке и проверка в CI убирают целый класс проблем; разовое переформатирование — отдельным коммитом в .git-blame-ignore-revs.
  • Размер файлов и владение. Файл на 3000 строк, который трогают все, будет конфликтовать. Разделение по ответственности снижает пересечение авторов — это зацепление и связность, измеренные через историю Git. CODEOWNERS конфликтов не уменьшает, но резко ускоряет разбор: понятно, кого звать.
  • Сгенерированные артефакты. dist/, скомпилированные протобуфы, снапшоты: если они в репозитории — они конфликтуют. Либо генерировать в CI, либо назначать merge-драйвер.

git merge-tree: проверить слияние, не сливая

С Git 2.38 слияние можно выполнить полностью в памяти, без рабочего дерева и без изменения репозитория. Это то, что нужно ботам, ночным проверкам и предупреждениям в PR; работает даже на bare-репозитории на сервере.

$ git merge-tree --write-tree main feature/timeouts
7f2c9a1b4e8d0c3f6a9b2e5d8c1f4a7b0e3d6c99      # чистое слияние: OID дерева, код возврата 0

$ git merge-tree --write-tree --name-only main feature/other
3a5c8e1f...
src/client.py
src/config.py
$ echo $?
1                                              # код 1 = конфликты, выше — список путей

Дальше дело техники: гонять это по всем открытым PR раз в час и оставлять комментарий «этот PR теперь конфликтует с main из-за src/client.py, конфликт появился после вливания PR-812». Разработчик узнаёт о проблеме в момент её появления, а не через две недели, когда никто не помнит контекста.

Данные подтверждают, что это правильная точка приложения усилий. Ghiotto и соавторы разобрали историю 2731 Java-проекта с GitHub («On the Nature of Merge Conflicts», IEEE TSE, 2020) и показали: конфликтует меньшинство слияний, а конфликтные участки в подавляющем большинстве крошечные — единицы строк, и чаще всего «решение» состоит в выборе одной из сторон целиком. Боль создаётся не размером конфликта, а неожиданностью и переключением контекста. Brun и соавторы («Proactive Detection of Collaboration Conflicts», ESEC/FSE 2011, инструмент Crystal) показали, что значительную часть конфликтов можно обнаружить заранее фоновым спекулятивным слиянием — ровно то, что сегодня делают merge-tree и merge queue. Вывод: не гонитесь за нулём конфликтов, гонитесь за тем, чтобы конфликт обнаруживался за часы, был маленьким и разрешался автором, пока он ещё помнит, что писал.

Типичные ошибки

  • git checkout --ours <файл> там, где слились и другие изменения. Молча выбрасывает всю работу другой стороны по этому файлу; нужен -X ours при слиянии или ручное разрешение.
  • Путать -s ours и -X ours. Первое стирает вклад целой ветки, оставляя в истории вид честного слияния.
  • Разрешать, не читая, зачем сделаны обе правки. git log --merge -p занимает тридцать секунд и регулярно вскрывает противоречие в требованиях.
  • Оставить маркер в файле. git diff --cached --check в pre-commit-хуке закрывает вопрос навсегда.
  • Закоммитить слияние, не собрав проект. Сломанный merge-коммит отравляет git bisect и блокирует команду; при этом --abort отменяет только операцию слияния, а не коммиты — они неизменяемы и никуда не денутся.
  • Считать, что чистое слияние = рабочий код. Семантический конфликт не даёт ни одного маркера.
  • Разрешать один и тот же конфликт двадцать раз при rebase. rerere — одна строка в конфиге.
  • Забывать, что при rebase стороны перевёрнуты. theirs — это ваша работа; проверяйте через git show :2: и git show :3:.
  • Лечить симптомы вместо причины. Если один и тот же файл конфликтует каждую неделю, проблема не в Git.

Мини-итог

Конфликт — точка, где Git отказывается принимать решение за человека, и это его лучшее свойство. Механика целиком выводится из модели данных: три версии файла (база, наше, их), три стадии в индексе, невозможность построить дерево, пока стадии не схлопнуты, и обычные файлы в .git, помнящие, в какой вы операции.

Разрешение сводится к дисциплине: понять состояние (git status), понять намерения обеих сторон (git log --merge -p), исправить руками, проверить себя (git diff AUTO_MERGE, git diff --cached --check), собрать и прогнать тесты — и только потом завершить. Из любого места есть безопасный выход --abort. Инструменты снимают рутину, но не думают за вас: zdiff3 показывает базу, rerere переиспользует принятые решения, merge-драйверы отдают машинам то, что машины и сгенерировали, merge-tree предупреждает о конфликте заранее. Количество же конфликтов определяется не Git, а тем, как долго живут ветки, насколько пересекаются файлы и есть ли в коде «реестры», куда дописывают строку все подряд. Самый опасный конфликт при этом Git вообще не виден — семантический, и против него работают только сборка и тесты на результате слияния.

Источники

Что дальше

Конфликты — управляемая ситуация: выход есть всегда, и ни один коммит при этом не страдает. Но бывают состояния, где кажется, что работа действительно пропала: неудачный reset --hard, rebase, после которого ветка «исчезла», случайный push --force, битые объекты после сбоя диска. Почти всё это обратимо — потому что Git по умолчанию ничего не удаляет сразу.

Дальше — Восстановление: reflog, потерянные коммиты, испорченный репозиторий: как читать reflog, находить недостижимые коммиты через fsck, доставать содержимое из packfile и что делать, когда репозиторий действительно повреждён.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов