Git и командная работа Merge и rebase: как работают на самом деле и что выбрать команде
0%

Merge и rebase: как работают на самом деле и что выбрать команде

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 на самом деле

Ключевое — что остаётся при конфликте. 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 создаёт новые коммиты, содержимое которых получено применением старых изменений к новому основанию.

Rebase создаёт новые объекты; старые остаются в базе и держатся reflog

Пошагово для git rebase main на ветке feature/pricing (merge-бэкенд, дефолт с Git 2.26):

  1. Записать текущий HEAD в ORIG_HEAD и в .git/rebase-merge/orig-head.
  2. Составить список: git rev-list --reverse --no-merges main..feature/pricing, минус коммиты, чей patch-id уже есть в main. Список пишется в .git/rebase-merge/git-rebase-todo. Перевести HEAD в detached-состояние на main.
  3. Для каждого коммита выполнить, по сути, git cherry-pick — то есть трёхпутевое слияние, где база — родитель исходного коммита, ours — текущий HEAD, theirs — сам коммит.
  4. Создать новый коммит: автор и дата авторства сохраняются, 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/* начинают указывать на коммиты, которых больше нет в вашей ветке.

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 линеен, одна задача — один коммит, откат тривиален. Минусы стоят дороже, чем кажется:

  1. merge-base не сдвигается. Git не знает, что содержимое ветки уже в main. Продолжите работать в той же ветке — следующее слияние принесёт все старые изменения и все старые конфликты. Отсюда правило: после squash-merge ветку обязательно удалять.
  2. Промежуточные шаги теряются. Для PR на 50 строк это не потеря. Для PR на 2000 строк это значит, что git bisect укажет на коммит-монолит, и расследование инцидента начнётся с чтения диффа на 2000 строк.
  3. Авторство склеивается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». Спор поэтому и не решается техническими аргументами — он про то, чего команда хочет от истории и сколько готова за это платить.

Вопросы, которые стоит задать команде вместо спора о вкусах:

Вы реально делаете 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 честнее» одинаково бессодержательны без ответов на эти вопросы.
  • Единственное правило прямо из модели данных: не переписывайте историю, которую уже забрали другие. Всё остальное — соглашение команды.

Источники

Что дальше

Rebase — частный случай более общей возможности: историю в Git можно менять как угодно, потому что коммиты неизменяемы, а ссылки — нет. Следующая статья разбирает весь арсенал: amend, интерактивный rebase со всеми командами todo-листа, filter-repo для вычистки секретов и больших файлов из всей истории, и риски, которые каждая из этих операций создаёт для команды.

Переписывание истории: amend, interactive rebase, filter-repo и риски

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов