Перейти к содержимому

Слияние и перебазирование глубоко: merge, rebase, конфликты, reflog

Это главная глава раздела — и главная причина, почему у Git репутация «страшного». Merge конфликтует, rebase «ломает историю», reset «съедает коммиты». Но ты уже знаешь из internals, что Git — это указатели (refs) и неизменяемые объекты. С этой оптикой merge и rebase — всего лишь два способа переставить указатели и, может быть, создать пару новых объектов. Ничего не «ломается» само: ломаются наши ожидания.

В продакшене эта глава — ежедневная работа. Пул-реквесты мерджатся в main, feature-ветки ребейзятся перед ревью, конфликты разрешаются вручную, а когда что-то пошло не так — спасает reflog. Разработчик, который понимает механику, решает «слияние с конфликтами» за минуты; не понимающий — полчаса мучается и боится нажимать кнопки.

Идём от простого к сложному: два типа merge, конфликты, rebase, интерактивный rebase, cherry-pick, revert против reset и в финале — reflog, твой спасательный круг.

Дано: ветка 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 ←──────────┘
feature

M — merge commit: у него два родителя (D — первая родительская линия, F — вторая). Merge commit создаётся автоматически, его содержимое Git вычисляет сам: он берёт снапшоты трёх точек — общий предок B, наша D, ихняя F — и для каждого файла решает, что взять.

Git отказывается от ff только при реальной развилке; для истории с –no-ff можно заставить создать merge commit даже на прямой линии (так делают, чтобы сохранить «пузыри» feature-веток в истории).

Окно терминала
git switch main
git merge feature # ff, если возможно; иначе three-way
git merge --no-ff feature # всегда merge commit
git 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.ts

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 ← D
feature: A ← B
временный stash-подобный буфер: [E, F]
ШАГ 2 — переместить указатель feature на main:
main: A ← B ← C ← D
feature: 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 feature
git rebase main # пересадить feature на main
# если во время пересадки конфликт:
# разрешаешь файл → git add → git rebase --continue
# сдался:
git rebase --abort # вернуть feature как было
# пропустить проблемный коммит целиком:
git rebase --skip

git rebase -i (интерактивный) — инструмент причёсывания истории: склеить коммиты, переименовать, выкинуть, переставить местами. Ты редактируешь «сценарий» в редакторе, а Git исполняет его как серию операций.

Запуск:

Окно терминала
git rebase -i HEAD~4 # последние 4 коммита текущей ветки
git rebase -i main # всё, что feature ушла от main

Git открывает файл со списком команд:

pick 9c2d5a8 feat(auth): форма и валидация
pick 71bf2c1 feat(auth): экран входа
pick 3b18e51 fix: опечатка в placeholder
pick 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: отладочные логи ← исчезнет безвозвратно

