voidlearn
 

3.3Rebase

Проследишь, как rebase повторно записывает коммиты поверх нового основания, меняя их hash. Сравнишь merge, rebase и squash, а также применишь abort и --onto для переноса части ветки.

Что сможешь объяснить после этой главы
  • Что git rebase main делает с коммитами ветки и почему патч прежний, а хеш новый.
  • Где rebase останавливается и как вернуть ветку через --abort и ORIG_HEAD.
  • Как пересадить часть ветки через --onto и перенести стопку веток одной командой.
  • Как склеить, переименовать и выбросить коммиты, вклеить правку через --fixup.
  • Что станет с историей, bisect и revert после merge, rebase и squash-merge.

Rebase записывает коммиты заново

У этой главы свой демо-репозиторий, Go-сервис shop. Аня ответвила retry от коммита «Добавить хранилище заказов» и сделала в ней три коммита. Боря тем временем добавил в main два своих, и первый из них поменял сигнатуру Store.Find:

$ git switch retry
Switched to branch 'retry'
$ git log --oneline --graph main retry
* 1ee0b4e (main) Описать запуск в README
* f178e64 Передавать контекст в хранилище
| * c357ae4 (HEAD -> retry) Искать заказ с повтором
| * 5058e4c Не ждать после последней попытки
| * e0377f6 Повторять запрос при ошибке
|/
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop

git rebase main переносит коммиты текущей ветки на вершину main:

$ git rebase main
Successfully rebased and updated refs/heads/retry.
$ git log --oneline --graph main retry
* 48b7729 (HEAD -> retry) Искать заказ с повтором
* cb6bb38 Не ждать после последней попытки
* 67bce9b Повторять запрос при ошибке
* 1ee0b4e (main) Описать запуск в README
* f178e64 Передавать контекст в хранилище
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop

Внутри четыре шага. Git составляет список коммитов из main..retry, то есть после merge-base 3b31e8b (как её ищут, сказано в главе 3.1). Отсоединяет HEAD на 1ee0b4e. По очереди применяет изменения каждого коммита: вычисляет их из пары «коммит и его родитель» и сливает с текущим снимком, как cherry-pick. В конце ставит retry на последний новый коммит и возвращает на неё HEAD. Старое с новым сравнивает git range-diff, где = значит «патч не изменился»:

$ git range-diff main ORIG_HEAD retry
1:  e0377f6 = 1:  67bce9b Повторять запрос при ошибке
2:  5058e4c = 2:  cb6bb38 Не ждать после последней попытки
3:  c357ae4 = 3:  48b7729 Искать заказ с повтором

Патч тот же, а хеш другой, потому что у нового коммита другой родитель и другое дерево: в снимок 67bce9b уже входят коммиты Бори. Автора и дату автора rebase переносит из старого коммита, дату коммитера ставит текущую:

$ git log --format='%h %ad %cd %s' --date=short main..retry
48b7729 2026-09-02 2026-09-04 Искать заказ с повтором
cb6bb38 2026-09-02 2026-09-04 Не ждать после последней попытки
67bce9b 2026-09-02 2026-09-04 Повторять запрос при ошибке
$ git rev-parse --short ORIG_HEAD
c357ae4

Старые коммиты целы. retry на c357ae4 больше не указывает, хеш прежней вершины записан в ORIG_HEAD, а сами коммиты пока держит вторая ветка Ани, retry-log. Когда сборка мусора удаляет недостижимое, сказано в главе 1.4.

1. До rebase a6f253d 3b31e8b f178e64 1ee0b4e merge-base e0377f6 5058e4c c357ae4 main retry HEAD rebase перенесёт main..retry 2. После git rebase main a6f253d 3b31e8b f178e64 1ee0b4e 67bce9b cb6bb38 48b7729 e0377f6 5058e4c c357ae4 тот же патч, новый хеш main ORIG_HEAD retry HEAD старые коммиты целы, но retry на них больше не указывает
Rebase записывает коммиты заново. Git применил патчи трёх коммитов Ани поверх 1ee0b4e и получил коммиты с другими хешами. Прежние остались в репозитории, retry на них больше не указывает, а хеш последнего хранит ORIG_HEAD.

