Что физически делает git rebase
Слово "перебазирование" буквально про это: rebase меняет базу ветки. База - это коммит, от которого ветка начала расходиться с другой (общий предок). Merge соединяет две линии новым коммитом. Rebase поступает иначе: он берёт твои коммиты, отвязывает их от старой базы и заново прикладывает поверх новой.
Ключевое слово - "заново". Коммит в git неизменяем: его SHA-1 (а в новых репозиториях с Git 2.51 - SHA-256) считается от содержимого дерева, родителя, автора, времени и сообщения. Меняешь родителя - меняется хеш. Поэтому rebase не двигает старые коммиты, он создаёт новые с тем же содержимым изменений (патчем), но другим родителем, и поэтому другим SHA. Старые коммиты остаются висеть в объектной базе сиротами, пока их не уберёт сборка мусора.
Разберём на графе. Легенда простая: кружок-узел - коммит, стрелка - связь коммит->родитель, ярлык - ветка или HEAD.
Код: Выделить всё
До rebase:
A---B---C feature
/
D---E---F---G main
После git switch feature; git rebase main:
A'--B'--C' feature
/
D---E---F---G main
Механически rebase делает примерно следующее: вычисляет список коммитов, которые есть в feature, но нет в main (git log main..feature), запоминает их патчи по порядку, перематывает feature на main, а потом проигрывает патчи один за другим как git cherry-pick. С Git 2.34 за это отвечает merge-движок ort - тот же, что в обычном merge, так что трёхстороннее применение патча умное, а не тупой текстовый diff.

