Ветка с дюжиной коммитов вроде "fix", "опять fix" и "WIP" устроит никого, когда придёт время открывать pull request. Ревьюер вынужден угадывать логику по обрывкам истории, а команда через полгода не поймёт, зачем этот кусок кода вообще появился. Интерактивный rebase решает задачу элегантно: история собирается заново в аккуратные логические шаги, временные правки склеиваются в основные коммиты, а сообщения переписываются на человеческом языке. Плата за эту гибкость одна: переписанная история требует принудительной отправки на сервер, и вот тут спрятаны почти все подводные камни. Ниже разобран законченный workflow от черновой ветки до безопасного merge, с реальными командами, выводами и разбором типичных поломок.
Постановка задачи и подготовка рабочей ветки
Пусть работа над фичей велась в ветке feature/checkout, которая ответвилась от main. За три дня накопилось восемь коммитов, и половина из них носит служебный характер.
git log --oneline main..HEAD
a5e8c1d WIP
f3b9a02 fix typo
7d41e55 fixup! add form validation
9c02b18 add form validation
e81d4fa review fixes
b37c990 add checkout page
02a6c3e fix tests
1d9f7ad add cart totals calculation
Читается вывод снизу вверх: самый старый коммит внизу. Видно, что работа строилась из трёх логических единиц (расчёт корзины, страница оформления, валидация формы), а остальное - песок: опечатки, правки после ревью, незаконченное состояние. Перед rebase полезно проверить, что незакоммиченных изменений нет, командой git status, и обновить main, чтобы сращиваться с актуальной базой:
git fetch origin
git merge-base --fork-point origin/main
Если ветка расходилась с main, интерактивный rebase удобно совместить с перевесом на свежий основной код: тогда в один приём чистится и история, и расхождение. Разбор этих коммитов - разовая работа; дальше решения уже принимает редактор todo-листа.
Запуск интерактивного сеанса и команды todo-листа
Точка входа проста:
git rebase -i HEAD~8
Аргумент HEAD~8 означает "восемь коммитов от текущей вершины". Альтернативная форма, часто надёжнее, потому что не заставляет считать:
git rebase -i $(git merge-base HEAD origin/main)
Так Git сам начнёт сеанс от точки ветвления, даже если коммитов двадцать. После запуска открывается редактор с планом действий:
pick 1d9f7ad add cart totals calculation
pick 02a6c3e fix tests
pick b37c990 add checkout page
pick e81d4fa review fixes
pick 9c02b18 add form validation
pick 7d41e55 fixup! add form validation
pick f3b9a02 fix typo
pick a5e8c1d WIP
# Rebase 3f81a7c..a5e8c1d onto 3f81a7c (8 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like squash, but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# d, drop <commit> = remove commit
Здесь порядок строк - порядок применения, от старого к новому. Ключевые операции: pick оставляет коммит как есть; squash вливает его в предыдущий и предлагает объединить сообщения; fixup делает то же самое, но молча выбрасывает сообщение вклеенного коммита, что идеально для строк вроде "fix typo"; reword оставляет изменения кода, но просит переписать описание; drop удаляет коммит совсем. Разница squash против fixup тонкая, но практическая: при большом количестве мелких правок окно объединения сообщений каждый раз отвлекает, поэтому fixup экономит нервы. Коммит WIP в конце списка проще выкинуть через drop, а нужные кусочки, если они там были, вытащить заранее через edit.
Workflow с ручной склейкой и reword сообщений
Переписываем план под три итоговых коммита с говорящими описаниями:
pick 1d9f7ad add cart totals calculation
fixup 02a6c3e fix tests
reword b37c990 add checkout page
fixup e81d4fa review fixes
pick 9c02b18 add form validation
fixup 7d41e55 fixup! add form validation
fixup f3b9a02 fix typo
drop a5e8c1d WIP
Сохранение файла запускает машинерию: Git по очереди применяет коммиты, на строке с reword останавливается и открывает редактор сообщения. Если в середине обнаружился конфликт, например e81d4fa правил те же строки, что и b37c990, разбор идёт как обычно: git status показывает помеченные файлы, после правки выполняется git add и git rebase --continue. Прервать целиком на любом шаге позволяет git rebase --abort, который возвращает всё в состояние до сеанса. Это важное психологическое страхование: экспериментировать с порядком строк не страшно, откат бесплатный. По завершении смотрим результат:
git log --oneline main..HEAD
9e1b45f add form validation on checkout
2c8d07a add checkout page with order summary
51c3a90 add cart totals calculation
Три чистых коммита вместо восьми рваных. Хеши изменились полностью, потому что пересчиталась вся цепочка: у новых коммитов другие родители и метаданные. Это переписанная история в прямом смысле слова, и её опасная сторона раскроется дальше именно в вопросе публикации.
Автоматическая склейка через autosquash и fixup коммиты
Ручная расстановка действий в редакторе хороша на восьми коммитах, но на тридцати превращается в однообразное кликанье. Git предлагает конвейер: в процессе работы мелкие правки оформляются специальными командами:
git add -p
git commit --fixup 9c02b18
Команда создаёт коммит вида "fixup! add form validation", привязанный к указанному хешу. Существует и git commit --squash <hash> для случаев, когда сообщение вклейки хочется сохранить. Затем:
git rebase -i --autosquash main
С опцией --autosquash редактор откроется уже с расставленными fixup и squash строками, причём коммиты-прицепы автоматически перемещаются сразу за своих родителей. Остаётся только сохранить файл и закрыть редактор. Ещё удобнее:
git rebase -i --autosquash --autostash main
Опция --autostash автоматически прячет незакоммиченные изменения перед rebase и возвращает их после, что снимает частую ошибку "Cannot rebase: You have unstaged changes". Чтобы autosquash работал по умолчанию, достаточно конфига:
[rebase]
autosquash = true
autostash = true
[commit]
verbose = true
Правило "не оставлять fixup коммиты без --autosquash" полезное: без флага такие коммиты просто лягут в историю со странными названиями вроде "fixup! fixup! fixup! add validation". Честно говоря, связка --fixup плюс --autosquash - самая недооценённая пара в практике: правки после ревью перестают плодить мусор, а журнал остаётся читаемым без ручной возни.
Почему переписывать опубликованную историю опасно
Переписанные коммиты имеют новые хеши, а это значит, что старые хеши, которые уже лежат на сервере и у коллег в локальных копиях, превращаются в отдельное ответвление. Если ветку feature/checkout уже кто-то вытянул и строил поверх неё свою работу, принудительная замена истории на сервере ломает им базу. После такого коллега выполняет git pull и получает либо странный merge самого себя с самим собой, либо каскад конфликтов: его коммиты ссылаются на объекты, которых в новой истории больше не существует.
По сути правило одно и оно простое: переписывать историю можно ровно до тех пор, пока её видите только вы. Локальная ветка - полная свобода. Ветка, отправленная на сервер, но заведомо личная и помеченная договорённостью "не pullить", обычно тоже безопасна. Общая ветка, над которой работают хотя бы двое, а тем более main - территория без rebase: там правят только новыми коммитами, иногда через git revert. Последствия нарушения не теоретические: замена опубликованной истории без предупреждения - классический способ зарасти недовольными тикетами и потерять доверие команды.
Остановка на edit и разбор конфликтов в середине сеанса
Отдельного упоминания заслуживает команда edit в todo-листе. Она полезна, когда один жирный коммит хочется разделить на два или когда внутри сеанса нужно подправить сам код, а не только сообщение. Git останавливается на такой строке, рабочее дерево оказывается ровно в том состоянии, и дальше работают привычные инструменты: git reset HEAD~1 мягко распаковывает коммит обратно в рабочее дерево, правки раскладываются по новым коммитам через git add -p, затем git rebase --continue продолжает план. Так можно вытащить из монолитного коммита случайно закоммиченный отладочный лог или, наоборот, забытый файл.
Конфликты при rebase отличаются от merge-конфликтов направлением мысли. При merge вы видите "нашу" и "ихнюю" стороны одновременно; при rebase конфликт возникает на каждом переносимом коммите по очереди, и признаки ours и theirs выглядят зеркально против привычного. Вывод конфликта типичен:
Auto-merging src/checkout/form.js
CONFLICT (content): Merge conflict in src/checkout/form.js
error: could not apply e81d4fa... review fixes
hint: Resolve all conflicts manually, mark them as resolved with
hint: git add/rm <conflicted_files>, then run git rebase --continue.
Первая строка сообщает, что файл обрабатывался трёхсторонним слиянием; вторая фиксирует текстовый конфликт; третья называет виновника, коммит e81d4fa; две подсказки напоминают последовательность действий. После правки файлов и git add сеанс продолжается, причём git rebase --skip выбрасывает конфликтный коммит, если он уже учтён склейкой. Опытный приём на длинных цепочках: включить git config --global rerere.enabled true, и Git будет запоминать разобранные конфликты, автоматически применяя то же решение при повторных rebase. На ветках, которые перебазируются на main ежедневно, rerere экономит заметную долю терпения.
Принудительная отправка через force with lease и типичные ошибки
После успешного rebase обычный git push откажется: сервер видит, что история не является прямым продолжением. Нужен принудительный вариант, и тут у Git два флага с принципиально разной философией.
git push --force-with-lease origin feature/checkout
Опция --force-with-lease проверяет, что удалённая вершина ветки всё ещё совпадает с той, которую клиент видел в последний fetch. Если коллега за это время успел запушить свои коммиты, сервер ответит отказом:
! [rejected] feature/checkout -> feature/checkout (stale info)
error: failed to push some refs to Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript. :repo.git'
hint: Updates were rejected because the remote contains work that you do not have locally.
Такой отказ - подарок: он предотвращает молчаливое затира чужой работы. Сравните с поведением git push --force: этот флаг затирает удалённую вершину без всяких вопросов. Если два человека одновременно правили одну ветку (пусть и по ошибке), второй принудительный push уносит чужой час работы в недра reflog на сервере, откуда выковыривать объекты придётся вручную. По сути --force-with-lease это --force с сигнализацией, и в командах, где переписывание своих веток перед merge - норма, его заворачивают в алиасы:
git config --global alias.pf "push --force-with-lease"
Типичные ошибки всего workflow держатся на нескольких граблях, и большинство встречается стабильно:
- Запуск rebase не от точки ветвления, а от HEAD~N при неверном N, тогда в план попадают чужие коммиты из main и история портится посторонними правками;
- Склейка опубликованных коммитов вместе с локальными, после чего push требует force и коллеги получают расхождение веток;
- Использование git push --force по привычке вместо --force-with-lease, особенно когда локальное представление об удалённой ветке устарело;
- Оставление коммитов fixup! в истории без --autosquash, из-за чего журнал засоряется служебными названиями;
- Запуск интерактивного rebase с грязным рабочим деревом и без --autostash, что выливается в отказ на первом же шаге;
- Попытка переписать ветку после её merge в другие ветки, когда удалить такие коммиты чисто уже невозможно.
Когда что-то пошло не так, первый инструмент спасения - git reflog. Журнал ссылок помнит каждое положение HEAD за последние девяносто дней, и вернуть состояние до rebase можно одной командой git reset --hard HEAD@{1} с нужным индексом из вывода reflog. Профилактика предсказуемая: перед rebase делать страховочную ветку git branch backup/feature-checkout, работать по правилу "сначала fetch, потом push --force-with-lease", включить rebase.autosquash в глобальном конфиге и договориться в команде, что общие ветки не переписываются никогда. При такой дисциплине интерактивный rebase перестаёт быть источником страха и превращается в то, чем он и должен быть, - в обычную ручную наводку порядка перед тем, как работа уйдёт в общую историю.