Новые коммиты rebase создаёт не всегда. Если ветка уже стоит на вершине main, переносить нечего:

$ git rebase main
Current branch retry is up to date.

Интерактивный rebase тоже оставляет коммит как есть, пока тот не меняется и родитель у него прежний: хеш совпадает до знака, даже дата коммитера не обновляется. Все коммиты заново создаёт --force-rebase. Merge-коммиты внутри ветки rebase по умолчанию выбрасывает и выпрямляет историю, а сохраняет их --rebase-merges.

Отмена: ORIG_HEAD и --abort

Rebase прошёл без единого конфликта, а собрать ветку уже нельзя:

$ go build ./...
# shop
./orders.go:7:19: not enough arguments in call to s.Find
	have (int)
	want (context.Context, int)

Боря поменял сигнатуру Find и поправил все вызовы, какие были в main. Вызов в orders.go Аня написала в своей ветке, Боря его не видел, а текстового конфликта не случилось: правки лежат в разных файлах. Так появился коммит 48b7729, который никто ни разу не собирал. Вернём ветку туда, где она стояла до rebase (про режимы reset есть глава 5.1):

$ git reset --hard ORIG_HEAD
HEAD is now at c357ae4 Искать заказ с повтором
$ go build ./...
$ git rev-parse --short ORIG_HEAD
48b7729

ORIG_HEAD хранит одно значение, и его перезаписывает следующий reset, merge, rebase и даже git stash, а commit и switch не трогают. Сам reset записал туда вершину, с которой ушёл, и повторный вызов вернул бы ветку на 48b7729. Затёртое значение найдёт reflog (глава 5.2).

Такой коммит ловят прямо во время переноса флагом -x: после каждого перенесённого коммита rebase запускает команду и останавливается, если она упала:

$ git rebase -x 'go build ./...' main
Executing: go build ./...
Executing: go build ./...
Executing: go build ./...
# shop
./orders.go:7:19: not enough arguments in call to s.Find
	have (int)
	want (context.Context, int)
warning: execution failed: go build ./...
You can fix the problem, and then run

  git rebase --continue
$ git branch -v
* (no branch, rebasing retry) a33e906 Искать заказ с повтором
  main                        1ee0b4e Описать запуск в README
  retry                       c357ae4 Искать заказ с повтором
  retry-log                   e078aaa Логировать неудачные попытки

Третий коммит не собрался (хеш у него не 48b7729: перенос шёл позже, и дата коммитера другая). git status пишет interactive rebase in progress: под капотом у -x интерактивный rebase, хотя редактор не открывался. HEAD отсоединён и стоит на новом коммите, а retry всё ещё на c357ae4: ветку rebase переставляет только в конце. Выхода два. git rebase --abort возвращает ветку, индекс и рабочую копию как было:

$ git rebase --abort
$ git log --oneline -1
c357ae4 (HEAD -> retry) Искать заказ с повтором

Или починить коммит на месте. Аня снова запускает тот же git rebase -x, на остановке передаёт контекст в вызов в orders.go, дописывает правку в коммит и продолжает:

$ go build ./...
$ git commit -a --amend --no-edit
[detached HEAD 0bb7055] Искать заказ с повтором
 Date: Wed Sep 2 12:00:00 2026 +0300
 1 file changed, 13 insertions(+)
 create mode 100644 orders.go
$ git rebase --continue
Successfully rebased and updated refs/heads/retry.
$ git log --oneline main..retry
0bb7055 (HEAD -> retry) Искать заказ с повтором
ce27cb2 Не ждать после последней попытки
dbe542f Повторять запрос при ошибке

На конфликте rebase останавливается так же, и к двум выходам добавляется --skip: выбросить текущий коммит. Как разрешать конфликт и почему ours и theirs при rebase меняются местами, объясняет глава 3.2.

--onto: пересадить только свои коммиты

Ветку retry-log Аня начала поверх старой retry. Rebase двигал только retry, и retry-log осталась на коммитах, которых в retry больше нет. Обычный git rebase retry возьмёт всё, что есть в ветке и чего нет в retry, то есть четыре коммита вместе со старыми копиями:

$ git switch retry-log
Switched to branch 'retry-log'
$ git log --oneline retry..
e078aaa (HEAD -> retry-log) Логировать неудачные попытки
c357ae4 Искать заказ с повтором
5058e4c Не ждать после последней попытки
e0377f6 Повторять запрос при ошибке
$ git rebase retry                 # строки hint: убраны
warning: skipped previously applied commit e0377f6
warning: skipped previously applied commit 5058e4c
Auto-merging orders.go
CONFLICT (add/add): Merge conflict in orders.go
error: could not apply c357ae4... Искать заказ с повтором
Could not apply c357ae4... # Искать заказ с повтором

Первые два коммита git пропустил сам. Перед переносом он сравнивает patch-id коммитов ветки с коммитами, которые есть в retry, но не в ветке: совпал патч, и git выбрасывает коммит. Третий коммит Аня чинила, его патч изменился, и git попытался применить старую версию поверх новой. Отменяем и говорим прямо, что переносить:

$ git rebase --abort
$ git rebase --onto retry c357ae4
Successfully rebased and updated refs/heads/retry-log.
$ git log --oneline main..
7a20d88 (HEAD -> retry-log) Логировать неудачные попытки
0bb7055 (retry) Искать заказ с повтором
ce27cb2 Не ждать после последней попытки
dbe542f Повторять запрос при ошибке

Полная форма: git rebase --onto <новая база> <старая база> [<ветка>]. Команда берёт коммиты из <старая база>..<ветка> и записывает их поверх новой, а без --onto базы совпадают. Старой базой стала прежняя вершина retry, и переехал один коммит. Так же пересаживают ветку, начатую по ошибке не от main.

--onto сверяет патчи со старой базой, а не с новой

В копии репозитория Боря по ошибке начал ветку readme-env от retry, а Аня забрала его коммит к себе через cherry-pick. Боря пересаживает ветку на main:

$ git rebase --onto main retry readme-env
warning: skipped previously applied commit 8086a14
hint: use --reapply-cherry-picks to include skipped commits
hint: Disable this message with "git config set advice.skippedCherryPicks false"
Successfully rebased and updated refs/heads/readme-env.
$ git log --oneline -1
1ee0b4e (HEAD -> readme-env, main) Описать запуск в README

Патчи ветки git сверил с коммитами retry, которых в ветке нет, нашёл там копию и выбросил коммит, хотя в main этой правки нет. Ветка стала равна main. Видишь skipped previously applied commit при --onto, проверь результат: откат через ORIG_HEAD и повтор с --reapply-cherry-picks вернут коммит.

Интерактивный rebase: план в редакторе

Боря собрал в ветке metrics пять коммитов, и в таком виде показывать их на ревью не стоит:

$ git log --oneline main..metrics
63862be (HEAD -> metrics) опечатка в имени метрики
ef79a30 отладка
768088a Отдавать метрики по HTTP
96a8725 wip
f58c0cf счётчик

git rebase -i main сначала открывает редактор с планом: в строке команда и коммит, сверху самый старый, заголовок после # (так с git 2.50) служит комментарием. Ниже идёт справка по командам, её пересказывает таблица:

pick f58c0cf # счётчик
pick 96a8725 # wip
pick 768088a # Отдавать метрики по HTTP
pick ef79a30 # отладка
pick 63862be # опечатка в имени метрики

# Rebase 1ee0b4e..63862be onto 1ee0b4e (5 commands)

Строки выполняются сверху вниз, их можно переставлять и менять в них команду. Боря пишет так:

reword f58c0cf # счётчик
fixup 63862be # опечатка в имени метрики
pick 96a8725 # wip
squash 768088a # Отдавать метрики по HTTP
drop ef79a30 # отладка
КомандаЧто делает
pickберёт коммит как есть
rewordберёт коммит, открывает редактор с сообщением
editберёт коммит и останавливается для commit --amend или разбиения
squashвклеивает в предыдущий коммит, открывает редактор с обоими сообщениями
fixupвклеивает в предыдущий коммит, сообщение остаётся от предыдущего; -C берёт сообщение этого
dropвыбрасывает коммит
execзапускает команду оболочки, -x ставит её после каждого коммита
breakостанавливает rebase здесь
update-refв конце ставит сюда ветку (стопка веток ниже)

