Разруливание конфликтов
Разруливание конфликтов
Конфликт в Git - не ошибка и не баг. Это нормальная ситуация, которая возникает, когда два человека (или ты в двух ветках) изменили одни и те же строки одного файла. Git честно говорит: «я не знаю, какой вариант правильный - реши сам». Пугаться конфликтов не нужно: они решаются за минуты, если понимаешь механику.
Конфликты - неизбежная часть командной работы. Чем больше людей в проекте, тем чаще они случаются. Задача - не избежать конфликтов полностью (это невозможно), а научиться разрешать их быстро и уверенно.
Почему возникают конфликты
Git автоматически сливает изменения, если они затрагивают разные файлы или разные участки одного файла. Конфликт возникает только когда:
- Две ветки изменили одну и ту же строку - Git не знает, какой вариант оставить
- Один удалил файл, другой его изменил - Git не знает, удалять или оставить с изменениями
- Оба создали файл с одинаковым именем - Git не знает, какую версию взять
Пошагово разберём самый частый случай - конфликт на уровне строк.
Создаём конфликт специально
# Подготовка
mkdir conflict-demo && cd conflict-demo
git init
echo "package main
func Hello() string {
return \"Hello, World\"
}" > main.go
git add main.go
git commit -m "init: add main.go"
# Ветка A: меняем приветствие на "Hi"
git switch -c feature/greeting-a
sed -i 's/Hello, World/Hi, World/' main.go
git commit -am "feat: change greeting to Hi"
# Ветка B: меняем приветствие на "Hey"
git switch main
git switch -c feature/greeting-b
sed -i 's/Hello, World/Hey, World/' main.go
git commit -am "feat: change greeting to Hey"
# Теперь сливаем A в main
git switch main
git merge feature/greeting-a # OK, fast-forward
# Пробуем слить B - конфликт!
git merge feature/greeting-b
# CONFLICT (content): Merge conflict in main.go
# Automatic merge failed; fix conflicts and then commit the result.
Как выглядит конфликт
После конфликта git status показывает файлы в состоянии «both modified»:
git status
# On branch main
# You have unmerged paths.
# (fix conflicts and run "git commit")
#
# Unmerged paths:
# both modified: main.go
Внутри файла Git вставляет маркеры конфликта:
package main
func Hello() string {
<<<<<<< HEAD
return "Hi, World"
=======
return "Hey, World"
>>>>>>> feature/greeting-b
}
То же самое в PHP-проекте выглядит так - Git вставляет маркеры в исходник, не глядя на язык:
<?php
declare(strict_types=1);
final class Greeter
{
public function hello(): string
{
<<<<<<< HEAD
return 'Hi, World';
=======
return 'Hey, World';
>>>>>>> feature/greeting-b
}
}
Три секции:
<<<<<<< HEAD...=======- версия текущей ветки (куда сливаем)=======...>>>>>>> feature/greeting-b- версия входящей ветки (что сливаем)
Пошаговое разрешение конфликта
Шаг 1: Понять контекст
Прежде чем трогать файл, разберись, что сделала каждая ветка:
# Посмотреть, что изменилось в нашей ветке
git diff HEAD main.go
# Посмотреть, что изменилось во входящей ветке
git log --oneline feature/greeting-b
# Посмотреть diff для merge в трёхстороннем формате
git diff
Шаг 2: Отредактировать файл
Открой файл и выбери правильный вариант. Есть три варианта:
Оставить нашу версию:
func Hello() string {
return "Hi, World"
}
public function hello(): string
{
return 'Hi, World';
}
Оставить их версию:
func Hello() string {
return "Hey, World"
}
public function hello(): string
{
return 'Hey, World';
}
Объединить оба варианта:
func Hello() string {
return "Hi and Hey, World"
}
public function hello(): string
{
return 'Hi and Hey, World';
}
Главное - удалить маркеры конфликта (<<<<<<<, =======, >>>>>>>). Если оставить маркеры, код не скомпилируется.
Шаг 3: Отметить конфликт как разрешённый
git add main.go
git commit # Git предложит стандартное merge-сообщение
3-way merge визуализация
Чтобы понять конфликт, полезно видеть три версии файла:
Base - это версия в общем предке (коммит, от которого разошлись ветки). Ours - версия в ветке, куда сливаем. Theirs - версия во входящей ветке.
Инструменты для разрешения конфликтов
VS Code
VS Code автоматически распознаёт маркеры конфликта и показывает кнопки:
- Accept Current Change - оставить нашу версию
- Accept Incoming Change - оставить их версию
- Accept Both Changes - оставить обе версии
- Compare Changes - показать diff в трёхпанельном виде
IntelliJ / GoLand
IntelliJ предлагает трёхпанельный merge-инструмент: слева Base, справа Theirs, по центру Result. Ты выбираешь, какие строки принять, и видишь результат в реальном времени.
git mergetool
Git может открыть внешний merge-инструмент:
# Настроить mergetool
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait --merge $REMOTE $LOCAL $BASE $MERGED'
# Запустить при конфликте
git mergetool
Конфликты при rebase
При rebase конфликты работают иначе: Git применяет коммиты по одному, и конфликт может возникнуть на каждом шаге.
git switch feature/auth
git rebase main
# CONFLICT in main.go
# Resolve all conflicts manually, mark them as resolved with
# "git add <pathspec>" then run "git rebase --continue"
Разница с merge:
- при merge конфликт один - между финальными состояниями веток
- при rebase конфликтов может быть несколько - по одному на каждый коммит
# Цикл разрешения при rebase:
# 1. Разрешить конфликт в файле
vim main.go # убрать маркеры
# 2. Добавить в staging
git add main.go
# 3. Продолжить rebase
git rebase --continue
# Если ещё есть коммиты с конфликтами - повторить
# Или: пропустить текущий коммит
git rebase --skip
# Или: отменить весь rebase
git rebase --abort
Флаги --ours и --theirs
При массовых конфликтах (например, после долгого расхождения веток) иногда нужно автоматически выбрать одну сторону:
# При merge: принять нашу версию для конкретного файла
git checkout --ours main.go
git add main.go
# При merge: принять их версию
git checkout --theirs main.go
git add main.go
Для целых файлов при merge:
# Принять нашу версию для всех конфликтных файлов
git merge -X ours feature/auth
# Принять их версию для всех конфликтных файлов
git merge -X theirs feature/auth
Отмена merge/rebase при конфликте
Если ты понял, что не хочешь разруливать конфликт прямо сейчас:
# Отменить merge (вернуть всё как было до merge)
git merge --abort
# Отменить rebase (вернуть всё как было до rebase)
git rebase --abort
# Если merge уже завершён (коммит сделан), откатить его:
git revert -m 1 HEAD # создаст обратный коммит
Подробнее про revert, reset и остальные команды-спасатели - в уроке про stash, cherry-pick и revert.
Предотвращение конфликтов
Полностью избежать конфликтов невозможно, но можно минимизировать:
- Частые pull/rebase - чем чаще обновляешь ветку от main, тем меньше расхождение
- Маленькие merge requests - 50 строк проще смерджить, чем 500
- Коммуникация - если два человека работают над одним файлом, договоритесь заранее
- Разделение ответственности - чем меньше пересечение файлов между задачами, тем меньше конфликтов
- Архитектура - маленькие файлы с чёткой ответственностью конфликтуют реже, чем один god-файл на 2000 строк. Это ещё один аргумент в пользу разбиения кода на модули по ответственности
# Полезная практика: перед merge request обновить ветку
git fetch origin
git rebase origin/main
# Разрешить конфликты сейчас, пока изменений мало,
# а не потом, когда main уедет ещё дальше
Реальный сценарий: конфликт в Go-проекте
Допустим, ты и коллега оба добавили новый endpoint в роутер:
// Твоя версия (feature/auth):
r.Post("/auth/login", h.Login)
r.Post("/auth/register", h.Register)
// Версия коллеги (feature/profile):
r.Get("/users/me", h.GetProfile)
r.Put("/users/me", h.UpdateProfile)
В PHP-проекте на Symfony то же самое - два разработчика добавили роуты в config/routes.php (или атрибуты в контроллере):
// Твоя версия (feature/auth):
$routes->add('auth_login', '/auth/login')->controller([AuthController::class, 'login'])->methods(['POST']);
$routes->add('auth_register', '/auth/register')->controller([AuthController::class, 'register'])->methods(['POST']);
// Версия коллеги (feature/profile):
$routes->add('profile_get', '/users/me')->controller([ProfileController::class, 'get'])->methods(['GET']);
$routes->add('profile_put', '/users/me')->controller([ProfileController::class, 'update'])->methods(['PUT']);
Обе ветки изменили файл router.go в одном месте. При merge - конфликт. Но решение простое - нужны оба набора строк:
r.Post("/auth/login", h.Login)
r.Post("/auth/register", h.Register)
r.Get("/users/me", h.GetProfile)
r.Put("/users/me", h.UpdateProfile)
$routes->add('auth_login', '/auth/login')->controller([AuthController::class, 'login'])->methods(['POST']);
$routes->add('auth_register', '/auth/register')->controller([AuthController::class, 'register'])->methods(['POST']);
$routes->add('profile_get', '/users/me')->controller([ProfileController::class, 'get'])->methods(['GET']);
$routes->add('profile_put', '/users/me')->controller([ProfileController::class, 'update'])->methods(['PUT']);
Удалить маркеры, оставить все четыре строки, git add, git commit. Конфликт разрешён за 30 секунд.
Мини-задание
- Создай конфликт специально: два изменения одной строки в двух ветках, попробуй merge
- Разреши конфликт вручную (в редакторе), завершив
git addиgit commit - Создай тот же конфликт, но теперь используй
git checkout --theirs <файл>для автоматического выбора - Попробуй конфликт при rebase: создай ветку с 3 коммитами, вызови конфликт, пройди цикл
resolve → git add → git rebase --continue - Попрактикуйся с
git merge --abort- начни merge, увидь конфликт, отмени