Merge и rebase: как работают на самом деле и что выбрать команде
Есть два способа объяснить разницу между merge и rebase. Первый — картинками с цветными кружочками и словами «merge сохраняет историю, rebase делает её линейной». Он запоминается за минуту и разваливается на первом же нетривиальном случае: почему rebase попросил разрешить один и тот же конфликт трижды? почему после revert merge-коммита фича не вернулась при повторном слиянии? почему git pull создал коммит, которого никто не писал? Второй способ — посмотреть, какие объекты создаёт каждая команда и куда после этого указывают ссылки. Он занимает вечер, но после него не остаётся вопросов: обе операции становятся предсказуемыми, а любая ошибка — обратимой.
Всё дальнейшее опирается на модель данных из статьи «Внутри Git»: коммит — неизменяемый объект со ссылками на дерево и родителей; ветка — 41 байт с идентификатором коммита; история — направленный ациклический граф. Если это пока звучит как заклинание, начните оттуда. Топология процессов вокруг слияний — в «Стратегиях ветвления», базовые команды и три дерева — в «Ежедневной работе».
Задача, которую решают обе команды
Формулировка одинакова: есть две линии работы, разошедшиеся от общего предка; надо получить состояние, где учтены обе. Разница только в том, какой объект окажется в результате.
Сквозной пример для всей статьи:
$ git log --oneline --graph --all
* 7f3a9c2 (feature/pricing) fix: округление скидки
* 2e18b40 feat: промокоды
* 6e21af0 refactor: вынести расчёт цены
| * 3ba0c17 (HEAD -> main, origin/main) chore: bump deps
| * 9d4c7e1 fix: таймаут в платежах
|/
* c05a3f8 feat: корзина
$ git merge-base main feature/pricing
c05a3f8e21b4d7a09c3f1e6b8d0a5c2f47e9b103
$ git rev-list --left-right --count main...feature/pricing
2 3 # 2 коммита «у нас», 3 «у них» с момента расхождения
Обратите внимание на три точки в main...feature/pricing — это симметрическая разность: то, что достижимо из одной ветки, но не из обеих. Две точки (main..feature/pricing) — только правая сторона минус левая. В git diff три точки означают «сравни правую сторону с merge-base», а не «сравни две стороны»; на этой разнице спотыкаются постоянно. Merge-base — не деталь реализации, а фундамент: именно он делает автоматическое слияние вычислимым.
Трёхпутевое слияние: почему нужна база
Имея две версии файла и больше ничего, объединить их автоматически невозможно — непонятно, какая строка добавлена, а какая удалена. Добавьте третью, общую версию, и для каждого участка текста становится видно, кто именно его менял.
Правило целиком: участок изменила одна сторона → берём её версию; не менял никто → берём базу; обе изменили одинаково → берём одну копию; обе изменили по-разному → конфликт, решает человек. Отсюда главное практическое наблюдение: конфликты порождает физическое перекрытие правок, а не Git и не выбор команды. Merge и rebase дают один и тот же набор конфликтующих участков при одинаковом содержимом — меняется лишь то, сколько раз вам их покажут. Сами конфликты, стили маркеров и zdiff3 разбираются в статье «Конфликты слияния».
Алгоритм построчного слияния
Внутри Git этим занимается xdiff: считаются два диффа — база→ours и база→theirs, — затем непересекающиеся хунки накладываются, а пересекающиеся выдаются как конфликт.
three_way_merge(base, ours, theirs):
da, db = diff(base, ours), diff(base, theirs) # хунки: (начало, конец в base, новые строки)
result, i = [], 0
while i < len(base):
ha, hb = хунк из da в позиции i, хунк из db в позиции i # или None
if ha and hb:
result += ha.новые if ha.новые == hb.новые else конфликт(base, ha, hb)
i = max(ha.конец, hb.конец)
elif ha: result += ha.новые; i = ha.конец
elif hb: result += hb.новые; i = hb.конец
else: result.append(base[i]); i += 1
return result
Рабочая иллюстрация на Python (в проде — git merge-file):
from difflib import SequenceMatcher
def hunks(base: list[str], side: list[str]) -> dict[int, tuple[int, list[str]]]:
sm = SequenceMatcher(None, base, side, autojunk=False) # {начало: (конец, строки)}
return {i1: (i2, side[j1:j2]) for tag, i1, i2, j1, j2 in sm.get_opcodes() if tag != "equal"}
def three_way(base: list[str], ours: list[str], theirs: list[str]) -> list[str]:
a, b, res, i = hunks(base, ours), hunks(base, theirs), [], 0
while i < len(base) or i in a or i in b:
ha, hb = a.get(i), b.get(i)
if ha and hb: # обе стороны трогали участок
res += ha[1] if ha[1] == hb[1] else [ # одинаково → одна копия
"<<<<<<< ours", *ha[1], "||||||| base", *base[i:ha[0]],
"=======", *hb[1], ">>>>>>> theirs"] # по-разному → конфликт
i = max(ha[0], hb[0])
elif ha: res += ha[1]; i = ha[0]
elif hb: res += hb[1]; i = hb[0]
else: res.append(base[i]); i += 1
return res
Сложность: дифф Майерса — O(N·D) по времени, где N — суммарная длина, D — размер минимального скрипта редактирования; память O(N) в линейно-пространственном варианте, который Git и использует. Наложение хунков — O(N). Итого слияние файла стоит примерно как два его диффа; разбор алгоритма и его связи с задачей LCS — в статье «Динамическое программирование». Практический вывод из O(N·D): дорого обходятся не большие файлы, а сильно разошедшиеся. Ветка, живущая месяц, платит за слияние по объёму расхождения — это техническое обоснование коротких веток, а не только «так принято в trunk-based».
Fast-forward: слияния не было
Если merge-base совпадает с текущим HEAD (ваша ветка не содержит ничего своего), объединять нечего — достаточно передвинуть указатель:
$ git merge feature/pricing
Updating 3ba0c17..7f3a9c2
Fast-forward
src/pricing.py | 42 +++++++++++++++++++++++++++++++-----------
1 file changed, 31 insertions(+), 11 deletions(-)
Ни одного нового объекта: изменился 41 байт в .git/refs/heads/main. Поэтому же git push отклоняется, когда fast-forward невозможен: сервер защищает не «историю», а достижимость — чтобы старые коммиты не выпали из ветки.
git merge --ff # по умолчанию: перемотать, если можно, иначе создать merge-коммит
git merge --no-ff # всегда создавать merge-коммит, даже когда можно перемотать
git merge --ff-only # перемотать или отказаться; НИКОГДА не создавать коммит
--no-ff — не косметика: merge-коммит помечает границу задачи, поэтому git log --first-parent даёт по строке на фичу, а git revert -m 1 <merge> откатывает её целиком. При ветках по одному коммиту разницы нет, при ветках по десять — существенная. Отдельно про git pull: по умолчанию это fetch + merge, и именно он порождает бессодержательные коммиты Merge branch 'main' of github.com:.... Лечится раз и навсегда: git config --global pull.ff only (pull падает, если нужно слияние, — и это правильно) либо pull.rebase true, если команда живёт на rebase.
Что делает git merge на самом деле
(рабочее дерево должно быть чистым)"] MB --> Q1{"merge-base == MERGE_HEAD?"} Q1 -- да --> UP["Already up to date"] Q1 -- нет --> Q2{"merge-base == HEAD?"} Q2 -- "да, --ff разрешён" --> FF["Fast-forward:
двигаем ссылку, объектов 0"] Q2 -- нет --> ORT["стратегия ort: слить деревья
base / ours / theirs
+ детекция переименований"] ORT --> Q3{"остались конфликты?"} Q3 -- нет --> AC["записать tree, создать commit
с ДВУМЯ родителями, двинуть ветку"] Q3 -- да --> CF["индекс: stages 1/2/3
маркеры в рабочем дереве
MERGE_HEAD, MERGE_MSG, AUTO_MERGE"] CF -- "git add + git commit" --> AC CF -- "git merge --abort" --> AB["вернуть состояние до merge"]
Ключевое — что остаётся при конфликте. Git не бросает работу «в никуда», он оставляет очень конкретное состояние, которое читается командами:
$ git merge feature/pricing
Auto-merging src/pricing.py
CONFLICT (content): Merge conflict in src/pricing.py
Automatic merge failed; fix conflicts and then commit the result.
$ git ls-files -u # индекс держит ТРИ версии файла — это и есть три пути
100644 6f1a2b9c4d3e0a75b8c1f209d4e7a3b0c5d8e912 1 src/pricing.py
100644 9c30de71a4b8f2059e6c3d81a70b4f92c5e08d36 2 src/pricing.py
100644 44ab7f0c9d21e836b5a0f47c93d1e620a8b5c704 3 src/pricing.py
$ git show :1:src/pricing.py > /tmp/base.py # база; :2: — наше (HEAD), :3: — их
$ git diff AUTO_MERGE # Git >= 2.35: только ваши правки
# поверх того, что предложил Git
$ git add src/pricing.py && git commit --no-edit
[main 5a9c3f1] Merge branch 'feature/pricing'
$ git cat-file -p HEAD
tree 8e07b41f2a95d63c0817ab4f9d2e5c06b3a71d84
parent 3ba0c17d4e91a2058c7b3f60d18a4e29c5b07f36 # ours — ПЕРВЫЙ родитель
parent 7f3a9c2b18e0d5a43c9f7126b0e8a5d3f4c21b90 # theirs — второй
AUTO_MERGE — недооценённая ссылка: это настоящее дерево с результатом автослияния (с маркерами внутри), поэтому можно спросить «что я уже поменял руками относительно предложенного». После git add три stage-записи схлопываются в одну, и коммит получает двух родителей.
Порядок родителей значим. Первый — ветка, на которой вы стояли; второй — влитая. На этом держатся git log --first-parent, git revert -m 1 и вообще понимание, «куда» всё влили.
Ни один старый коммит не изменился. Merge — операция, которая только добавляет объекты; это её главное свойство и главный аргумент в её пользу: её нельзя выполнить так, чтобы потерялись чужие данные.
Стратегии слияния: ort, recursive и остальные
ort (Ostensibly Recursive’s Twin, автор Elijah Newren) — стратегия по умолчанию с Git 2.34: тот же алгоритм, что у recursive, но работающий с деревьями в памяти вместо индекса и рабочего каталога. На больших репозиториях выигрыш в десятки раз, плюс исправлены давние баги с переименованиями и коллизиями «каталог/файл». Слово «рекурсивная» в названии предшественника — про случай, когда merge-base не одна (criss-cross merge: две ветки уже сливались друг с другом крест-накрест, и общих предков стало два, ни один не «лучше»). Тогда recursive/ort сливают сами базы между собой, получают виртуальную базу и уже её используют для трёхпутевого слияния. Проверяется одной командой:
$ git merge-base --all topicA topicB
b2c1d09f3a874e6501bd2c93f70a8e4d16c5b982
77a0e31c48b95d206ff3a1e78c4b0d95a6e2f0c1 # две базы — будет виртуальная
| Что | Когда нужно |
|---|---|
-s ours |
Записать merge-коммит, полностью проигнорировав содержимое второй ветки: формально влита, кода из неё нет |
-X ours / -X theirs |
Не путать с предыдущим: разрешает только конфликтующие хунки в пользу стороны, остальное сливает нормально |
-s subtree |
Одно дерево живёт внутри другого под префиксом — основа git subtree, см. «Монорепо» |
-s octopus |
Больше двух родителей сразу; работает только без конфликтов |
-X ignore-space-change |
Спасение при слиянии ветки, где кто-то прогнал форматтер |
-X rename-threshold=40% |
Файл переименовали и сильно поправили — детекция не сработала |
--no-commit --no-ff |
Собрать слияние, но остановиться до коммита: git diff --cached --stat, прогнать тесты и только потом git commit |
Rebase: не «перенос», а переписывание
«Rebase переносит коммиты на другую базу» — вредная метафора: она подразумевает, что объекты движутся. Объекты в Git неизменяемы. Rebase создаёт новые коммиты, содержимое которых получено применением старых изменений к новому основанию.
Пошагово для git rebase main на ветке feature/pricing (merge-бэкенд, дефолт с Git 2.26):
- Записать текущий HEAD в
ORIG_HEADи в.git/rebase-merge/orig-head. - Составить список:
git rev-list --reverse --no-merges main..feature/pricing, минус коммиты, чей patch-id уже есть вmain. Список пишется в.git/rebase-merge/git-rebase-todo. Перевести HEAD в detached-состояние наmain. - Для каждого коммита выполнить, по сути,
git cherry-pick— то есть трёхпутевое слияние, где база — родитель исходного коммита, ours — текущий HEAD, theirs — сам коммит. - Создать новый коммит: автор и дата авторства сохраняются, committer, дата коммита и родитель — новые. Дерево может оказаться буквально тем же объектом, но
parentиcommitterвходят в хеш → идентификатор другой. Когда todo пуст,refs/heads/feature/pricingпереставляется на последний созданный коммит, HEAD возвращается на ветку.
$ git rebase main
Auto-merging src/pricing.py
CONFLICT (content): Merge conflict in src/pricing.py
error: could not apply 2e18b40... feat: промокоды
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
$ cat .git/rebase-merge/git-rebase-todo # что осталось
pick 2e18b40 feat: промокоды
pick 7f3a9c2 fix: округление скидки
$ cat .git/rebase-merge/done # что уже применено
pick 6e21af0 refactor: вынести расчёт цены
Конфликт возник не на всей ветке сразу, а на конкретном коммите — это и отличает rebase от merge на практике. Состояние «rebase идёт» полностью материализовано на диске, поэтому из него всегда есть выход:
Почему конфликт повторяется несколько раз
При merge сравниваются два конечных состояния — конфликт решается один раз. При rebase каждый коммит применяется отдельно, поэтому если одна область кода менялась в трёх ваших коммитах, а на main она тоже изменилась, вас спросят трижды, причём про промежуточные состояния, которых в готовом виде никогда не существовало. Это прямое следствие механики: rebase сохраняет пошаговость и пошагово за неё платит. Лекарство встроено в Git — rerere (reuse recorded resolution):
$ git config --global rerere.enabled true
$ git config --global rerere.autoUpdate true # сразу помечать разрешённое как resolved
Recorded resolution for 'src/pricing.py'. # запомнил пару «конфликт → решение»
Resolved 'src/pricing.py' using previous resolution. # при повторе того же конфликта
Слепки лежат в .git/rr-cache/, включается один раз на всю жизнь. Пожалуй, самая недоиспользуемая полезная настройка Git: помогает и при rebase длинных веток, и при повторных слияниях долгоживущей release-ветки.
--onto и флаги, которые стоит знать наизусть
git rebase <upstream> — сокращение. Полная форма git rebase --onto <newbase> <upstream> <branch>, где upstream определяет, что исключить, а newbase — куда положить:
# ветка отпочковалась от feature/base, её уже влили в main — переносим только своё
$ git rebase --onto main feature/base feature/pricing
git rebase -i main # интерактивный: reorder, squash, edit, drop
git rebase --autosquash --autostash main # применить fixup!/squash!, спрятать грязное
git rebase --rebase-merges # сохранить структуру merge-коммитов внутри ветки
git rebase --update-refs # Git >= 2.38: подвинуть все ветки внутри стека
git rebase --exec 'pytest -q' main # прогнать тесты на КАЖДОМ коммите
--exec превращает rebase в проверку «каждый коммит ветки собирается и проходит тесты» — ровно то, что нужно команде, которая держит bisectable-историю. --update-refs (конфиг rebase.updateRefs=true) снимает главную боль stacked-PR: раньше ребейз стека из трёх веток означал три ребейза руками. Устаревший --preserve-merges объявлен deprecated и удалён из современных версий — используйте --rebase-merges.
patch-id: почему rebase «пропускает» коммиты
Иногда коммитов после rebase меньше, чем было. Причина — patch-id: нормализованный хеш самого изменения, без учёта пробелов, номеров строк и метаданных. Если коммит с таким же patch-id уже есть в upstream (ваш патч приняли через cherry-pick или по почте), rebase его не повторяет.
$ git cherry -v main feature/pricing
- 6e21af0 refactor: вынести расчёт цены # минус = по содержанию уже есть в main
+ 2e18b40 feat: промокоды # плюс = ещё нет
Управляется флагами --no-reapply-cherry-picks / --reapply-cherry-picks. Знать об этом стоит хотя бы чтобы не паниковать, когда в ветке осталось два коммита вместо трёх.
Merge и rebase рядом: что именно отличается
git merge |
git rebase |
|
|---|---|---|
| Существующие коммиты | не трогает | становятся недостижимы, вместо них новые |
| Создаётся объектов | 1 коммит (+1 дерево) | по коммиту на каждый переносимый |
| Идентификаторы | стабильны | меняются у всей ветки |
| Топология | ветвление сохранено, виден факт параллельной работы | линейная, факт параллельности утрачен |
| Конфликты | один раз, на итоговых состояниях | до N раз, на промежуточных |
| Откат целиком | git revert -m 1 <merge> |
git revert A..B или git reset |
| Автор и дата авторства | сохраняются | сохраняются |
| Committer и дата коммита | новые у merge-коммита | новые у всех коммитов ветки |
| Подписи GPG | сохраняются | ломаются, нужен -S / rebase.gpgSign |
| Чужие клоны | ничего не ломается | нужен force-push и координация |
| Комментарии ревью к строкам | сохраняются | «отваливаются»: коммитов больше нет |
Проверяется руками — и заодно вводит обязательный инструмент:
$ git range-diff main feature/pricing@{1} feature/pricing
1: 6e21af0 = 1: f10c8d3 refactor: вынести расчёт цены
2: 2e18b40 = 2: b83f1c5 feat: промокоды
3: 7f3a9c2 ! 3: 4d7e0b8 fix: округление скидки
@@ src/pricing.py: def apply_discount(total, promo):
- return round(total * (1 - promo.rate), 2)
git range-diff (Git 2.19+) сравнивает две серии патчей и показывает, что изменилось в самих изменениях: = — патч тот же, ! — патч поехал. Если после ребейза вы ожидали одни =, а видите !, значит при разрешении конфликта что-то потеряли. Запускать после каждого нетривиального rebase.
Публикация: где rebase действительно опасен
Модель данных даёт одно жёсткое правило: не переписывайте историю, которую уже забрали другие — не потому, что «rebase плохой», а потому, что чужие refs/remotes/origin/* начинают указывать на коммиты, которых больше нет в вашей ветке.
удалённая на G' → расхождение 4 и 1 B->>B: git pull → merge → каша из дублей Note over A,B: Правильно: git rebase --onto origin/feature/pricing G H
git push --force # НИКОГДА без нужды: затрёт чужой push минуту назад
git push --force-with-lease # откажется, если удалённая ссылка изменилась
# с момента вашего последнего fetch
git push --force-with-lease --force-if-includes # Git >= 2.30, ещё строже
Тонкость: --force-with-lease сравнивается с вашей локальной refs/remotes/origin/<branch>. Если в фоне работает git fetch (или автофетч из IDE), ссылка обновится — и «защита» пропустит перезапись. --force-if-includes закрывает именно эту дыру: он проверяет, что переписываемые коммиты действительно проходили через ваш reflog, и с Git 2.30 включается автоматически, когда --force-with-lease используется без явного значения. Организационное правило дешевле любого технического: ветка, на которую открыт PR и в которой начали ревью, — публичная. Rebase такой ветки уничтожает привязку комментариев к строкам, и ревьюеру придётся читать всё заново. Про оформление PR — статья «Pull Request»; базовое введение для тех, кто пришёл без подготовки, — «Git» из трека development.
Reflog: почему любую ошибку можно отменить
Раздел, ради которого стоит читать статьи про Git. Rebase не удаляет коммиты — он делает их недостижимыми из веток, а Git ведёт журнал всех перемещений каждой ссылки, и по этому журналу старые коммиты остаются достижимыми.
$ git reflog
4d7e0b8 (HEAD -> feature/pricing) HEAD@{0}: rebase (finish): returning to refs/heads/feature/pricing
4d7e0b8 HEAD@{1}: rebase (pick): fix: округление скидки
b83f1c5 HEAD@{2}: rebase (pick): feat: промокоды
3ba0c17 HEAD@{3}: rebase (start): checkout main
7f3a9c2 HEAD@{4}: checkout: moving from main to feature/pricing
$ git reflog show feature/pricing # журнал ветки — надёжнее при частых переключениях
4d7e0b8 feature/pricing@{0}: rebase (finish): onto 3ba0c17
7f3a9c2 feature/pricing@{1}: commit: fix: округление скидки
HEAD@{4} — состояние до rebase; вернуться можно через git reset --hard HEAD@{4}, git reset --hard feature/pricing@{1} или короче всего через git reset --hard ORIG_HEAD (эту ссылку rebase, merge, reset и pull выставляют сами). Тонкость синтаксиса, на которой спотыкаются все: HEAD@{4} — четвёртая запись назад в журнале; main@{yesterday} — состояние на момент времени; HEAD~4 — четвёртый предок в графе. Три разные вещи с похожим написанием.
Аварийный набор на все типовые случаи:
$ git rebase --abort # rebase идёт, всё сломалось
$ git reset --hard ORIG_HEAD # rebase закончился, результат плохой
$ git merge --abort # merge не закоммичен
$ git reset --hard HEAD~ # merge закоммичен, но не запушен
$ git revert -m 1 <merge-sha> # merge уже запушен: новый коммит-обратка
$ git fsck --full --no-reflogs --unreachable --lost-found # что висит без ссылок
unreachable commit 7f3a9c2b18e0d5a43c9f7126b0e8a5d3f4c21b90
$ git branch rescue/pricing 7f3a9c2 # спасти в новую ветку
Записи reflog для достижимых объектов живут 90 дней (gc.reflogExpire), для недостижимых — 30 (gc.reflogExpireUnreachable). Это ваше окно на восстановление, и оно настраивается. Не спасает reflog только от двух вещей: изменений, никогда не попадавших ни в коммит, ни в индекс, и чужого клона после force-push в чужой репозиторий. Всё остальное восстановимо — полный набор техник в статье «Восстановление».
Третий вариант: squash и semi-linear
$ git merge --squash feature/pricing
Squash commit -- not updating HEAD
$ git commit -m "feat: промокоды и округление скидки (#412)"
Получился один обычный коммит с одним родителем, содержащий суммарное изменение ветки; связи с исходными коммитами в графе нет. Плюсы очевидны: main линеен, одна задача — один коммит, откат тривиален. Минусы стоят дороже, чем кажется:
- merge-base не сдвигается. Git не знает, что содержимое ветки уже в
main. Продолжите работать в той же ветке — следующее слияние принесёт все старые изменения и все старые конфликты. Отсюда правило: после squash-merge ветку обязательно удалять. - Промежуточные шаги теряются. Для PR на 50 строк это не потеря. Для PR на 2000 строк это значит, что
git bisectукажет на коммит-монолит, и расследование инцидента начнётся с чтения диффа на 2000 строк. - Авторство склеивается (в
mainпопадёт один автор; GitHub частично лечит это строкамиCo-authored-by:), аgit cherryперестаёт помогать: patch-id склеенного патча не совпадёт ни с одним исходным.
Squash — отличный дефолт для команды с короткими ветками и маленькими PR и плохой для команды с ветками по две недели: редкий случай, когда инструмент прямо стимулирует правильный процесс. Компромисс между squash и merge — semi-linear history (так это называют GitLab и Azure DevOps): ветка ребейзится на актуальный main (чтобы CI проверил ровно то, что попадёт в main), а затем вливается через git merge --no-ff. Первый родитель main остаётся прямой линией, внутри задачи сохранены отдельные коммиты, а merge-коммит даёт git revert -m 1. Плата — force-push в ветку перед слиянием и требование гонять CI после ребейза, а не до. Как это встраивают в пайплайн — в «CI: основы».
Как выбрать: критерии вместо холивара
Модель данных не отдаёт предпочтения ни одной операции: merge создаёт коммит с двумя родителями, rebase — цепочку с одним, и то и другое ровно настолько же «настоящий Git». Спор поэтому и не решается техническими аргументами — он про то, чего команда хочет от истории и сколько готова за это платить.
нет PR, никто не забирал"] --> A1["rebase свободно:
чистим коммиты, ребейзим на main"] B["Ветка опубликована,
идёт ревью"] --> B1{"комментарии привязаны
к строкам коммитов?"} B1 -- да --> B2["merge origin/main в ветку
или ничего до конца ревью"] B1 -- нет --> B3["rebase + force-with-lease,
предупредив ревьюера"] C["Ветка попадает в main"] --> C1{"как долго живёт
и какого размера PR?"} C1 -- "часы, < 400 строк" --> C2["squash merge:
одна задача = один коммит"] C1 -- "дни, осмысленные коммиты" --> C3["rebase + merge --no-ff:
semi-linear"] C1 -- "релиз и hotfix" --> C4["merge --no-ff:
факт слияния важен для аудита"] D["Публичная ветка:
main, release/*"] --> D1["НИКОГДА не rebase.
Только merge/revert + защита на сервере"]
Вопросы, которые стоит задать команде вместо спора о вкусах:
Вы реально делаете git bisect и как считаете релизные ноты? Если бисектите регулярно, линейная история с проверенными коммитами (rebase --exec) окупается; если последний раз бисектили в позапрошлом году, аргумент «линейность помогает бисекту» чисто теоретический. Если ноты собираются по git log --first-parent между тегами — нужны merge-коммиты или squash; если по трекеру задач — история для этого не используется вовсе, и спор пустой.
Кто платит за конфликты и насколько уверенно команда владеет Git? При rebase конфликты разрешает автор ветки, до слияния, столько раз, сколько коммитов затронуто; при merge — один раз, но результат сразу попадает в общую ветку. Первый вариант справедливее, второй дешевле по суммарному времени. Второй вопрос не снобизм, а фактор риска: rebase-first процесс требует force-push, а force-push в незнающих руках стоит потерянного дня. Команда, где половина боится git rebase --abort, спокойнее живёт на merge-процессе; обучение это решает — но его надо провести, а не предположить.
Как долго живут ветки? Главный вопрос, и он не про merge/rebase вообще. При ветках на день разница между стратегиями — минуты в неделю; при ветках на две недели больно будет при любом выборе. Способы сокращать расхождение разобраны в «Стратегиях ветвления».
Дефолт, который работает у большинства и который стоит взять, если спорить не хочется:
Rebase внутри своей ветки, пока она не опубликована. Squash или semi-linear merge при попадании в
main. Никогда не rebase публичных веток.rerereвключён у всех,--force-with-leaseвместо--force.
Ловушка, о которой узнают поздно: revert merge-коммита
Ломает людям день примерно раз в карьеру. Вы влили фичу, она сломала прод, вы откатили:
$ git revert -m 1 5a9c3f1 # -m 1 = «оставить состояние первого родителя»
[main 8c4f0a2] Revert "Merge branch 'feature/pricing'"
Через неделю фичу починили и снова влили ветку — и получили Already up to date. либо только новые коммиты, без старых изменений. Причина строго механическая: merge-base уже включает исходное слияние, Git считает старые коммиты учтёнными, а revert отменил их отдельным коммитом. Граф говорит «слито», содержимое говорит «нет». Выхода два: git revert 8c4f0a2 (откатить откат — честнее всего, если revert был временной мерой) либо пересобрать ветку заново от текущего main через git cherry-pick 6e21af0^..7f3a9c2.
Канонический разбор написан Линусом и лежит в документации Git: How to revert a faulty merge — один из немногих текстов, где механика объясняется через граф, а не через метафоры.
Настройки, которые стоит поставить всей команде
git config --global pull.ff only # никаких мусорных merge-коммитов при pull
git config --global rebase.autoStash true
git config --global rerere.enabled true # помнить разрешённые конфликты
git config --global rerere.autoUpdate true
git config --global merge.conflictStyle zdiff3 # показывать ещё и базу (Git >= 2.35)
git config --global rebase.updateRefs true # ребейз стека веток целиком (Git >= 2.38)
git config --global rebase.gpgSign true # иначе подписи теряются при ребейзе
git config --global gc.reflogExpireUnreachable "365 days" # окно на восстановление
git config --global diff.algorithm histogram # осмысленнее диффы → меньше конфликтов
git config --global alias.pushf 'push --force-with-lease --force-if-includes'
На стороне сервера обязательный минимум — запрет force-push и удаления защищённых веток. Это единственная мера, которая превращает «не переписывать публичную историю» из пожелания в правило. Как автоматизировать проверки на стороне репозитория — в «Хуках и автоматизации».
Типичные ошибки
«Rebase удалил мою работу». Почти никогда: работа недостижима из ветки, но жива. Прежде чем паниковать — git reset --hard ORIG_HEAD.
git pull на ветке с локальными коммитами без настройки. Создаёт merge-коммит Merge branch 'main' of ..., который попадает в PR и путает ревью. Лечится pull.ff only.
Rebase публичной ветки «чтобы было чисто». Каждый, кто её забрал, получит расхождение; если уже случилось — коллеге нужен git rebase --onto origin/<branch> <старый-head> <его-ветка>, а не git pull. Force-push без --lease. Затирает чужой push, сделанный между вашим fetch и вашим push: на сервере данные ещё живы до gc, но без доступа к серверному reflog их не найти. Из той же серии — -X ours как способ «быстро разрулить»: он тихо выбрасывает чужие изменения в конфликтных хунках, без единого предупреждения.
Squash-merge и продолжение работы в той же ветке. Второе слияние принесёт всё снова. Удаляйте ветку после squash.
Ожидание, что merge «сохранит» переименования. Git не хранит переименований, он их выводит эвристикой. Файл переименовали и переписали на 70% — получите конфликт «удалён/добавлен»; иногда спасают -X rename-threshold и merge.renamelimit.
Уверенность, что линейная история — это гигиена. Линейность оплачена утратой информации о параллельной работе. Это осознанный выбор, а не чистота.
Мини-итог
- Обе команды объединяют две линии, разошедшиеся от общей базы. Базу находит
git merge-base; без неё автоматическое слияние невозможно в принципе. - Merge только добавляет: один коммит с двумя родителями, существующие объекты не трогаются. Порядок родителей значим — он даёт
--first-parentиrevert -m 1. Fast-forward же вообще не слияние, а перезапись 41 байта в файле ссылки. - Rebase переписывает: на каждый коммит делается трёхпутевое слияние и создаётся новый объект. Автор сохраняется, committer и родитель — нет, поэтому меняются идентификаторы всей ветки.
- Конфликты порождает перекрытие правок, а не выбор команды. Merge спрашивает один раз, rebase — по разу на коммит;
rerereубирает повторы. - Старые коммиты после rebase живы: reflog,
ORIG_HEAD,git fsck --lost-found, окно 30–90 дней.git range-diffпосле ребейза и--force-with-lease --force-if-includesпри push — две привычки, отделяющие уверенную работу от рулетки. - Squash — третья операция со своими свойствами: линейная история ценой потери шагов и обязательного удаления ветки.
- Выбор — про процесс: как считаете релизные ноты, делаете ли bisect, как долго живут ветки, кто платит за конфликты. «Rebase чище» и «merge честнее» одинаково бессодержательны без ответов на эти вопросы.
- Единственное правило прямо из модели данных: не переписывайте историю, которую уже забрали другие. Всё остальное — соглашение команды.
Источники
- Pro Git, гл. 3.2 «Basic Branching and Merging» и 3.6 «Rebasing» — Scott Chacon, Ben Straub.
- git-merge(1), git-rebase(1), merge-strategies(7) — раздел
MERGE STRATEGIESстоит прочитать целиком. - How to revert a faulty merge — Linus Torvalds.
- git-rerere(1), git-range-diff(1), git-reflog(1); Highlights from Git 2.34 — переход на стратегию
ort. - Eugene W. Myers, «An O(ND) Difference Algorithm and Its Variations», Algorithmica, 1986 — алгоритм в основе
git diffи трёхпутевого слияния.
Что дальше
Rebase — частный случай более общей возможности: историю в Git можно менять как угодно, потому что коммиты неизменяемы, а ссылки — нет. Следующая статья разбирает весь арсенал: amend, интерактивный rebase со всеми командами todo-листа, filter-repo для вычистки секретов и больших файлов из всей истории, и риски, которые каждая из этих операций создаёт для команды.
Переписывание истории: amend, interactive rebase, filter-repo и риски