Как пошагово разрешать сложные конфликты слияния в Git

Разрешайте сложные конфликты слияния Git, читая версии ours, theirs и base, обрабатывая переименования, перебазирования, бинарные файлы и тесты.

Как пошагово разрешать сложные конфликты слияния в Git

Сложный конфликт слияния Git редко бывает сложным из-за самих маркеров конфликта. Он сложен, потому что вам нужно сохранить намерения двух разных изменений одновременно. Одна ветка переименовала функцию, а другая изменила её поведение. Одна ветка переместила файл, а другая его отредактировала. Одна ветка изменила последовательность миграций базы данных. Git может показать вам пересечение, но не может решить, что должно означать программное обеспечение после этого.

Когда слияние останавливается с конфликтами, не начинайте сразу удалять маркеры. Сначала сориентируйтесь.

git status

Git перечислит неслитые пути. Он может сказать both modified, deleted by us, deleted by them, both added или что-то подобное. Эти фразы говорят вам о форме конфликта.

Если слияние кажется неправильным или вы не готовы его разрешить, прервите его, прежде чем вносить дополнительные изменения:

git merge --abort

Для конфликта при перебазировании эквивалентная команда:

git rebase --abort

Это не провал. Это чистая перезагрузка в состояние до операции, что часто является самым умным ходом, когда вы понимаете, что вам нужно больше контекста.

Читайте конфликт как три версии

Обычный маркер конфликта выглядит так:

<<<<<<< HEAD
текущая версия ветки
=======
входящая версия ветки
>>>>>>> feature-branch

Во время слияния HEAD — это ветка, которая была у вас активна, когда вы запустили git merge. Нижняя сторона — это ветка, которая сливается.

Для сложных конфликтов используйте три этапа, которые Git хранит в индексе:

git show :1:путь/к/файлу   # общий предок
git show :2:путь/к/файлу   # наша версия
git show :3:путь/к/файлу   # их версия

Общий предок — это версия, с которой начали обе ветки. Он полезен, потому что показывает, что именно изменила каждая ветка. Без него вы можете сравнивать две финальные версии и упустить причину изменений.

Вы также можете использовать:

git diff
git diff --ours -- путь/к/файлу
git diff --theirs -- путь/к/файлу
git diff --base -- путь/к/файлу

Это то место, где многие действуют слишком быстро. Цель — не выбрать "нашу" или "их" версию как голос лояльности команде. Цель — создать правильный итоговый файл.

Безопасный ручной рабочий процесс

Используйте эту процедуру для каждого конфликтующего файла:

  1. Откройте файл и найдите все маркеры конфликта.
  2. Прочитайте окружающий код, а не только отмеченные строки.
  3. Проверьте коммиты, которые затрагивали файл в обеих ветках.
  4. Отредактируйте файл до окончательной предполагаемой версии.
  5. Удалите все маркеры конфликта.
  6. Запустите наименьший релевантный тест или проверку сборки.
  7. Проиндексируйте файл.

Полезные команды при этом:

git log --oneline --left-right --merge -- путь/к/файлу
git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'

git diff --check ловит оставшиеся проблемы с пробелами. git grep ловит забытые маркеры конфликта до того, как они попадут в CI.

После разрешения одного файла:

git add путь/к/файлу

Когда все конфликты проиндексированы:

git status
git commit

Во время перебазирования используйте:

git rebase --continue

Когда обе ветки изменили одну и ту же функцию

Это распространенный случай. Предположим, одна ветка добавляет валидацию, а другая переименовывает параметр:

<<<<<<< HEAD
function createUser(email) {
  return db.users.insert({ email });
}
=======
function createUser(rawEmail) {
  const email = rawEmail.trim().toLowerCase();
  return db.users.insert({ email });
}
>>>>>>> normalize-email

Правильный ответ может объединить оба:

function createUser(rawEmail) {
  const email = rawEmail.trim().toLowerCase();
  return db.users.insert({ email });
}

Но только если вызывающие функции были обновлены для передачи rawEmail и если нормализация все еще желательна. Найдите функцию:

git grep -n 'createUser'

Сложные конфликты часто требуют проверки соседних файлов. Конфликт сигнатуры функции в одном файле может потребовать обновлений в тестах, маршрутах, типах, моках или документации.

Конфликты переименования и редактирования

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

git status --short

Затем проверьте информацию об именах-статусах:

git diff --name-status --diff-filter=R

Если одна ветка переименовала src/user.js в src/account.js, а другая отредактировала src/user.js, вам обычно нужно применить отредактированное содержимое к новому пути. Визуальный инструмент слияния может помочь, но концепция проста: сохранить переименование и сохранить значимые правки.

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

git rm старый/путь.js
git add новый/путь.js

Не индексируйте оба файла, если только финальный проект действительно не должен содержать оба.

Удалено нами или удалено ими

Конфликт удаления/изменения означает, что одна ветка удалила файл, а другая его изменила. Git не может знать, сделало ли удаление изменение неактуальным.

Если файл должен остаться удаленным:

git rm путь/к/файлу

Если файл должен остаться, выберите нужную версию и проиндексируйте ее:

git checkout --theirs путь/к/файлу
git add путь/к/файлу

или:

git checkout --ours путь/к/файлу
git add путь/к/файлу

Будьте осторожны с --ours и --theirs во время перебазирования. При перебазировании метки могут казаться перевернутыми, потому что Git воспроизводит ваши коммиты поверх другой базы. Если сомневаетесь, проверьте этапы:

git show :2:путь/к/файлу
git show :3:путь/к/файлу

Конфликты бинарных файлов

Git не может объединять большинство бинарных файлов. Если две ветки изменили одно и то же изображение, архив, документ или скомпилированный ресурс, вам придется выбрать одну версию или создать новый файл вручную.

Чтобы взять нашу версию:

git checkout --ours путь/к/файлу.bin
git add путь/к/файлу.bin

Чтобы взять их версию:

git checkout --theirs путь/к/файлу.bin
git add путь/к/файлу.bin

Если бинарный файл генерируется, лучшим ответом может быть его повторная генерация из исходников после разрешения текстовых файлов. Если бинарный файл — это дизайн-ресурс или документ, поговорите с человеком, который изменил другую сторону. Угадывание может уничтожить работу.

Используйте инструмент слияния, когда файл слишком сложен для чтения

Хороший инструмент слияния показывает четыре вещи: базовую версию, вашу версию, их версию и результат. Настройте тот, который вам действительно нравится. Visual Studio Code распространен:

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'

Затем запустите:

git mergetool

Другие команды предпочитают Meld, KDiff3, Beyond Compare или инструменты, интегрированные в IDE. Инструмент менее важен, чем понимание трех версий. Не нажимайте "принять входящие" в сложном конфликте, просто чтобы заставить красные маркеры исчезнуть.

После использования mergetool проверьте наличие резервных файлов, таких как .orig:

git status --short

Вы можете отключить резервные файлы mergetool глобально, если они вам не нужны:

git config --global mergetool.keepBackup false

Стратегические опции — не магия

Вы можете увидеть совет вроде:

git merge -X theirs feature

Это не означает "заменить мою ветку на feature". Это означает, что когда стратегия слияния Git видит конфликтующие фрагменты, она должна предпочесть другую сторону для этих фрагментов. Неконфликтующие изменения из обеих веток все равно сливаются. Это может быть полезно для сгенерированных lock-файлов или механических конфликтов форматирования, но рискованно для бизнес-логики.

-X ours и -X theirs — это опции стратегии. Стратегия слияния ours отличается:

git merge -s ours old-branch

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

Конфликты при перебазировании

Во время перебазирования Git воспроизводит коммиты один за другим. Это означает, что вы можете разрешить несколько меньших конфликтов вместо одного большого конфликта слияния.

Цикл такой:

git status
# редактируем файлы
git add разрешенный-файл
git rebase --continue

Если воспроизводимый коммит больше не нужен, потому что новая база уже содержит изменение, используйте:

git rebase --skip

Используйте skip осторожно. Он удаляет этот коммит из перебазируемой ветки. Сначала прочитайте коммит:

git show

Опять же, --ours и --theirs могут сбивать с толку при перебазировании. Проверяйте :2: и :3:, если сомневаетесь.

Тестируйте разрешение, а не только слияние

Слияние может быть синтаксически разрешено, но все равно быть неправильным. После индексации файлов запустите тесты, которые затрагивают измененную область. Для конфликта во фронтенде это может быть проверка типов и целенаправленный тест компонента. Для конфликта в бэкенде это может быть один тест сервиса или проверка миграции. Для конфликта в lock-файле переустановите зависимости и запустите команду проверки менеджера пакетов.

Как минимум:

git diff --check
git grep -n '<<<<<<<\|=======\|>>>>>>>'

Затем запустите специфичную для проекта проверку, которая могла бы выявить плохую комбинацию.

Сокращайте будущие конфликты

