Анатомия конфликтных маркеров
Когда Git встречает конфликт, он не падает и не отменяет операцию. Вместо этого Git переписывает файл, вставляя в него обе версии с разделителями. Эти разделители называются conflict markers — те самые ≪≪≪≪≪≪≪ и ≫≫≫≫≫≫≫, которые ты увидишь в “конфликтных” файлах.
В этом уроке мы разбираем, что значит каждый маркер, как читать конфликт, и почему включение merge.conflictStyle = zdiff3 — лучшая микро-настройка, которую ты можешь сделать сегодня.
Дефолтный вид конфликта
Когда git merge упёрся в конфликт на строке, Git вставляет в файл блок такого вида (показываем внутри bash-блока, чтобы MDX не сошёл с ума):
def fetch():
<<<<<<< HEAD
return "v2-from-main"
=======
return "v3-from-feature"
>>>>>>> feature
Три маркера:
≪≪≪≪≪≪≪ HEAD— начало “наших” изменений (текущая ветка, OURS).=======— разделитель.≫≫≫≫≫≫≫ feature— конец “их” изменений (мерджимая ветка, THEIRS).
То есть структура такая:
<<<<<<< HEAD
[OURS — что у меня в текущей ветке]
=======
[THEIRS — что в мерджимой ветке]
>>>>>>> feature
Эти маркеры — просто текст в файле. Никакой магии: Git вставил их и сохранил файл. Питон, конечно, не сможет такой файл выполнить — там синтаксис сломан. Но для Git это просто строки. Когда ты их удаляешь и оставляешь нужный код, файл снова рабочий.
Никогда не коммить файл с не разрешёнными маркерами. Это означает “запушить заведомо нерабочий код”. Linter и pre-commit hooks обычно ловят это, но привычка — каждый раз перед git add визуально проверить, что маркеров не осталось.
Что значит “HEAD” и имя ветки в маркерах
После ≪≪≪≪≪≪≪ Git пишет имя коммита или ветки, чтобы ты не запутался, кто есть кто.
≪≪≪≪≪≪≪ HEAD— типично дляgit merge. HEAD = текущая ветка, в которую ты мерджишь.≪≪≪≪≪≪≪ feature— может встретиться, например, при rebase, где “OURS” и “THEIRS” меняются местами относительно интуиции.
Запомни правило: OURS — это куда мерджишь, THEIRS — что мерджишь. При merge ты обычно стоишь на main и мерджишь feature — значит OURS = main, THEIRS = feature.
Подвох с rebase
При rebase интуиция переворачивается. git rebase main на ветке feature означает: “возьми коммиты feature и переставь их поверх main”. Технически Git проигрывает каждый коммит feature поверх main, и для каждого создаёт мини-мердж. В этом мини-мердже:
- OURS =
main(новая база). - THEIRS = коммит из
feature.
То есть наоборот относительно git merge! Поэтому в конфликте при rebase ты увидишь:
<<<<<<< HEAD
[содержимое из main]
=======
[содержимое из твоего коммита feature]
>>>>>>> abc1234 (your commit message)
И сначала это путает. Помни: при rebase HEAD — это “временная база”, а THEIRS — твои перебазируемые коммиты.
merge.conflictStyle = zdiff3: показать BASE
Дефолтные маркеры показывают только OURS и THEIRS. Но Git знает и третью версию — BASE (общий предок). Если её показать, понимать конфликт становится в разы легче.
Включи zdiff3:
git config --global merge.conflictStyle zdiff3
Теперь тот же конфликт выглядит так:
def fetch():
<<<<<<< HEAD
return "v2-from-main"
||||||| merged common ancestors
return "v1"
=======
return "v3-from-feature"
>>>>>>> feature
Дополнительные маркеры:
||||||| merged common ancestors— открывает блок BASE.=======— закрывает BASE, открывает THEIRS.
Теперь ты видишь три версии:
- BASE =
v1(что было). - OURS =
v2-from-main(как поменяла наша ветка). - THEIRS =
v3-from-feature(как поменяла их ветка).
Чем zdiff3 лучше старого diff3
diff3 тоже показывает BASE, но есть нюанс: иногда BASE содержит куски, которые обе стороны изменили на одно и то же. diff3 всё равно их показывает в BASE-блоке — раздувает конфликт.
zdiff3 (“zealous diff3”) эту дубликацию убирает: если OURS и THEIRS одинаково изменили строку, она не попадает в конфликт. Это сильно чище.
zdiff3 доступен с Git 2.35 (январь 2022). К 2026 году это стандарт. Установи и забудь.
Сценарии: чтение реального конфликта
Сценарий 1: обе стороны правят одну строку
DB_HOST = "prod.db.example.com"
DB_TIMEOUT = 30
<<<<<<< HEAD
DB_POOL_SIZE = 50
||||||| merged common ancestors
DB_POOL_SIZE = 10
=======
DB_POOL_SIZE = 25
>>>>>>> feature/perf-tuning
Читай: было DB_POOL_SIZE = 10. Наша ветка повысила до 50 (агрессивно). Feature-ветка — до 25 (умеренно).
Решение зависит от ситуации:
- Если 50 было результатом перфоманс-теста, оставляй 50.
- Если 25 — компромисс с какими-то ограничениями, бери 25.
- Может быть и третий вариант — 35, среднее.
Удаляешь все маркеры, оставляешь финальную строку:
DB_POOL_SIZE = 35 # компромисс между OURS и THEIRS
Сценарий 2: одна сторона добавила импорт, другая — модифицировала функцию
Иногда конфликт shows непересекающиеся изменения, которые Git сгруппировал в один блок. Это означает, что строки рядом, но обе стороны их трогали.
<<<<<<< HEAD
from datetime import datetime
import logging
def process(data):
logger = logging.getLogger(__name__)
logger.info("start")
return [x.upper() for x in data]
||||||| merged common ancestors
from datetime import datetime
def process(data):
return [x for x in data]
=======
from datetime import datetime, timezone
def process(data):
return [x.upper() for x in data if x]
>>>>>>> feature
Читай:
- BASE: импорт datetime, простая функция.
- OURS (HEAD): добавил импорт logging, добавил logging в функцию.
- THEIRS (feature): добавил импорт timezone, добавил фильтрацию
if xи.upper().
Оба изменения нужны. Правильный мердж:
from datetime import datetime, timezone
import logging
def process(data):
logger = logging.getLogger(__name__)
logger.info("start")
return [x.upper() for x in data if x]
Это типичный случай “не выбирай OURS/THEIRS — слей оба умом”.
Сценарий 3: structurally конфликтные изменения
Бывает, что оба изменения логически конфликтуют, не только текстуально.
def calculate_tax(amount):
<<<<<<< HEAD
return amount * 0.20 # VAT повышен до 20%
||||||| merged common ancestors
return amount * 0.18
=======
return amount * 0.18 + 100 # фиксированный сбор
>>>>>>> feature/fixed-fee
OURS повысил процент. THEIRS добавил фиксированный сбор. Это не “выбери один” — нужно обсуждение с автором обеих веток: “коллега, ты добавил fee, я повысил процент, как это должно работать вместе?”.
Не разрешай такие конфликты в одиночку — спроси команду.
В реальной работе, если ты не понимаешь, что значит чужое изменение в конфликте — открой git log -p <file> на их ветке, посмотри коммит-сообщение, узнай контекст. Иногда полезно дописать в Slack: “Эй, у меня конфликт на твоей строке, можем созвониться?”.
Как Git отмечает конфликтные файлы
git status показывает конфликты явно:
$ 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/etl.py
both modified: src/config.py
deleted by them: src/old.py
no changes added to commit (use "git add" and/or "git commit -a")
Категории:
- both modified — обе стороны изменили (классический конфликт). Внутри файла есть маркеры.
- deleted by them — ты изменил, они удалили. Решение:
git rm(согласен удалить) илиgit add(восстанавливаешь). - deleted by us — ты удалил, они изменили. Аналогично.
- both added — обе ветки создали файл с этим именем (с разным содержимым).
- added by them / us — один из видов above.
Команда для списка только конфликтных файлов:
$ git diff --name-only --diff-filter=U
src/etl.py
src/config.py
Полезно для скриптов и в IDE.
Index в состоянии конфликта
Под капотом Git хранит конфликтные файлы в index в трёх “ступенях” (stages):
- Stage 1 = BASE
- Stage 2 = OURS
- Stage 3 = THEIRS
Команда git ls-files -u это показывает:
$ git ls-files -u
100644 a1b2c3d... 1 src/etl.py # BASE
100644 f4e5d6c... 2 src/etl.py # OURS (HEAD)
100644 9876543... 3 src/etl.py # THEIRS (feature)
После того, как ты сделаешь git add src/etl.py, все три stage схлопываются в один (stage 0), и Git понимает: конфликт разрешён.
Это знание не нужно в быту, но полезно знать: можно явно достать любую из версий через git checkout-index --stage=N -- <file>.
Включи zdiff3 прямо сейчас
git config --global merge.conflictStyle zdiff3
# Проверь
git config --global merge.conflictStyle
# zdiff3
Это применится ко всем будущим конфликтам во всех твоих репозиториях. Никаких минусов нет, только плюсы — больше контекста при чтении.
Попробуй сам
# 1. Включи zdiff3 (если ещё не)
git config --global merge.conflictStyle zdiff3
# 2. Создай тестовый репо
mkdir markers-demo && cd markers-demo
git init
cat > config.py <<EOF
DB_HOST = "localhost"
DB_PORT = 5432
DB_TIMEOUT = 10
EOF
git add . && git commit -m "init"
# 3. Создай два конфликтующих изменения
git switch -c feature
sed -i.bak 's/DB_TIMEOUT = 10/DB_TIMEOUT = 60/' config.py && rm config.py.bak
git commit -am "feature: increase timeout to 60"
git switch main
sed -i.bak 's/DB_TIMEOUT = 10/DB_TIMEOUT = 30/' config.py && rm config.py.bak
git commit -am "main: increase timeout to 30"
# 4. Попробуй смерджить — будет конфликт
git merge feature
# 5. Посмотри файл — увидишь маркеры с BASE
cat config.py
# 6. Если зачем-то надо переключить style временно:
git config merge.conflictStyle diff2
# Сделай merge --abort и снова merge — увидишь старый формат
git merge --abort
git merge feature
cat config.py
grep: поиск в файлах по шаблону