Базовый rebase: что делает каждый шаг
git rebase main на feature-ветке — звучит как одна команда, но под капотом Git проделывает несколько операций последовательно. Понимание этого механизма ключевое: оно объясняет, почему конфликты при rebase разрешаются по-другому, почему меняются SHA, и почему “потерянные” коммиты можно восстановить через reflog.
В этом уроке разбираем механику базового rebase — без interactive (-i), без советских флагов, просто git rebase <branch>.
Алгоритм по шагам
Когда ты делаешь git rebase main на ветке feature/x, Git выполняет такую процедуру:
Распишем подробнее каждый шаг.
Шаг 1: найти merge base
Git ищет общего предка двух веток. Для нашего сценария: feature/x отошла от main неделю назад в коммите C0. Это merge base.
Можно посмотреть руками:
$ git merge-base feature/x main
c0c0c0c1b2a3...
Шаг 2: собрать коммиты feature/x
Git собирает список коммитов в feature/x, которых нет в main:
$ git log --oneline main..feature/x
abc1234 F3: tests
def5678 F2: implementation
9876543 F1: scaffold
Это 3 коммита, которые надо перебазировать.
Шаг 3: reset на верхушку main
Git делает git checkout на HEAD ветки main — то есть переключает working tree на состояние M3. Внутренне это detached HEAD:
HEAD -> M3 (на main)
feature/x -> пока остаётся на F3 (старая позиция, до неё мы дотянемся в конце)
Шаг 4: применить коммиты по одному
Это сердце rebase. Git берёт первый коммит из списка (F1), применяет его на текущий HEAD как cherry-pick:
Применяет F1 на M3 -> создаёт F1' (новый SHA)
HEAD -> F1'
Потом F2 поверх F1’, потом F3 поверх F2’:
HEAD -> F3' -> F2' -> F1' -> M3 -> M2 -> M1 -> C0
Каждый коммит применяется как git cherry-pick. Это значит:
- Берётся diff коммита (что он изменяет).
- Diff применяется к текущему HEAD.
- Создаётся новый коммит с тем же сообщением и автором, но с новым SHA, parent, и (если содержимое изменилось) tree.
Шаг 5: переместить feature/x на новый top
После того как все коммиты применены, Git двигает указатель feature/x с F3 на F3’. Старые F1, F2, F3 теперь “сироты” — не указывает на них ни одна ветка.
$ git log --oneline
fff8888 F3' (HEAD -> feature/x)
eee7777 F2'
ddd6666 F1'
m3m3m3m M3
m2m2m2m M2
m1m1m1m M1
c0c0c0c init
История стала линейной. F1’, F2’, F3’ — это твои изменения, но с новыми SHA, висящие после M3.
Что значит “новые SHA”
Каждый Git коммит — это объект, hash которого вычисляется из:
- Tree (snapshot файлов).
- Parent (родительский коммит).
- Author / committer.
- Message.
- Timestamp.
При rebase меняется parent (был C0, стал M3 или F1’/F2’). Этого достаточно, чтобы SHA полностью изменился. Даже если изменения в файлах остались идентичными, SHA — новый.
# Старый F1 (до rebase)
9876543 F1: scaffold
└── parent: c0c0c0c (C0)
└── tree: <unchanged>
# Новый F1' (после rebase)
ddd6666 F1: scaffold
└── parent: m3m3m3m (M3) ← поменялся parent
└── tree: <unchanged> ← но содержимое то же
Если ты до rebase запушил ветку feature/x на сервер с SHA 9876543, а после rebase сделал ещё git push — сервер ругнётся “non-fast-forward”. Потому что локально у тебя ddd6666, а на сервере 9876543, и они не связаны линейно. Решение — git push --force-with-lease (об этом в уроке “golden rule”).
Где старые коммиты
Старые F1, F2, F3 не удалены сразу. Они остаются в .git/objects/ без указателей. Git хранит их минимум 30 дней (или 90 — зависит от настройки gc.reflogExpire).
Найти их можно через reflog:
$ git reflog feature/x
ddd6666 (HEAD -> feature/x) feature/x@{0}: rebase (finish): returning to refs/heads/feature/x
ddd6666 feature/x@{1}: rebase (pick): F1: scaffold
m3m3m3m feature/x@{2}: rebase (start): checkout main
abc1234 feature/x@{3}: commit: F3: tests ← старый F3!
def5678 feature/x@{4}: commit: F2: implementation
9876543 feature/x@{5}: commit: F1: scaffold
Если ты испортил rebase, можно восстановить старое состояние:
git reset --hard feature/x@{3} # вернёт к старому F3
Это спасательный круг. Подробно reflog в модуле 10 курса.
Конфликты при rebase
В отличие от merge, где конфликт возникает один раз для всего сводного результата, при rebase конфликт может возникать для каждого коммита. Git проигрывает коммиты по очереди, и если на коммите F2 возник конфликт — Git останавливается.
$ git rebase main
Auto-merging src/etl.py
CONFLICT (content): Merge conflict in src/etl.py
error: could not apply def5678... F2: implementation
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".
Что делать:
- Разрешить конфликт в файле (как мы учились в Module 06).
git add <file>— отметить разрешённым.git rebase --continue— Git закроет F2’, применит следующий коммит (F3).
Если хочешь отменить весь rebase и вернуть всё как было:
git rebase --abort
Если хочешь пропустить проблемный коммит (потерять его):
git rebase --skip
git rebase --skip — опасная команда. Она выкидывает текущий коммит из rebase. Только если ты на 100% уверен, что коммит больше не нужен (например, его изменения уже есть в новой базе). Иначе ты теряешь работу.
Конфликты по одному имеют плюс и минус. Плюс: каждый конфликт меньше по объёму, легче понять контекст. Минус: если у тебя 10 коммитов и в каждом конфликт — это 10 циклов резолва, дольше чем один большой merge.
Полезные флаги rebase
--onto: новая база, другой источник
Иногда нужно “пересадить” коммиты с одной ветки на другую без захвата лишних коммитов.
Сценарий: ты сделал ветку feature от develop, потом feature от feature (то есть feature/sub). Хочешь, чтобы feature/sub основывалась прямо на develop, а не на feature.
git rebase --onto develop feature feature/sub
Читается так: “взять коммиты, которые есть в feature/sub, но не в feature, и переставить на develop”.
Это продвинутая операция, в быту нужна редко, но знай о ней.
--rebase-merges
По дефолту rebase сплющивает merge commits — берёт только их diff и применяет как обычный коммит. Иногда хочется сохранить merge commits в перебазируемой ветке:
git rebase --rebase-merges main
В быту тоже редкость. Используется в очень структурированных репозиториях.
-i (interactive)
Это отдельная история — следующий урок целиком посвящён interactive rebase.
Стандартный workflow с базовым rebase
Типичный день junior’а:
# Утром: подтянуть main
git switch main
git pull --rebase
# Создать feature ветку
git switch -c feature/add-spark-job
# Работать, коммитить
echo "code" > src/job.py
git add . && git commit -m "feat: add spark job"
# Ещё коммит
echo "test" > tests/test_job.py
git add . && git commit -m "test: add unit test"
# Через час — коллеги напушили в main, хочу подтянуть
git fetch origin
git rebase origin/main
# (возможно конфликты — разрешить)
# Запушить ветку — первый раз с -u, потом просто push
git push -u origin feature/add-spark-job
# Перед PR — ещё раз rebase, чтобы поверх свежего main
git fetch origin
git rebase origin/main
# Теперь нужен force-with-lease, потому что переписал history
git push --force-with-lease
Это идеальный flow — линейная история, чистая ветка, удобный PR.
Сравни: тот же сценарий через merge
git switch feature/add-spark-job
git fetch origin
git merge origin/main
# (возможно конфликты — разрешить один раз)
git push # без force, потому что merge не переписывает history
Преимущества merge:
- Не нужен force-push, проще для джуна.
- Конфликты разрешаются один раз.
Недостатки merge:
- В feature-ветке появляются merge commits “Merge branch ‘main’ into feature/x”.
- История становится ёлкой, особенно если несколько раз так синхронизируешься.
Многие команды решают: до открытия PR — rebase, после открытия PR — merge (чтобы не ломать review).
Попробуй сам
mkdir basic-rebase-demo && cd basic-rebase-demo
git init -b main
# Базовый коммит
echo "initial" > file.txt
git add . && git commit -m "C0 init"
# Симулируем работу в main
for i in 1 2 3; do
echo "main line $i" >> file.txt
git commit -am "M$i"
done
# Делаем feature от C0
git switch -c feature HEAD~3
# Несколько коммитов в feature
echo "feature 1" > feature.txt
git add . && git commit -m "F1"
echo "feature 2" >> feature.txt
git commit -am "F2"
echo "feature 3" >> feature.txt
git commit -am "F3"
# Посмотрим SHA F1 F2 F3 до rebase
git log --oneline feature
# Rebase
git rebase main
# Посмотрим SHA после — другие!
git log --oneline feature
# Посмотрим reflog — старые коммиты ещё там
git reflog feature
# Если бы хотели вернуться:
# git reset --hard feature@{4} ← но не будем
Идемпотентность: перезапустить пайплайн безопасно