Результат: вместо четырёх коммитов — два аккуратных. Это стандартный ритуал перед открытием пул-реквеста: история читается как документация, а не как дневник.

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, который делает ровно противоположное D
Окно терминала
git 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 # отменить последний коммит, правки останутся staged
git 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 (см. 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 # журнал перемещений HEAD
git 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
Влить готовую 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 — инструменты спасения, сюда встроенные.
  • Staginggit add при разрешении конфликтов и --ours/--theirs — прямое продолжение рабочего цикла.
  1. Rebase запушенной ветки, над которой работают другие. Golden rule нарушен: коллегы получают расходящуюся историю и вынуждены делать merge чужих переписанных коммитов или force-pull. Решение: revert вместо переписывания.
  2. git merge с конфликтом → правка файлов → git commit забыт. Состояние «все разрешено, но merge не завершён» висит; следующий коммит попадёт внутрь merge. Проверяй git status до коммита: должен быть пустым списком unmerged.
  3. Разрешение конфликта через «взял –theirs, потом удивляюсь, где мои правки». --ours/--theirs работает в контексте операции: при rebase «ours» — это ПРИЁМНАЯ ветка (main), а «theirs» — твои коммиты feature. Интуиция переворачивается! При merge наоборот. Читай стороны по маркерам, не по привычке.
  4. git reset --hard вместо git restore и слёзы над потерянными правками. Hard reset двигает ветку И затирает workdir. Нужно просто отменить локальные правки — git restore ., без движения ветки.
  5. «Потерял» коммиты и пересоздаёт репозиторий. Reflog + git switch -c rescue <хэш> решает за минуту. Пересоздание репозитория — ядерный вариант для случаев физического повреждения диска.
  6. Отсутствие --abort-рефлекса. Зашёл в конфликтный merge/rebase, непонятно, что делать — сразу git merge --abort / git rebase --abort, разобраться со схемой на бумаге, повторить. Полумёртвый merge хуже чистого.
  1. Чем fast-forward отличается от three-way merge? FF — целевая ветка просто передвигается к концу вливаемой, новых объектов нет. Three-way — ветки разошлись, Git строит merge commit с двумя родителями, сравнивая общий предок и обе головы.
  2. Что происходит при конфликте на уровне index? Файл получает три записи: stage 1 — базовая версия, stage 2 — «ours» (HEAD), stage 3 — «theirs» (вливаемая). Рабочий файл содержит conflict-markers. Разрешение = привести файл в итоговый вид и git add (запись возвращается в stage 0).
  3. Почему rebase меняет хэши коммитов? Хэш коммита включает хэш родителя. Rebase пересоздаёт каждый коммит на новой базе → меняется родитель → меняется хэш. Содержимое (patch) то же, идентичность другая.
  4. Golden rule of rebase — сформулируй. Не перебазируй коммиты, которые уже опубликованы (запушены) и могли быть использованы другими, иначе их история разойдётся с твоей переписанной.
  5. Чем revert отличается от reset? Когда какой? Revert создаёт новый «обратный» коммит, историю не трогает — для публичных веток. Reset передвигает указатель ветки назад, «выбрасывая» коммиты — только для локальной работы; спасение через reflog.
  6. Что такое reflog и что он спасает? Журнал всех передвижений HEAD/веток (commit, reset, rebase, merge) за ~90 дней. Позволяет найти и вернуть «потерянные» коммиты после reset –hard, неудачного rebase, переключений в detached HEAD.
  7. git checkout --theirs при rebase — чья это версия? «Theirs» — сторона ВЛИВАЕМЫХ коммитов (твоя feature), а «ours» — приёмная ветка (main). При merge наоборот. Источник путаницы №1 при разрешении конфликтов.
  8. Когда merge, когда rebase? Публичные ветки (main, релизы) — merge, историю не переписывать. Личные feature-ветки до пуша/PR — rebase для линейной истории. Всё, что запушено и разошлось, — merge или revert.
  1. В песочном репозитории сделай ветку feature, коммит в ней, вернись в main без коммитов и сделай ff-merge. Затем добавь коммит в main, ещё один в feature и сделай three-way merge. Критерий: после первого merge git log --oneline линейный, после второго есть merge commit с двумя родителями (git cat-file -p HEAD показывает две строки parent).
  2. Сымитируй конфликт: обе ветки меняют одну строку. Не разрешая, посмотри git ls-files --stage и git show :1/:2/:3:файл. Разреши вручную, git add, git commit. Критерий: git status чист, merge commit создан, маркеров в файле нет (grep -r '<<<<<<<' . пусто).
  3. Отрепетируй --abort: начни конфликтный merge, измени файл, затем git merge --abort. Критерий: рабочая директория идентична состоянию до merge (проверь git status и содержимое файла).
  4. Сделай ветку с тремя коммитами (включая «wip» и «fix typo»), пока main ушёл вперёд. Перебазируй feature на main с конфликтом, разреши через --theirs для одного файла. Критерий: git lg показывает три новых хэша поверх main; старые коммиты видны в git reflog.
  5. Повтори №4 с включённым rerere.enabled и убедись, что при повторном rebase того же конфликта Git применяет твоё прошлое разрешение. Критерий: .git/rr-cache/ содержит запись, повторный rebase прошёл без остановки.
  6. Закоммить ошибочный коммит в main, сделай git revert, затем отдельно — git reset --soft HEAD~1 в другой ветке. Критерий: объясни разницу по схеме объектов; покажи в reflog записи обеих операций.
  7. «Катастрофа»: сделай три коммита, git reset --hard HEAD~3, «потеряй» всё. Спаси через git reflog, не создавая новых коммитов до спасения. Критерий: все три коммита адресуются новой веткой, git fsck без «dangling commit», время спасения < 5 минут.