Разрешение конфликтов вручную
Когда git merge уперся в конфликт, Git останавливается и ждёт от тебя действий. Что именно делать — единый workflow на все случаи: посмотри git status, открой файл, удали маркеры, оставь нужный код, добавь в индекс, закоммить. В этом уроке мы разбираем этот workflow шаг за шагом, плюс хитрые случаи вроде git checkout --ours/--theirs для бинарных файлов и lock-файлов.
Полный workflow разрешения
Разберём каждый шаг.
Шаг 1: git status — что произошло
После провалившегося merge всегда начинай со status. Он скажет, что ровно случилось и что делать дальше.
$ git merge feature
Auto-merging src/etl.py
CONFLICT (content): Merge conflict in src/etl.py
Auto-merging src/config.py
CONFLICT (content): Merge conflict in src/config.py
Automatic merge failed; fix conflicts and then commit the result.
$ 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/config.py
both modified: src/etl.py
Ключевые подсказки:
- “You have unmerged paths” — состояние merge in progress.
- “both modified” — обе ветки тронули этот файл.
- В скобках Git подсказывает команды:
git merge --abort,git addдля разрешения.
Шаг 2: открыть файл и разобраться
Открой первый конфликтный файл в редакторе. Найди блоки с маркерами ≪≪≪≪≪≪≪ === ≫≫≫≫≫≫≫ (или ≪≪≪ ||| === ≫≫≫ с zdiff3).
В каждом блоке у тебя есть несколько опций:
Опция A: оставить OURS
Если ты понимаешь, что твоя версия правильная, удали маркеры и THEIRS-блок:
def fetch():
<<<<<<< HEAD
return "v2-from-main"
=======
return "v3-from-feature"
>>>>>>> feature
Становится:
def fetch():
return "v2-from-main"
Опция B: оставить THEIRS
Аналогично, если их версия правильнее:
def fetch():
return "v3-from-feature"
Опция C: объединить обе
Самый частый случай: обе стороны внесли полезные изменения, нужно слить.
def fetch():
# merged: оставили v2 из main, но добавили fallback из feature
try:
return "v2-from-main"
except Exception:
return "v3-from-feature"
Опция D: третий путь
Иногда правильный ответ не “OURS или THEIRS”, а что-то третье:
def fetch():
return os.environ.get("API_VERSION", "v2-from-main")
Это нормально. Git тебя ничем не ограничивает — главное, чтобы маркеры исчезли и код был валидный.
Перед git add обязательно поищи в файле все маркеры. Если хоть один блок остался — закоммитишь сломанный файл. Команда поиска (запускай в терминале):
grep -n "<<<<<<<\|=======\|>>>>>>>" src/etl.pyШаг 3: git add — отметить разрешённым
После того, как файл выглядит как валидный код без маркеров, скажи Git’у “я разобрался”:
git add src/etl.py
Это меняет состояние файла в index с “unmerged” на “ready”. git status обновится:
$ git status
On branch main
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge)
Changes to be committed:
modified: src/etl.py
Заметь: git status подскажет “All conflicts fixed”. Это сигнал, что можно коммитить.
Если файлов несколько, добавляй каждый по очереди (или все сразу через git add -A / git add ., но осторожно — это добавит и unrelated unstaged changes).
Шаг 4: git commit — закрепить мердж
После git add всех разрешённых файлов:
$ git commit
Git откроет редактор с автоматически предзаполненным сообщением:
Merge branch 'feature' into main
# Conflicts:
# src/etl.py
# src/config.py
#
# It looks like you may be committing a merge.
# If this is not correct, please run
# git update-ref -d MERGE_HEAD
# and try again.
Стандарт — оставить дефолт и сохранить редактор (:wq в vim, Ctrl+S в VS Code). Если хочешь короче — git commit --no-edit.
После коммита merge завершён. git log покажет merge commit с двумя родителями:
$ git log --oneline --graph
* abc1234 (HEAD -> main) Merge branch 'feature' into main
|\
| * def5678 (feature) feature: change v1 to v3
* | 9876543 main: change v1 to v2
|/
* a1b2c3d init
git merge --abort: всё откатить
Если на шаге 2 ты понял “ой, не хочу сейчас этим заниматься” — можно безопасно откатиться:
git merge --abort
Это вернёт твою ветку в состояние до git merge. Все локальные изменения, которые были до merge, сохранятся. Сам merge будто и не начинался.
git merge --abort — твой safety net. Если не уверен в разрешении конфликта, лучше abort, спросить коллегу, и попробовать снова. Не выкручивайся в потёмках.
Эквивалентные команды для других операций:
git rebase --abort
git cherry-pick --abort
git revert --abort
git checkout --ours/--theirs для целого файла
Иногда понятно, что нужно полностью взять одну сторону, не вдаваясь в детали. Например:
- Конфликт в
package-lock.json/poetry.lock/pdm.lock/Pipfile.lock— нет смысла мерджить, проще регенерировать. - Конфликт в бинарном файле (PNG, PDF, parquet) — нет diff построчно, придётся выбрать целиком.
- Конфликт в сгенерированном файле (миграции, схемы) — обычно перегенерируешь.
Команды:
# Взять полностью версию из OURS (твоя ветка)
git checkout --ours path/to/file
git add path/to/file
# Взять полностью версию из THEIRS (мерджимая ветка)
git checkout --theirs path/to/file
git add path/to/file
В современном Git есть аналог через restore:
git restore --ours path/to/file
git restore --theirs path/to/file
Специальный случай: lock-файлы
Для package-lock.json и аналогов не пытайся мерджить руками — это бессмысленно, lock-файл регенерируется детерминированно из манифеста.
# Сохрани какую-то из версий
git checkout --theirs package-lock.json
# Регенерируй
npm install
# или
poetry lock --no-update
# или
pdm lock
# Добавь регенерированный
git add package-lock.json
То же самое для Python — requirements.txt обычно мерджится руками (там читаемый текст), но poetry.lock / uv.lock всегда регенерируется.
Что значит “unmerged paths”
В шапке git status ты увидишь раздел Unmerged paths. Это файлы в особом состоянии конфликта. У них есть три stage в index (BASE, OURS, THEIRS), и Git ждёт, пока ты их схлопнешь в один.
После git add файл переходит в обычный stage 0 — Git понимает, “разрешено”.
$ git status
You have unmerged paths.
Unmerged paths:
both modified: src/etl.py
$ git add src/etl.py
$ git status
All conflicts fixed but you are still merging.
Changes to be committed:
modified: src/etl.py
Хитрый случай: конфликт в файле, который ты не трогал
Бывает странное: git merge падает с конфликтом в файле, в котором, как тебе кажется, ты ничего не менял. Возможные причины:
-
CRLF / LF — Windows. Кто-то закоммитил файл с CRLF, ты с LF, или наоборот. Каждая строка считается изменённой. Решение: настройка
core.autocrlfдля команды (.gitattributesс* text=autoобычно решает). -
Whitespace-only изменения. Перформатирование (или прогон через линтер другой версии) меняет все строки, и любой merge становится конфликтом. Решение: договорённость про формат + pre-commit hooks.
-
Перемещения / rename. Файл переименован, и Git не догадался отследить переименование. Тогда он видит “удалили один файл, создали другой с такими же изменениями”. Решение:
git config --global merge.renames true(включено по умолчанию в Git 2.18+).
Сценарий: разрешение конфликтов пошагово
Воспроизведём типичный конфликт и пройдём workflow:
# Создаём sandbox
mkdir resolve-demo && cd resolve-demo
git init -b main
# Начальный файл
cat > app.py <<EOF
import os
DB_HOST = "localhost"
DB_PORT = 5432
DB_TIMEOUT = 10
def connect():
return f"db://{DB_HOST}:{DB_PORT}"
EOF
git add . && git commit -m "init"
# Ветка production-config
git switch -c production-config
sed -i.bak 's/DB_HOST = "localhost"/DB_HOST = "prod.example.com"/' app.py
sed -i.bak 's/DB_TIMEOUT = 10/DB_TIMEOUT = 30/' app.py
rm app.py.bak
git commit -am "config: production DB settings"
# Ветка retry-logic от main
git switch main
git switch -c retry-logic
sed -i.bak 's/DB_TIMEOUT = 10/DB_TIMEOUT = 60/' app.py
rm app.py.bak
git commit -am "config: increase timeout for retries"
# Мерджим обе ветки в main
git switch main
git merge production-config # этот пройдёт fast-forward
git merge retry-logic # тут будет CONFLICT
# Смотрим статус
git status
# both modified: app.py
# Открываем файл, видим:
cat app.py
# Заметим конфликт на DB_TIMEOUT
Что мы видим (с zdiff3):
DB_HOST = "prod.example.com"
DB_PORT = 5432
<<<<<<< HEAD
DB_TIMEOUT = 30
||||||| merged common ancestors
DB_TIMEOUT = 10
=======
DB_TIMEOUT = 60
>>>>>>> retry-logic
Решение: для production retry нужен максимум — берём 60.
cat > app.py <<EOF
import os
DB_HOST = "prod.example.com"
DB_PORT = 5432
DB_TIMEOUT = 60
def connect():
return f"db://{DB_HOST}:{DB_PORT}"
EOF
# Проверяем, что маркеров не осталось
grep -n "<<<<<<<\|=======\|>>>>>>>" app.py
# (ничего не выводит)
# Отмечаем разрешённым
git add app.py
# Завершаем merge
git commit --no-edit
# Проверяем результат
git log --oneline --graph --all
Чек-лист перед git commit после конфликта
git statusпоказывает “All conflicts fixed”.- В файлах нет маркеров
≪≪≪≪≪≪≪,=======,≫≫≫≫≫≫≫,|||||||. - Код компилируется/проходит линтер локально.
- Тесты прошли (минимально — те, которые покрывают изменённые участки).
- Если был сложный merge — short проверка вручную, что мердж имеет смысл.
grep для поиска маркеров — твой друг:
grep -rn "<<<<<<<" .
Если ничего не вернёт — маркеров не осталось.
Попробуй сам
# Пройди полный цикл резолва
mkdir conflict-practice && cd conflict-practice
git init
cat > settings.json <<'EOF'
{
"name": "myapp",
"version": "1.0.0",
"timeout": 30,
"retries": 3
}
EOF
git add . && git commit -m "init"
# Ветка перфоманс: повышаем retries
git switch -c perf-tune
sed -i.bak 's/"retries": 3/"retries": 5/' settings.json && rm settings.json.bak
git commit -am "perf: more retries"
# Ветка stability: понижаем retries
git switch main
git switch -c stability
sed -i.bak 's/"retries": 3/"retries": 2/' settings.json && rm settings.json.bak
git commit -am "stability: fewer retries"
# Мерджим
git switch main
git merge perf-tune # fast-forward
git merge stability # CONFLICT
# Дальше — твой ход:
# - Открой settings.json в редакторе
# - Реши конфликт (например, оставь 4 — компромисс)
# - grep маркеры, чтобы убедиться что чисто
# - git add settings.json
# - git commit
# - git log --oneline --graph
pip и venv: зависимости Python-проекта