Первым идёт reword: git открывает редактор с сообщением счётчик, и Боря пишет вместо него «Считать запросы к заказам». fixup молча вклеивает исправление опечатки в тот же коммит. На squash в редакторе оба сообщения, начало файла такое:

# This is a combination of 2 commits.
# This is the 1st commit message:

wip

# This is the commit message #2:

Отдавать метрики по HTTP

Строки с # git выбросит сам, Боря стирает wip. После редакторов в терминале остаётся вот что:

$ git rebase -i main
[detached HEAD c6bea21] Считать запросы к заказам
 Date: Sat Sep 5 10:00:00 2026 +0300
 1 file changed, 7 insertions(+)
 create mode 100644 metrics.go
[detached HEAD 04006db] Отдавать метрики по HTTP
 Date: Sat Sep 5 11:00:00 2026 +0300
 1 file changed, 15 insertions(+)
 create mode 100644 metrics_http.go
Successfully rebased and updated refs/heads/metrics.
$ git log --oneline main..metrics
04006db (HEAD -> metrics) Отдавать метрики по HTTP
f17a06f Считать запросы к заказам

Из пяти коммитов осталось два. c6bea21 промежуточный: его создал reword, а fixup заменил на f17a06f. Вместо drop строку можно было стереть, git молча выбросит и такой коммит.

--fixup и --autosquash: правка старого коммита

На ревью Боре предложили считать ещё и ошибки. Новый счётчик orderErrors относится к первому коммиту ветки, а --amend дотягивается только до последнего (глава 2.1). Коммит, в заголовке которого записано, куда его вклеить, создаёт --fixup:

$ git commit -a --fixup=f17a06f
[metrics efc8c94] fixup! Считать запросы к заказам
 1 file changed, 2 insertions(+)
$ git log --oneline main..metrics
efc8c94 (HEAD -> metrics) fixup! Считать запросы к заказам
04006db Отдавать метрики по HTTP
f17a06f Считать запросы к заказам

git rebase -i --autosquash main находит целевой коммит по заголовку после fixup! и сам ставит исправление в план следующей строкой. Боря закрывает редактор без правок:

pick f17a06f # Считать запросы к заказам
fixup efc8c94 # fixup! Считать запросы к заказам
pick 04006db # Отдавать метрики по HTTP
$ git rebase -i --autosquash main
Successfully rebased and updated refs/heads/metrics.
$ git log --oneline main..metrics
f2eae8f (HEAD -> metrics) Отдавать метрики по HTTP
b4e4e49 Считать запросы к заказам

С git 2.44 --autosquash работает и без -i, а rebase.autoSquash=true включает его по умолчанию только для интерактивного rebase. Сообщение правят через --fixup=reword:<коммит>, сообщение и содержимое через --fixup=amend:<коммит> (оба с git 2.32): они открывают редактор и создают коммит amend! …. Готовую ветку Боря влил в main перемоткой, git merge --ff-only metrics (глава 3.1).

Стопка веток: --update-refs

retry-log стоит на retry: это стопка, вторая ветка поверх первой, у каждой свой merge request. main опять ушёл вперёд. Обычный git rebase main из retry-log перенёс бы все четыре коммита, но retry осталась бы на старых, и снова понадобился бы --onto. Флаг --update-refs (git 2.38) двигает и ветки, указывающие на переносимые коммиты. Аня переключается на retry-log и запускает git rebase -i --update-refs main, в плане такая ветка видна строкой update-ref:

pick dbe542f # Повторять запрос при ошибке
pick ce27cb2 # Не ждать после последней попытки
pick 0bb7055 # Искать заказ с повтором
update-ref refs/heads/retry

pick 7a20d88 # Логировать неудачные попытки
$ git rebase -i --update-refs main
Successfully rebased and updated refs/heads/retry-log.
Updated the following refs with --update-refs:
	refs/heads/retry
$ git log --oneline -5
adf9d5b (HEAD -> retry-log) Логировать неудачные попытки
a1de955 (retry) Искать заказ с повтором
a9378c9 Не ждать после последней попытки
f30b966 Повторять запрос при ошибке
f2eae8f (metrics, main) Отдавать метрики по HTTP