git merge vs rebase: главный спор и где правда
"git merge vs rebase" - один из топовых вечных холиваров. Сразу скажу: правильного ответа "всегда так" нет, есть контекст. Разложим честно.
Merge сохраняет историю как она была. Каждый merge-коммит - это документ: "вот тут ветка X влилась в main, вот её два родителя". Ты видишь, что фича разрабатывалась параллельно три дня. Ничего не переписывается, существующие коммиты неприкосновенны. Минус - граф со временем превращается в спагетти: десятки merge-коммитов, параллельные дорожки, и git log без флагов читается тяжело.
Rebase даёт линейную историю git: одна прямая линия, git log читается как changelog, git bisect по ней работает идеально (нет merge-коммитов, в которых непонятно, что именно сломалось). Минус - ты переписываешь историю. Коммиты получают новые хеши, реальная хронология теряется (твои коммиты выглядят так, будто написаны после чужих, хотя физически были раньше).
Философски разница вот в чём. Merge отвечает на вопрос "что произошло на самом деле". Rebase отвечает на вопрос "как должна читаться история, чтобы её было удобно изучать потом". Первое - про точность протокола, второе - про чистоту нарратива. Команды выбирают по вкусу и по тому, важна ли им археология или читабельность.
Практический компромисс, который прижился в индустрии: rebase локально, merge публично. Свою личную ветку перед вливанием причёсываешь rebase'ом (она ещё никому не нужна), а в общий main вливаешь через merge или squash. Это подводит нас к самому важному.
Золотое правило rebase: не трогай опубликованную историю
Запиши это и повесь над столом.
Почему это смертельно? Rebase меняет хеши. Хеш - это идентичность коммита для всего git. Допустим, ты запушил ветку feature с коммитами A, B, C. Коллега сделал git fetch и завязал на C свою работу - коммит D с родителем C. Теперь ты передумал, сделал rebase, и у тебя появились A', B', C' с новыми хешами. Ты делаешь force-push. На сервере теперь C', а старого C нет в ветке.Никогда не делай rebase коммитов, которые ты уже опубликовал и которыми пользуется кто-то ещё.
Что у коллеги? Его D всё ещё указывает на исчезнувший C. Когда он сделает pull, git увидит две расходящиеся истории - твою новую (C') и его старую (C->D) - и предложит их слить. Коллега, не разобравшись, сольёт. И в историю вернутся старые A, B, C, которые ты выкинул, плюс дубликаты-двойники A', B', C'. Каждое изменение теперь присутствует дважды. Это и есть "клоны разъезжаются": репозитории команды расходятся, и собрать их обратно - боль на полдня.
Граница простая. Пока коммиты живут только в твоём локальном .git и нигде не опубликованы - rebase сколько хочешь. Как только запушил в ветку, на которую кто-то может опереться, - руки прочь, только merge поверх.
Есть нюанс с "своей" feature-веткой в PR, которую видишь только ты. Технически она опубликована, но раз на неё никто не завязан, rebase допустим - но force-push делай только через --force-with-lease, а не голым --force:
Код: Выделить всё
git push --force-with-lease origin feature
git rebase --onto: хирургическая пересадка истории
Обычный git rebase main переносит всё, что есть в твоей ветке сверх main. Но иногда нужно перенести не всё, а кусок, и поставить его в конкретное место. Для этого есть git rebase onto - самая мощная и недопонятая форма.
Синтаксис: git rebase --onto НОВАЯ_БАЗА СТАРАЯ_БАЗА [ВЕТКА]. Читается так: "возьми коммиты после СТАРАЯ_БАЗА (не включая её саму) вплоть до ВЕТКА и пересади их на НОВАЯ_БАЗА".
Классический сценарий. Ты отвёл ветку topic от ветки feature, а feature - от main. Потом понял, что topic к feature отношения не имеет, её надо повесить прямо на main, выкинув коммиты feature из-под неё.
Код: Выделить всё
H---I---J topic
/
E---F---G feature
/
A-B-C-D main
Код: Выделить всё
git rebase --onto main feature topic
Код: Выделить всё
H'--I'--J' topic
/
A-B-C-D main
Код: Выделить всё
$ git rebase --onto main feature topic
Successfully rebased and updated refs/heads/topic.
Код: Выделить всё
git rebase --onto A B D
Запомни мантру для --onto: куда, откуда-не-включая, что. Три аргумента в этом порядке, и магия становится понятной.
Конфликты при rebase: continue, skip, abort
Rebase проигрывает твои патчи по одному поверх новой базы. Если патч не ложится чисто - rebase останавливается ровно на проблемном коммите и отдаёт тебе руль. Это не катастрофа, это нормальная часть процесса. Вывод выглядит так:
Код: Выделить всё
$ git rebase main
Auto-merging service.py
CONFLICT (content): Merge conflict in service.py
error: could not apply 4f2a1c9... add retry logic
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".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
- git rebase --continue - после того как ты открыл файл, убрал маркеры <<<<<<< ======= >>>>>>>, оставил правильный код и сделал git add. Git доделывает текущий коммит и идёт к следующему патчу. Это твой основной путь.
- git rebase --skip - выбросить текущий коммит совсем. Используй осознанно: если изменение уже присутствует в новой базе (например, тот же фикс уже влили в main), патч становится пустым, и skip - правильный ход. Случайный skip = потеря изменений.
- git rebase --abort - паническая кнопка. Откатывает всё к состоянию до rebase, ветка возвращается на старую базу как будто ничего не было. Если запутался в конфликтах - не геройствуй, жми abort и подумай.
Где ты сейчас в процессе - всегда видно. git status во время rebase честно пишет "interactive rebase in progress" и сколько коммитов осталось.
git pull --rebase и --ff-only: тонкости, обещанные ранее
Теперь про pull - команду, которую все используют и почти никто не настроил правильно. git pull - это не одна операция, а две: git fetch (скачать чужие коммиты) плюс что-то, чтобы их пристыковать. И вот это "что-то" по умолчанию - merge. А значит, дефолтный pull плодит мелкие merge-коммиты "Merge branch main of origin..." каждый раз, когда твоя локальная ветка разошлась с удалённой. Это мусор, который замусоривает историю.
Есть три режима, и ты обязан выбрать осознанно.
Код: Выделить всё
git pull --rebase # fetch + rebase: твои локальные коммиты переедут поверх чужих
git pull --ff-only # fetch + только перемотка, никаких слияний
git pull --no-rebase # fetch + merge (старый дефолт, плодит merge-коммиты)
--ff-only (fast-forward only) - самый строгий и безопасный. Он соглашается только на перемотку: если у тебя нет своих локальных коммитов и ветку можно просто продвинуть вперёд - ок. А если истории разошлись - git откажется и заставит тебя явно решить, rebase или merge. Вывод при расхождении:
Код: Выделить всё
$ git pull --ff-only
fatal: Not possible to fast-forward, aborting.
Настрой раз и навсегда в глобальном конфиге. Рекомендую такую связку:
Код: Выделить всё
git config --global pull.rebase true # pull всегда ребейзит
git config --global pull.ff only # но если ребейз не нужен - только fast-forward
git config --global rebase.autoStash true # спрятать незакоммиченное на время rebase
Линейная история: за и против, и когда merge честнее
Линейная история git - это красиво и удобно: git log, git bisect, git blame работают как часы, ревью читается по коммитам. Многие команды настраивают сервер на rebase-merge или squash-merge для PR, чтобы main был идеально прямым.
Но у медали есть оборот. Rebase врёт о времени: коммиты, написанные в понедельник, после rebase в пятницу выглядят как пятничные, поставленные после чужих изменений. Если важна точная археология ("в каком порядке реально велась работа", "когда именно слили релизную ветку") - merge честнее, потому что merge-коммит фиксирует факт слияния и обоих родителей навсегда.
Когда merge честнее rebase:
- Слияние долгоживущих веток (release, develop в main) - тут merge-коммит это значимая веха, а не шум.
- Когда на ветку уже завязались другие - золотое правило, rebase запрещён.
- Когда важна аудируемость: кто, когда, что именно влил - регуляторика, безопасность.
Мини-лаба: пощупай rebase и --onto руками
Десять минут, всё локально, ничего не пушим. Повтори по шагам.
Код: Выделить всё
# 1. Песочница
mkdir rebase-lab && cd rebase-lab
git init -b main
git config user.email you@example.com
git config user.name You
# 2. Базовая история на main
echo base > file.txt && git add . && git commit -m "base"
echo more >> file.txt && git commit -am "main work"
# 3. Ветка feature с двумя своими коммитами
git switch -c feature
echo feat1 > feat.txt && git add . && git commit -m "feat 1"
echo feat2 >> feat.txt && git commit -am "feat 2"
# 4. Тем временем main ушёл вперёд
git switch main
echo newmain >> file.txt && git commit -am "new main commit"
# 5. Посмотри вилку
git log --oneline --graph --all
# 6. Перебазируй feature на свежий main
git switch feature
git rebase main
git log --oneline --graph --all # теперь прямая линия
# 7. Поиграй с --onto: отведи topic от feature
git switch -c topic
echo t1 > t.txt && git add . && git commit -m "topic 1"
# пересади topic прямо на main, убрав коммиты feature из-под него
git rebase --onto main feature topic
git log --oneline --graph --all
Типичные грабли
- Force-push голым --force в общую ветку - классика разрушения. Всегда --force-with-lease, и только в своих ветках.
- Rebase ветки, на которую кто-то завязан - нарушение золотого правила, дубли коммитов у всей команды. Сначала спроси, не отвёл ли кто-то от тебя ветку.
- Паника при конфликте и хаотичный --skip - skip выбрасывает коммит. Если не уверен - --abort и начни заново на свежую голову.
- Путаница в аргументах --onto - порядок "куда откуда что", и "откуда" не включается. Перечитай мантру.
- Дефолтный pull без настройки - тихо плодит merge-коммиты. Поставь pull.rebase=true и pull.ff=only один раз.
- Почему после rebase у коммитов меняются хеши, хотя изменения в коде те же самые?
- Сформулируй золотое правило rebase и объясни, что конкретно сломается у коллеги, если его нарушить.
- У тебя ветка topic отведена от feature, а нужно повесить её прямо на main без коммитов feature. Какая команда и в каком порядке аргументы?
- Чем git pull --ff-only отличается от git pull --rebase и в какой ситуации каждый из них уместен?
Rebase пересаживает твои коммиты на новую базу, создавая новые коммиты с новыми хешами и линейную историю. Merge сохраняет правду о том, что было, ценой ветвистого графа. Выбор - это спор "точность против читабельности", и ответ зависит от контекста: rebase локально и приватно, merge публично и для долгих веток. --onto даёт хирургическую пересадку любого куска истории по схеме "куда, откуда-не-включая, что". Pull настрой раз: pull.rebase=true плюс pull.ff=only, и больше никаких случайных merge-коммитов. И главное - золотое правило: опубликованную историю не ребейзят. Запомнишь только это - уже сбережёшь команде кучу нервов.