Разруливание конфликтов

Разруливание конфликтов

Конфликт в Git - не ошибка и не баг. Это нормальная ситуация, которая возникает, когда два человека (или ты в двух ветках) изменили одни и те же строки одного файла. Git честно говорит: «я не знаю, какой вариант правильный - реши сам». Пугаться конфликтов не нужно: они решаются за минуты, если понимаешь механику.

Конфликты - неизбежная часть командной работы. Чем больше людей в проекте, тем чаще они случаются. Задача - не избежать конфликтов полностью (это невозможно), а научиться разрешать их быстро и уверенно.

Почему возникают конфликты

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

  1. Две ветки изменили одну и ту же строку - Git не знает, какой вариант оставить
  2. Один удалил файл, другой его изменил - Git не знает, удалять или оставить с изменениями
  3. Оба создали файл с одинаковым именем - 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 визуализация

Чтобы понять конфликт, полезно видеть три версии файла:

3-way merge: три версии файла - Base общий предок, Ours HEAD и Theirs входящая ветка - сходятся в узел CONFLICT с вопросом какой вариант оставить

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
На старте хватает VS Code с его встроенными кнопками. Когда конфликты станут сложнее (большие файлы, несколько секций), попробуй трёхпанельный mergetool в IntelliJ.

Конфликты при 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
Если при 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
При rebase значения ours/theirs меняются местами! `--ours` - это ветка, НА которую ребейзим (main), а `--theirs` - это твои коммиты из feature-ветки. Это контринтуитивно и часто путает.

Для целых файлов при 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.

Предотвращение конфликтов

Полностью избежать конфликтов невозможно, но можно минимизировать:

  1. Частые pull/rebase - чем чаще обновляешь ветку от main, тем меньше расхождение
  2. Маленькие merge requests - 50 строк проще смерджить, чем 500
  3. Коммуникация - если два человека работают над одним файлом, договоритесь заранее
  4. Разделение ответственности - чем меньше пересечение файлов между задачами, тем меньше конфликтов
  5. Архитектура - маленькие файлы с чёткой ответственностью конфликтуют реже, чем один 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, увидь конфликт, отмени

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.