Обе ветки переехали. Флаг работает и без -i, а насовсем его включает git config --global rebase.updateRefs true.

cherry-pick: копия одного коммита

git cherry-pick делает с одним коммитом то же, что rebase с каждым: вычисляет его патч и записывает новый коммит поверх HEAD. Боря исправил в main печать копеек, а в ветке release/0.1, начатой от старого коммита, та же ошибка:

$ git log --oneline -1
81a63fb (HEAD -> main) Печатать копейки двумя знаками
$ git switch -c release/0.1 3b31e8b
Switched to a new branch 'release/0.1'
$ go run .
заказ 1: 500.5 ₽ <nil>
$ git cherry-pick -x main
[release/0.1 95849ba] Печатать копейки двумя знаками
 Date: Tue Sep 8 10:00:00 2026 +0300
 1 file changed, 1 insertion(+), 1 deletion(-)
$ go run .
заказ 1: 500.05 ₽ <nil>
$ git log -1 --format=%B
Печатать копейки двумя знаками

(cherry picked from commit 81a63fb7cb0b0198b51a08e525169e1c22e8f60f)

-x дописал в сообщение полный хеш исходного коммита, и по нему потом выясняют, откуда пришло исправление и во все ли релизные ветки оно попало. Документация советует -x для переноса между опубликованными ветками: хеш из локальной ветки никому ничего не скажет. git cherry-pick A..B переносит несколько коммитов по порядку, без самого A. У копии тот же patch-id, поэтому rebase на ветку с копией пропустит оригинал, как пропустил два коммита в разделе про --onto.

Опубликованную историю не переписывают

Всё выше меняло только локальный репозиторий. Когда старые коммиты уже на сервере, на них опираются чужие клоны. Обычный push переписанной ветки git отклонит как non-fast-forward, а после принудительного у коллег окажутся две истории с одинаковыми патчами. Слияние склеит обе, и каждое изменение попадёт в историю дважды (как с amend в главе 2.1). Кто строил на твоей ветке, будет пересаживать свои коммиты.

Что можно переписывать, а что нет

Неотправленные коммиты переписывай как угодно. Свою ветку merge request, на которой никто не строит, тоже можно: её отправляют через git push --force-with-lease, который откажет, если на сервере появилось чужое (глава 4.1). main, релизные и другие общие ветки не переписывают: ошибку в них исправляют новым коммитом или git revert (глава 5.1).

merge, rebase и squash-merge на одной ветке

Вернёмся к исходному состоянию: в main новая сигнатура Find, в retry три коммита Ани до починки. Так бывает, когда CI проверил ветку раньше, чем main ушёл вперёд. Вольём retry в три копии репозитория по очереди: слиянием, rebase с перемоткой и squash-merge, который кладёт всю работу ветки в main одним коммитом.

$ git merge -q retry
$ git log --oneline --graph
*   6f84f94 (HEAD -> main) Merge branch 'retry'
|\
| * c357ae4 (retry) Искать заказ с повтором
| * 5058e4c Не ждать после последней попытки
| * e0377f6 Повторять запрос при ошибке
* | 1ee0b4e Описать запуск в README
* | f178e64 Передавать контекст в хранилище
|/
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop
$ git rebase -q main retry
$ git switch -q main
$ git merge -q --ff-only retry
$ git log --oneline --graph
* 48b7729 (HEAD -> main, retry) Искать заказ с повтором
* cb6bb38 Не ждать после последней попытки
* 67bce9b Повторять запрос при ошибке
* 1ee0b4e Описать запуск в README
* f178e64 Передавать контекст в хранилище
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop
$ git merge -q --squash retry
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested
$ git commit -q -m 'Повторять поиск заказа при ошибке'
$ git log --oneline --graph
* 8848d58 (HEAD -> main) Повторять поиск заказа при ошибке
* 1ee0b4e Описать запуск в README
* f178e64 Передавать контекст в хранилище
* 3b31e8b Добавить хранилище заказов
* a6f253d Создать модуль shop

Ни одна из трёх копий не собирается. Виноватый коммит ищет git bisect: хороший 3b31e8b, плохой main, проверка go build ./... (как устроен bisect run, смотри в главе 5.3). В копии со слиянием:

$ git bisect start main 3b31e8b
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[c357ae48749e42794b8607ac570c0e8c23e4aaaa] Искать заказ с повтором
$ git bisect run go build ./...
running 'go' 'build' './...'
Bisecting: 0 revisions left to test after this (roughly 1 step)
[1ee0b4e913593137fbaf58f01293fe2c8b5aac8e] Описать запуск в README
running 'go' 'build' './...'
6f84f94f943383ad5fe8606be46a204ff5dec1fa is the first bad commit
commit 6f84f94f943383ad5fe8606be46a204ff5dec1fa
Merge: 1ee0b4e c357ae4
Author: Боря <borya@example.com>
Date:   Fri Sep 4 10:00:00 2026 +0300

    Merge branch 'retry'

 orders.go | 11 +++++++++++
 retry.go  | 16 ++++++++++++++++
 2 files changed, 27 insertions(+)
 create mode 100644 orders.go
 create mode 100644 retry.go
bisect found first bad commit

Если прогнать то же в других копиях, выйти через git bisect reset, откатить найденный коммит и удалить ветку, выйдет так:

mergerebase и fast-forwardsquash-merge
Что нашёл bisect6f84f94, слияние; обе стороны по отдельности собираются48b7729, один файл и 11 строк8848d58, вся ветка: два файла, 27 строк
git revert HEADотказ без -m; с -m 1 уходит вся веткауходит только orders.go, сборка снова проходитуходит вся ветка
git branch -d retryудаляетудаляетотказ: not fully merged
merge git merge retry 3b31e8b f178e64 1ee0b4e 6f84f94 e0377f6 5058e4c c357ae4 bisect: слияние 6f84f94 revert только с -m 1, уходит вся ветка branch -d retry: удалит rebase и fast-forward git rebase main retry 3b31e8b f178e64 1ee0b4e 67bce9b cb6bb38 48b7729 bisect: 48b7729, один файл revert HEAD убирает только orders.go branch -d retry: удалит squash-merge git merge --squash retry 3b31e8b f178e64 1ee0b4e 8848d58 e0377f6 5058e4c c357ae4 коммиты retry не в main bisect: 8848d58, вся ветка revert HEAD убирает всю ветку разом branch -d retry: откажет
Одна ветка, три истории. Отмечен коммит, на который указал git bisect: слияние, один небольшой коммит или вся ветка разом. От этого зависит и то, что уберёт один git revert.

После слияния bisect указал на merge-коммит, и ответ честный: каждая сторона собирается, сломало их сочетание, искать придётся в обоих родителях. Revert слияния с -m 1 убирает всю ветку (глава 3.1), а повторно влить её после починки так просто не выйдет: старые коммиты Ани git считает влитыми, и retry.go не вернётся (ловушка разобрана в главе 5.1). После rebase виноват маленький коммит, и откатить можно его одного. Но 48b7729 создал rebase, и никто его не собирал, пока не сломался main. Переносишь ветку, гоняй тесты на каждом коммите: git rebase -x 'go test ./...' main.

После squash-merge история короче всех и revert убирает ветку одной командой, зато bisect не пойдёт глубже коммита на всю ветку. Для git retry не влита: в main лежит сумма её изменений, а не её коммиты, и git log main..retry по-прежнему выводит три коммита. Если поменять в retry паузу в retry.go и влить ветку снова, merge-base останется 3b31e8b: правка столкнётся со squash-копией старых коммитов, и выйдет конфликт add/add в retry.go. На GitHub три способа называются Merge pull request, Rebase and merge и Squash and merge, а GitLab при squash и методе Merge commit положит поверх squash-коммита ещё merge-коммит.

Вопросы

4
Суть: merge оставляет коммиты ветки как есть и добавляет merge-коммит с двумя родителями. Rebase записывает коммиты ветки заново поверх main, история остаётся прямой. Squash-merge кладёт в main один новый коммит с суммой изменений. От выбора зависит, насколько точно bisect укажет виноватого и что уберёт один revert.

Что остаётся в истории

После merge в main исходные коммиты ветки и merge-коммит, а --first-parent показывает влитую ветку одним коммитом (глава 1.2). Rebase ставит коммиты в линию с новыми хешами. После squash-merge исходные коммиты живут только в ветке, пока её не удалили.

bisect и revert

  • merge: bisect указывает на merge-коммит, если виновато сочетание сторон; revert требует -m 1 и убирает ветку целиком (ловушки в главе 5.1);
  • rebase: bisect доходит до маленького коммита, и откатить можно его одного;
  • squash-merge: коммит на всю ветку, revert простой, но точнее bisect не скажет.

Как выбирают

Свою ветку перед вливанием переносят на свежий main и гоняют тесты на каждом коммите. Осмысленные коммиты сохраняют: rebase с перемоткой или merge, если нужна граница ветки в истории. Ветку из «wip» и «правок по ревью» вливают squash-merge. Обычно команда закрепляет один способ в настройках проекта.

Чистая история из коммитов, которых никто не собирал

Rebase без -x не проверяет ни один перенесённый коммит. В демо-репозитории перенос прошёл без конфликтов, а 48b7729 не компилируется. Кто хвалит rebase за линейную историю и молчит про -x или CI после переноса, получает лог с несобираемыми коммитами, которые потом мешают bisect.

Суть: пока rebase не закончен (конфликт, edit, упавший -x), git rebase --abort возвращает всё как было. Сразу после окончания помогает git reset --hard ORIG_HEAD: там прежняя вершина. Старые коммиты целы.

Rebase ещё идёт

На остановке HEAD отсоединён, а ветка стоит на старом коммите, её rebase переставит только в конце. Отсюда три пути: --continue после исправления, --skip, чтобы выбросить текущий коммит, и --abort, чтобы отменить всё.

Rebase закончен

--abort уже ответит fatal: no rebase in progress. Прежняя вершина лежит в ORIG_HEAD, сравнить её с новой можно через git range-diff main ORIG_HEAD retry, а откат такой:

$ git reset --hard ORIG_HEAD
HEAD is now at c357ae4 Искать заказ с повтором

Делать это нужно сразу: ORIG_HEAD перезаписывают reset, merge, следующий rebase и git stash, а второй такой же reset вернёт перенесённую версию. Затёртое значение хранит reflog ветки (глава 5.2).

reset посреди rebase его не отменяет

Если на остановке сделать git reset --hard ORIG_HEAD, рабочая копия вернётся к старому коммиту, но rebase не закончится: git status всё так же пишет interactive rebase in progress, а следующий git rebase откажет с fatal: It seems that there is already a rebase-merge directory. На остановке выходят через --abort.

Суть: правку содержимого коммитят с --fixup=<коммит> и вклеивают через git rebase -i --autosquash <база>. Сообщение меняют командой reword в плане или через --fixup=reword:<коммит>. Сложную правку делают на остановке edit. Переписываются этот коммит и все после него.

Правка содержимого

git commit --fixup=f17a06f создаёт коммит fixup! Считать запросы к заказам, а --autosquash по заголовку ставит его в план за целевым коммитом с командой fixup: изменения вклеятся, сообщение останется прежним.

Остановиться на коммите

В плане меняют pick на edit, и rebase останавливается сразу после этого коммита:

$ git rebase -i main
Stopped at b4e4e49...  # Считать запросы к заказам
You can amend the commit now, with

  git commit --amend

Once you are satisfied with your changes, run

  git rebase --continue

Дальше правят файлы, делают git commit -a --amend и git rebase --continue. Разбить коммит можно так: git reset HEAD^, несколько коммитов по частям (add -p, глава 2.1), потом --continue.

Что поменяется

Коммиты до исправленного сохраняют хеши, исправленный и все после него получают новые. Ветки поверх этой перенесёт --update-refs, а отправленную ветку придётся пушить с --force-with-lease (глава 4.1).

fixup! без --autosquash так и останется в истории

Обычный git rebase -i main fixup-коммиты не переставляет: они остаются на своих местах с pick и после rebase живут отдельными коммитами fixup! …. Не забывай флаг или включи rebase.autoSquash=true, она действует на интерактивный rebase.