Лучший конфликт — тот, который вы никогда не создаете. Держите ветки короткоживущими, регулярно перебазируйте или сливайтесь с основной веткой и избегайте смешивания механических изменений с изменениями функциональности. PR только с форматированием не должен также менять логику. Перемещение файла не должно также переписывать файл, если этого можно избежать.

Для файлов, которые всегда болезненны, рассмотрите изменения в владении или структуре. Большие конфигурационные файлы, сгенерированные снимки, lock-файлы, списки миграций и центральные реестры маршрутов часто создают повторяющиеся конфликты, потому что все редактируют одну и ту же область. Иногда решение — в процессе. Иногда решение — разделить файл или генерировать его из меньших источников.

Используйте .gitattributes для файлов, требующих особого поведения при слиянии. Например, некоторые сгенерированные lock-файлы могут иметь драйверы слияния, специфичные для менеджера пакетов. Не изобретайте их на ходу, но проверьте, есть ли в вашей экосистеме рекомендуемый драйвер.

Конфликты слияния — это часть технической работы и часть коммуникации. Если вы не понимаете намерений другой ветки, спросите. Десять минут с автором дешевле, чем молчаливое слияние кода, который проходит тесты, но удаляет функцию, которую они создавали.

Lock-файлы, миграции и другие файлы с высоким трением

Некоторые файлы конфликтуют чаще, потому что многие ветки редактируют одну и ту же небольшую область. Lock-файлы зависимостей — распространенный пример. Если две ветки добавляют пакеты, конфликт lock-файла может быть технически большим, но концептуально простым: перегенерируйте его с помощью менеджера пакетов после разрешения файла манифеста.

Для Node-проекта это может означать разрешение package.json, а затем запуск менеджера пакетов, которому принадлежит lock-файл:

npm install
# или pnpm install
# или yarn install

Затем проиндексируйте и манифест, и lock-файл. Не редактируйте сложный lock-файл вручную, если вы не понимаете его формат. Менеджер пакетов с меньшей вероятностью сделает тонкую ошибку в графе зависимостей.

Миграции баз данных требуют большей осторожности. Если две ветки создают миграции с предположениями о порядке, принятия обоих файлов может быть недостаточно. Проверьте временные метки миграций, номера последовательностей, зависимости и то, изменяют ли обе миграции одну и ту же таблицу или данные. Иногда правильным решением является новая последующая миграция, которая согласует две ветки.

Сгенерированные снимки и golden-файлы имеют тот же шаблон: сначала разрешите изменение исходника, перегенерируйте вывод, затем проверьте сгенерированный diff. Если сгенерированный diff огромен, спросите, должен ли он быть в том же коммите слияния. Огромные сгенерированные изменения могут скрыть плохое ручное разрешение.

Когда конфликт затрагивает несколько файлов, запишите предполагаемое финальное поведение перед редактированием. Короткая заметка вроде "сохранить новую валидацию из функции A, сохранить переименованный сервис из функции B, перегенерировать типы клиента" предотвращает локальное разрешение каждого файла с потерей общего дизайна.

Для особенно рискованных слияний создайте временную ветку перед началом:

git switch -c merge-test/main-with-feature
git merge feature

Если разрешение становится запутанным, вы можете отказаться от временной ветки, не нарушая исходную. Эта маленькая привычка делает сложные конфликты менее стрессовыми, потому что у вас всегда есть чистый путь назад.

Просматривайте финальное слияние как отдельное изменение

Разрешение конфликта — это новая работа. Относитесь к ней так же при ревью. Финальный diff должен показывать не только изменения обеих веток, но и любой связующий код, который вы написали, чтобы заставить их работать вместе. Если коммит слияния большой, объясните разрешение в сообщении коммита или комментарии к PR. Ревьюеры не должны обратно инженерно восстанавливать, почему была выбрана одна сторона.

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

git diff HEAD^1..HEAD -- путь/к/файлу
git diff HEAD^2..HEAD -- путь/к/файлу

Для незакоммиченного слияния проверьте проиндексированные изменения:

git diff --cached

Ищите случайное удаление тестов, импорты, которые больше не используются, дублированные записи конфигурации и пути кода, где обе ветки добавили похожую логику под разными именами. Это ошибки, которые Git не может определить за вас.

Если конфликт затрагивал поведение, добавьте или обновите тест, который бы провалился, если бы вы выбрали неправильную сторону. Этот тест делает больше, чем доказывает сегодняшнее слияние. Он защищает решение от отмены в следующем рефакторинге.