Слияние и перебазирование глубоко: merge, rebase, конфликты, reflog
Это главная глава раздела — и главная причина, почему у Git репутация «страшного». Merge конфликтует, rebase «ломает историю», reset «съедает коммиты». Но ты уже знаешь из internals, что Git — это указатели (refs) и неизменяемые объекты. С этой оптикой merge и rebase — всего лишь два способа переставить указатели и, может быть, создать пару новых объектов. Ничего не «ломается» само: ломаются наши ожидания.
В продакшене эта глава — ежедневная работа. Пул-реквесты мерджатся в main, feature-ветки ребейзятся перед ревью, конфликты разрешаются вручную, а когда что-то пошло не так — спасает reflog. Разработчик, который понимает механику, решает «слияние с конфликтами» за минуты; не понимающий — полчаса мучается и боится нажимать кнопки.
Идём от простого к сложному: два типа merge, конфликты, rebase, интерактивный rebase, cherry-pick, revert против reset и в финале — reflog, твой спасательный круг.
Merge: fast-forward против three-way
Заголовок раздела «Merge: fast-forward против three-way»Дано: ветка feature ответвилась от main и ушла вперёд. Мы стоим на main и хотим влить feature (подробная механика — git-merge(1)).
Случай 1 — fast-forward (ff). За время жизни feature в main не было новых коммитов. История — одна прямая линия:
ДО merge: ПОСЛЕ git merge feature:
A ←──── B ←──── C A ←──── B ←──── C ▲ ▲ │ │ main, feature main, feature (HEAD -> main) (HEAD -> main)main просто «передёргивается» (перемещается указателем) до C. Новых объектов нет, хэши не меняются, история линейна. Это идеальный случай: ничего не может конфликтовать.
Случай 2 — three-way merge (true merge). В main тоже появились коммиты — ветки разошлись:
ДО merge: C ←──── D / \A ←──── B ────────────────── ← main (HEAD) \ E ←──── F ← feature
ПОСЛЕ git merge feature:
C ←──── D ←──────────┐ / \ \A ←──── B ────────────────── M ←───────┴── main (HEAD) \ ▲ E ←──── F ←──────────┘ featureM — merge commit: у него два родителя (D — первая родительская линия, F — вторая). Merge commit создаётся автоматически, его содержимое Git вычисляет сам: он берёт снапшоты трёх точек — общий предок B, наша D, ихняя F — и для каждого файла решает, что взять.
Git отказывается от ff только при реальной развилке; для истории с –no-ff можно заставить создать merge commit даже на прямой линии (так делают, чтобы сохранить «пузыри» feature-веток в истории).
git switch maingit merge feature # ff, если возможно; иначе three-waygit merge --no-ff feature # всегда merge commitgit merge --ff-only feature # только ff; упадёт с ошибкой при развилкеКонфликты: как читать и разрешать
Заголовок раздела «Конфликты: как читать и разрешать»Конфликт — не ошибка. Это состояние: Git честно говорит «в обеих ветках меняли одни и те же строки, я не решаю за тебя». Репозиторий встаёт на паузу посреди merge, и дальше два пути: разрешить и завершить, или прервать всё.
Что видно при конфликте:
git merge feature# Auto-merging app.ts# CONFLICT (content): Merge conflict in app.ts# Automatic merge failed; fix conflicts and then commit the result.
git status# You have unmerged paths.# both modified: app.ts ← маркер в index: stage 1/2/3В файле появляются conflict-markers:
function getTimeout() {<<<<<<< HEAD return 5000; // наша версия (то, куда мерджим, HEAD)======= return 30_000; // ихняя версия (merging-ветка, feature)>>>>>>> feature}Читаем маркеры: <<<<<<< HEAD открывает «нашу» сторону, ======= — разделитель, >>>>>>> feature — конец «ихней» стороны. Задача — отредактировать файл до рабочего состояния: оставить одну сторону, склеить обе или переписать заново. Маркеры и лишние строки нужно удалить.
Под капотом в index в этот момент живут три версии файла:
index после начала конфликтного merge:
stage 1: app.ts (база, B) ← git ls-files --stage: "1 app.ts" stage 2: app.ts (ours, D) ← "2 app.ts" stage 3: app.ts (theirs, F) ← "3 app.ts"
посмотреть: git show :1:app.ts / :2:app.ts / :3:app.tsПолный цикл разрешения:
# 1. разгрести все файлы из git status (отредактировать, убрать маркеры)git add app.ts # разрешённый файл → stage 0
# 2. когда все файлы разрешены:git commit # завершает merge, создаёт M# (с сообщением по умолчанию "Merge branch 'feature'")Если в процессе понял, что merge был ошибкой — прервись чисто:
git merge --abort # вернёт всё как было до git mergeЕсли одна из сторон правильная целиком и не хочется править вручную:
git checkout --ours app.ts # взять нашу версию (HEAD)git checkout --theirs app.ts # взять ихнюю версию (feature)git add app.tsRebase: механика пересадки коммитов
Заголовок раздела «Rebase: механика пересадки коммитов»Rebase — альтернатива merge с другой философией: не склеивать ветки коммитом-слиянием, а переписать историю feature так, будто она началась от текущего main (классический разбор — Pro Git, раздел «Rebase»). Все коммиты feature пересоздаются (новые хэши!) поверх новой базы.
Пошагово, что делает git rebase main, стоя на feature:
ШАГ 0 — исходное состояние:
C ←──── D ← main /A ←──── B ─────┤ \ E ←──── F ← feature (HEAD)
ШАГ 1 — откатить feature до общего предка (B), запомнив E и F:
main: A ← B ← C ← Dfeature: A ← Bвременный stash-подобный буфер: [E, F]
ШАГ 2 — переместить указатель feature на main:
main: A ← B ← C ← Dfeature: A ← B ← C ← D ← указатель подвинулся
ШАГ 3 — последовательно пересоздать E и F поверх новой базы:
main: A ← B ← C ← D \ E' ← F' ← feature (HEAD)
E' = копия E (тот же patch, но parent = D) → новый хэшF' = копия F (parent = E') → новый хэшE и F остаются в базе до gc, доступны через reflogПочему новые хэши? Потому что хэш коммита включает хэш родителя: сменил базу — сменился хэш. Можно думать о rebase как о цепочке cherry-pick’ов (см. ниже): каждый коммит feature «перенакатывается» на новое место.
git switch featuregit rebase main # пересадить feature на main
# если во время пересадки конфликт:# разрешаешь файл → git add → git rebase --continue# сдался:git rebase --abort # вернуть feature как было# пропустить проблемный коммит целиком:git rebase --skipИнтерактивный rebase: история как редактор
Заголовок раздела «Интерактивный rebase: история как редактор»git rebase -i (интерактивный) — инструмент причёсывания истории: склеить коммиты, переименовать, выкинуть, переставить местами. Ты редактируешь «сценарий» в редакторе, а Git исполняет его как серию операций.
Запуск:
git rebase -i HEAD~4 # последние 4 коммита текущей веткиgit rebase -i main # всё, что feature ушла от mainGit открывает файл со списком команд:
pick 9c2d5a8 feat(auth): форма и валидацияpick 71bf2c1 feat(auth): экран входаpick 3b18e51 fix: опечатка в placeholderpick d829f0a wip: отладочные логи
# Команды:# p, pick — оставить коммит как есть# r, reword — оставить, но изменить сообщение# e, edit — остановиться на этом коммите (поправить и продолжить)# s, squash — склеить с предыдущим (сообщения объединяются)# f, fixup — склеить с предыдущим, сообщение этого выбросить# d, drop — выкинуть коммит# (перестановка строк = reorder коммитов)Пример сценария: склеить wip в предыдущий, переименовать опечатку, выкинуть логи:
pick 9c2d5a8 feat(auth): форма и валидацияsquash 71bf2c1 feat(auth): экран входа ← два коммита станут однимreword 3b18e51 fix: опечатка в placeholder ← Git попросит новое сообщениеdrop d829f0a wip: отладочные логи ← исчезнет безвозвратноРезультат: вместо четырёх коммитов — два аккуратных. Это стандартный ритуал перед открытием пул-реквеста: история читается как документация, а не как дневник.
Cherry-pick: перенести один коммит
Заголовок раздела «Cherry-pick: перенести один коммит»git cherry-pick <хэш> (см. git-cherry-pick(1)) — взять diff конкретного коммита и применить его поверх текущей ветки, создав новый коммит:
ДО: ПОСЛЕ cherry-pick E:
main: A ← B ← C ← D main: A ← B ← C ← D ← E'feature: B ← E ← F ▲ копия patch'а E, (E' ≠ E: новый хэш, parent = D)Применения: перенести hotfix из релизной ветки в main, забрать одну фичу из чужой ветки без merge целиком. Конфликты разрешаются так же, как в rebase: правишь, git add, git cherry-pick --continue.
Revert против reset: откат без боли и откат силой
Заголовок раздела «Revert против reset: откат без боли и откат силой»Revert (см. git-revert(1)) — создать новый коммит, обратный указанному (антипатч). История не переписывается — откат сам становится частью истории. Безопасен для запушенного.
ДО: ПОСЛЕ git revert D:
A ← B ← C ← D ← main A ← B ← C ← D ← D⁻¹ ← main (HEAD) (HEAD)
D⁻¹ = commit, который делает ровно противоположное Dgit revert HEAD # откатить последний коммитgit revert 9c2d5a8 # откатить конкретныйgit revert A..B # откатить диапазон (после A до B включительно)Reset (см. git-reset(1)) — передвинуть указатель ветки назад (и, опционально, прибрать index и рабочую директорию). История переписывается: коммиты «исчезают» из ветки (объекты живы в reflog). Три режима — три глубины воздействия на зоны:
HEAD INDEX WORKDIR--soft ◀─── stays stays «забыть коммиты, правки оставить»--mixed ◀─── ◀─── stays «ещё и раз unstagить» (default)--hard ◀─── ◀─── ◀─── «вычистить всё, как было в C»
схема для git reset --hard HEAD~2:
до: A ← B ← C ← D ← E main, HEAD index: E workdir: E │после: A ← B ← C main, HEAD index: C workdir: C ▲ └── D и E: безымянные, живут в reflog ~90 днейgit reset --soft HEAD~1 # отменить последний коммит, правки останутся stagedgit reset HEAD~1 # то же + разstagить (mixed, по умолчанию)git reset --hard HEAD~1 # ПОЛНАЯ потеря коммита и правок (осторожно!)git reset --hard origin/main # «забудь всё локальное, как на сервере»Три зоны — три риска. --soft безопасен почти всегда. --mixed тоже (правки не теряются, только index). --hard — единственный, который затирает рабочую директорию: смотри git stash или commit перед ним.
| Задача | Команда | Переписывает историю? |
|---|---|---|
| Откатить запушенный коммит безопасно | git revert |
Нет |
| Отменить локальный коммит, оставив правки | git reset --soft HEAD~1 |
Да (локально) |
| Разstagить всё | git reset |
Нет (только index) |
| Убрать мусорные правки полностью | git reset --hard / git restore . |
Да, и workdir затирает |
Reflog: спасательный круг
Заголовок раздела «Reflog: спасательный круг»Reflog (см. git-reflog(1)) — журнал всех передвижений HEAD (а значит, и веток): каждый commit, reset, rebase, merge, checkout записывается в .git/logs/HEAD. Это локальный механизм: не пушится, не клонируется. И главное: пока запись в reflog жива (по умолчанию 90 дней), ни один коммит не потерян — даже после --hard.
.git/logs/HEAD (суть):
9c2d5a8 HEAD@{0}: commit: feat(auth): формаd829f0a HEAD@{1}: reset: moving to HEAD~1 ← «потерянный» коммит71bf2c1 HEAD@{2}: commit: feat(auth): экран3b18e51 HEAD@{3}: rebase (start): checkout main...git reflog # журнал перемещений HEADgit reflog show feature # журнал конкретной веткиgit log -g # тот же reflog в формате log
# спасение после git reset --hard HEAD~2:git reflog # нашёл хэш «потерянного» E в записи HEAD@{n}git switch -c rescued <хэш> # вернул его именованной веткойСхема спасения:
«потеряли» D и E: спасли через reflog:
A ← B ← C main, HEAD A ← B ← C main, HEAD ▲ ▲ \ │ │ \ (D, E — живы, rescued: D ← E ← новая ветка просто безымянны) на старых объектах в reflog: адреса есть) (объекты не копировались!)Merge или rebase: таблица решений
Заголовок раздела «Merge или rebase: таблица решений»| Ситуация | Merge | Rebase |
|---|---|---|
| Влить готовую feature в main | ✅ git merge --no-ff feature (пузырь виден) |
❌ запрещён (main публичный) |
| Обновить свою локальную feature относительно main | ⚠️ можно, но история ветки «ломается» пузырями | ✅ git rebase main до пуша |
| Причесать историю перед PR | — | ✅ git rebase -i main |
| Hotfix в релизную ветку | ✅ прямой merge | — |
| Откатить запушенное | ✅ git revert |
❌ никогда не переписывай публичное |
| Длинная feature, живущая месяц | merge main → feature регулярно, история честная | rebase + rerere, история линейная (нужна дисциплина) |
Практическое правило команды, работающей с GitHub Flow: в main — только merge через PR (с запретом push), в свои feature — rebase как угодно, пока не открыт PR. После merge PR ветку удаляют — никакой «длинной» истории не остаётся, и rebase бесплатен.
Связь с соседними темами
Заголовок раздела «Связь с соседними темами»- Internals — конфликты читаются через stage 1/2/3 в index; rebase работает, потому что объекты неизменяемы и можно копировать patch’и на новых родителях; reflog — это файлы в
.git/logs/. - Branching — merge/rebase — операции над указателями веток; detached HEAD и
switch -c— инструменты спасения, сюда встроенные. - Staging —
git addпри разрешении конфликтов и--ours/--theirs— прямое продолжение рабочего цикла.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Rebase запушенной ветки, над которой работают другие. Golden rule нарушен: коллегы получают расходящуюся историю и вынуждены делать merge чужих переписанных коммитов или force-pull. Решение: revert вместо переписывания.
git mergeс конфликтом → правка файлов →git commitзабыт. Состояние «все разрешено, но merge не завершён» висит; следующий коммит попадёт внутрь merge. Проверяйgit statusдо коммита: должен быть пустым списком unmerged.- Разрешение конфликта через «взял –theirs, потом удивляюсь, где мои правки».
--ours/--theirsработает в контексте операции: при rebase «ours» — это ПРИЁМНАЯ ветка (main), а «theirs» — твои коммиты feature. Интуиция переворачивается! При merge наоборот. Читай стороны по маркерам, не по привычке. git reset --hardвместоgit restoreи слёзы над потерянными правками. Hard reset двигает ветку И затирает workdir. Нужно просто отменить локальные правки —git restore ., без движения ветки.- «Потерял» коммиты и пересоздаёт репозиторий. Reflog +
git switch -c rescue <хэш>решает за минуту. Пересоздание репозитория — ядерный вариант для случаев физического повреждения диска. - Отсутствие
--abort-рефлекса. Зашёл в конфликтный merge/rebase, непонятно, что делать — сразуgit merge --abort/git rebase --abort, разобраться со схемой на бумаге, повторить. Полумёртвый merge хуже чистого.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем fast-forward отличается от three-way merge? FF — целевая ветка просто передвигается к концу вливаемой, новых объектов нет. Three-way — ветки разошлись, Git строит merge commit с двумя родителями, сравнивая общий предок и обе головы.
- Что происходит при конфликте на уровне index?
Файл получает три записи: stage 1 — базовая версия, stage 2 — «ours» (HEAD), stage 3 — «theirs» (вливаемая). Рабочий файл содержит conflict-markers. Разрешение = привести файл в итоговый вид и
git add(запись возвращается в stage 0). - Почему rebase меняет хэши коммитов? Хэш коммита включает хэш родителя. Rebase пересоздаёт каждый коммит на новой базе → меняется родитель → меняется хэш. Содержимое (patch) то же, идентичность другая.
- Golden rule of rebase — сформулируй. Не перебазируй коммиты, которые уже опубликованы (запушены) и могли быть использованы другими, иначе их история разойдётся с твоей переписанной.
- Чем revert отличается от reset? Когда какой? Revert создаёт новый «обратный» коммит, историю не трогает — для публичных веток. Reset передвигает указатель ветки назад, «выбрасывая» коммиты — только для локальной работы; спасение через reflog.
- Что такое reflog и что он спасает? Журнал всех передвижений HEAD/веток (commit, reset, rebase, merge) за ~90 дней. Позволяет найти и вернуть «потерянные» коммиты после reset –hard, неудачного rebase, переключений в detached HEAD.
git checkout --theirsпри rebase — чья это версия? «Theirs» — сторона ВЛИВАЕМЫХ коммитов (твоя feature), а «ours» — приёмная ветка (main). При merge наоборот. Источник путаницы №1 при разрешении конфликтов.- Когда merge, когда rebase? Публичные ветки (main, релизы) — merge, историю не переписывать. Личные feature-ветки до пуша/PR — rebase для линейной истории. Всё, что запушено и разошлось, — merge или revert.
Практика
Заголовок раздела «Практика»- В песочном репозитории сделай ветку
feature, коммит в ней, вернись в main без коммитов и сделай ff-merge. Затем добавь коммит в main, ещё один в feature и сделай three-way merge. Критерий: после первого mergegit log --onelineлинейный, после второго есть merge commit с двумя родителями (git cat-file -p HEADпоказывает две строки parent). - Сымитируй конфликт: обе ветки меняют одну строку. Не разрешая, посмотри
git ls-files --stageиgit show :1/:2/:3:файл. Разреши вручную,git add,git commit. Критерий:git statusчист, merge commit создан, маркеров в файле нет (grep -r '<<<<<<<' .пусто). - Отрепетируй
--abort: начни конфликтный merge, измени файл, затемgit merge --abort. Критерий: рабочая директория идентична состоянию до merge (проверьgit statusи содержимое файла). - Сделай ветку с тремя коммитами (включая «wip» и «fix typo»), пока main ушёл вперёд. Перебазируй feature на main с конфликтом, разреши через
--theirsдля одного файла. Критерий:git lgпоказывает три новых хэша поверх main; старые коммиты видны вgit reflog. - Повтори №4 с включённым
rerere.enabledи убедись, что при повторном rebase того же конфликта Git применяет твоё прошлое разрешение. Критерий:.git/rr-cache/содержит запись, повторный rebase прошёл без остановки. - Закоммить ошибочный коммит в main, сделай
git revert, затем отдельно —git reset --soft HEAD~1в другой ветке. Критерий: объясни разницу по схеме объектов; покажи в reflog записи обеих операций. - «Катастрофа»: сделай три коммита,
git reset --hard HEAD~3, «потеряй» всё. Спаси черезgit reflog, не создавая новых коммитов до спасения. Критерий: все три коммита адресуются новой веткой,git fsckбез «dangling commit», время спасения < 5 минут.
Что почитать
Заголовок раздела «Что почитать»- Pro Git, глава 3: Ветвление — rebase — классический разбор механики rebase и golden rule.
- Pro Git, глава 7: rerere — reuse recorded resolution.
- git-reflog(1) — man-страница — формат журнала и expire-политики.
- Merging vs. Rebasing (Atlassian Git Tutorial) — наглядные схемы и командные сценарии.
- Oh Shit, Git!?! — спасательные рецепты на русском.
- Conventional Commits + squash: причёсывание истории перед PR — связка rebase -i с форматом коммитов из предыдущей главы.