Суть: rebase создаёт вместо коммитов новые, а старые уже есть у всех, кто сделал fetch. Обычный push не пройдёт, после принудительного у коллег разойдутся истории: слияние задвоит коммиты, ветки поверх твоей придётся пересаживать. Исключение: своя ветка merge request, на которой никто не строит, её отправляют с --force-with-lease.

Что видит сервер

Обычный push проходит, только если новая вершина потомок старой, поэтому после rebase он отклонён как non-fast-forward. После git push --force-with-lease origin retry у Бори git fetch напечатает строку с + c357ae4...48b7729 и пометкой (forced update): плюс значит принудительное обновление, слева старая вершина.

Что происходит у коллеги

Боря начал ветку retry-metrics поверх старой retry и поправил в ней retry.go. Если Аня только перенесла коммиты и патчи не поменялись, хватит обычного rebase: git узнает старые копии по patch-id и пропустит их.

$ git rebase origin/retry          # строки hint: убраны
warning: skipped previously applied commit e0377f6
warning: skipped previously applied commit 5058e4c
warning: skipped previously applied commit c357ae4
Successfully rebased and updated refs/heads/retry-metrics.

Если патчи менялись (склейка, починка, разрешённый конфликт), git rebase origin/retry старые версии не узнает и попробует применить их второй раз. Тогда переносят только свои коммиты, указав старую вершину из строки fetch: git rebase --onto origin/retry c357ae4. git pull --rebase находит её сам (глава 4.1).

Подсказка «use git pull» ведёт не туда

После fetch git status у Бори пишет, что ветка и origin/retry «have diverged, and have 4 and 5 different commits each», и предлагает git pull. git pull --no-rebase сольёт старые копии коммитов Ани с новыми: у Бори это кончилось конфликтом add/add в retry.go, а в ветке без правок в тех же файлах обе копии молча легли в историю рядом. git pull --rebase переносит только коммит Бори.

Что стоит запомнить

  • Rebase воспроизводит patch каждого commit поверх новой базы и создаёт новые commit objects.
  • ORIG_HEAD и reflog помогают найти исходное положение указателя после операции.
  • --onto позволяет выбрать новую базу и диапазон переносимых коммитов отдельно.

Частые ошибки

  • Не переписывай общую ветку, на которую уже опираются коллеги.
  • Похожие patch не гарантируют одинаковый commit hash: hash включает родителей и метаданные.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Сравни hash до и после rebase

До rebase feature содержит коммит F на базе A. После переноса на B сообщение и diff остались прежними, но hash стал F2. Почему?

before: A---F
         \
          B
after:  A---B---F2
Ответ и разбор

Rebase создаёт новый commit с родителем B. Hash вычисляется из содержимого commit object, куда входит ссылка на родителя, поэтому при новом parent hash меняется даже при прежнем сообщении и patch. Старый F может оставаться доступен через reflog или другую ссылку.

Код-ревью

2. Не force-push чужую основу

Feature уже опубликована и коллега добавил в неё commit. После локального rebase инструкция предлагает:

git push --force origin feature

Как отправить переписанную историю с защитой и почему всё равно нужно согласование?

Ответ и разбор

Слепой force может затереть commit коллеги, который появился после fetch. Сначала синхронизируйся и проверь, что переписываемая ветка общая и изменение согласовано; затем используй --force-with-lease, чтобы отказать при неожиданном продвижении remote ref. Lease не заменяет координацию и проверку diff.

Применение

3. Перенеси только часть ветки

В feature есть коммиты A, B и C поверх main. Нужны только B и C, но посадить их надо на новую ветку release. Какую роль здесь играет --onto?

Ответ и разбор

Укажи git rebase --onto release A feature: A задаёт границу, а коммиты после A до feature переносятся на release. До выполнения проверь граф, что A действительно предок и что переносимый диапазон содержит нужные B и C.

Применение

4. Вернись после остановленного rebase

Rebase остановился на конфликте, и ты решил отменить всю операцию. Какая команда восстанавливает исходную ветку и что проверить после неё?

Ответ и разбор

Выполни git rebase --abort, затем проверь git status, текущую ветку и diff. Если команда уже завершилась, abort не сработает; тогда найди исходный указатель в ORIG_HEAD или reflog и сначала осмотри commit, прежде чем передвигать ветку.