Git rebase: перенос коммитов, отличие от merge и отмена

Git Автор: Среда и версия: Git 2.55.0, Linux, Bash; rebase, merge, abort, continue и интерактивный fixup проверены во временных репозиториях
содержание

git rebase main берёт коммиты текущей ветки, которых нет в main, и повторно применяет их изменения поверх новой базы. Если база изменилась, перенесённые коммиты получают другие хеши. Так рабочую ветку можно обновить до состояния main, сохранив линейную историю.

Rebase подходит для своих ещё не опубликованных коммитов. Если на твоей истории уже основана работа коллег, сначала согласуй переписывание; для обновления общей ветки обычно проще выбрать merge.

Подготовка: две ветки и четыре коммита

Все команды упражнения выполняются в отдельном временном репозитории. Нужен Bash в Linux или Git Bash. Не пропускай первые две строки: они отделяют эксперимент от рабочего проекта.

rebase_demo=$(mktemp -d)
cd "$rebase_demo"
git init -b main
git config user.name 'Handbook Demo'
git config user.email 'demo@example.com'
git config commit.gpgsign false

printf 'Учебный магазин\n' > README.md
git add README.md
git commit -m 'A: создать проект'

git switch -c feature/cart
printf 'items=1\n' > cart.txt
git add cart.txt
git commit -m 'C: добавить корзину'
printf 'items=2\n' > cart.txt
git commit -am 'D: уточнить количество'

git switch main
printf 'Учебный магазин\nДоставка по городу\n' > README.md
git commit -am 'B: описать доставку'
git switch feature/cart
git branch feature-merge
git branch backup/feature-before-rebase
git log --oneline --graph --all

В main лежат A и B, в feature/cart — A, C и D. Буквы здесь взяты из сообщений коммитов; настоящие хеши Git вычислит сам. Ветки feature-merge и backup/feature-before-rebase указывают на D: первая нужна для сравнения, вторая сохраняет исходную вершину.

Мы меняли разные файлы: B добавляет строку в README.md, C и D работают с cart.txt. Поэтому следующий перенос пройдёт без конфликта.

Git / 01

Новая база меняет хеши перенесённых коммитов

Перенос C и D поверх BДо переноса main указывает на B после A, а feature/cart указывает на D после A и C. После rebase main остаётся на B, а feature/cart указывает на D штрих в цепочке A, B, C штрих, D штрих. Исходные C и D сохранены резервной веткой, которая на схеме не показана.01 / до rebase02 / после rebase mainABCDmainfeature/cartABC′D′mainfeature/cartC′ и D′ содержат перенесённые правки; main по-прежнему указывает на B.
Rebase обновляет текущую feature/cart. Коммит B остаётся прежним, а после него появляются C′ и D′ с новыми родителями и хешами.

Как выполнить git rebase main

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

git status --short
git rebase main
git log --oneline --graph main feature/cart
cat README.md
cat cart.txt

Короткий статус перед переносом пуст. После rebase история feature/cart идёт цепочкой A → B → C′ → D′. В README.md появилась доставка из B, в cart.txt осталось items=2 из D. Ветка main продолжает указывать на B: rebase не влил в неё корзину.

Сравни старый и новый хеш вершины:

git rev-parse backup/feature-before-rebase
git rev-parse feature/cart
git log --reverse --format='%s' main..feature/cart

Хеши различаются, а сообщения перенесённых коммитов сохранились:

C: добавить корзину
D: уточнить количество

Проверять только количество коммитов недостаточно. Rebase может пропустить правку, уже присутствующую в новой базе; результат разрешения конфликта тоже способен изменить содержимое. После переноса посмотри итоговый diff и запусти проверки проекта. Правила пропуска эквивалентных изменений и пустых коммитов описаны в документации git-rebase.

Если git status --short показывает незакоммиченные изменения, сначала сделай коммит или отложи изменения через git stash. Начинай перенос из чистого состояния. --autostash умеет автоматизировать сохранение, но при возврате отложенных правок возможен отдельный конфликт; для первого знакомства явные шаги понятнее.

В настоящем проекте main может отставать от сервера. git fetch origin обновляет origin/main; перенос поверх origin/main использует этот снимок, не требуя переключать локальную main. Разница локальных и удалённых ссылок разобрана в статье о ветках Git.

Rebase и merge: одинаковые файлы, разная история

Вернёмся к сохранённой копии исходной ветки и выполним merge:

git switch feature-merge
git merge main -m 'M: влить main в корзину'
git log --oneline --graph main feature-merge
git diff --exit-code feature/cart feature-merge --
git switch feature/cart

В нашем упражнении последний diff пуст: содержимое файлов совпало. Флаг --exit-code превращает сравнение в проверку: код завершения 0 означает совпадение, 1 — различия. Но feature-merge сохранила исходные C и D и получила дополнительный коммит M с двумя родителями. В feature/cart после rebase лежат C′ и D′ поверх B, без коммита слияния.

Что сравниваемgit rebase maingit merge main
Коммиты C и D в этом примереЗаменяются на C′ и D′ в истории веткиСохраняются с прежними хешами
История рабочей веткиЛинейнаяДве линии сходятся в M
Когда возникает конфликтПри применении конкретного коммитаПри объединении вершин веток
Работа коллег основана на C или DНужна договорённость о переписыванииИсходные коммиты остаются в истории

Здесь merge создаёт коммит слияния, потому что ветки разошлись. Когда текущая ветка — предок вливаемой, обычный merge может ограничиться перемещением указателя, то есть fast-forward. Это условие описано в справке git-merge.

При rebase возможны конфликты. Подробное сравнение графов и последствий публикации есть в главе Pro Git о rebase.

Конфликт: отмена через abort и продолжение через continue

Для конфликта создадим второй временный репозиторий. Обе ветки изменят одну строку title.txt:

conflict_demo=$(mktemp -d)
cd "$conflict_demo"
git init -b main
git config user.name 'Handbook Demo'
git config user.email 'demo@example.com'
git config commit.gpgsign false

printf 'Главная\n' > title.txt
git add title.txt
git commit -m 'Начальный заголовок'
git switch -c feature/title
printf 'Магазин\n' > title.txt
git commit -am 'Заголовок магазина'
git switch main
printf 'Каталог\n' > title.txt
git commit -am 'Заголовок каталога'
git switch feature/title

git rebase main
git status --short

Команда rebase завершится с ошибкой конфликта, а статус покажет UU title.txt. Git остановился на переносимом коммите. Если сейчас нужно вернуться к исходной ветке, выполни:

git rebase --abort
git branch --show-current
cat title.txt

Вернутся ветка feature/title и заголовок Магазин. --abort отменяет незавершённый rebase и возвращает ветку к состоянию до его начала. Ручные правки, сделанные для разрешения этого конфликта, будут отброшены; если они нужны отдельно, сохрани их перед отменой.

Теперь повтори перенос и на этот раз разреши конфликт:

git rebase main

Снова появится конфликт. В учебной задаче объединяем смысл двух вариантов в заголовок «Каталог магазина»:

printf 'Каталог магазина\n' > title.txt
git add title.txt
git diff --cached -- title.txt
git rebase --continue
git status --short
cat title.txt

При --continue может открыться редактор сообщения коммита: сохрани сообщение и закрой его. После успешного завершения короткий статус пуст, файл содержит Каталог магазина. Если переносится несколько конфликтующих коммитов, цикл редактирование → add--continue может повториться.

git rebase --skip пропускает текущую правку целиком. Используй его только когда решил, что именно этот переносимый коммит не нужен. Для обычного исправления конфликта предназначен --continue, для возврата в начало — --abort. У --quit другое поведение: он прекращает операцию без восстановления исходных файлов и HEAD. Режимы git rebase.

Не выбирай ours или theirs наугад: при rebase их смысл часто удивляет. Разбор сторон, маркеров и проверки результата вынесен в конфликты Git.

Интерактивный rebase: объединить две свои правки

Вернёмся в первый репозиторий. После переноса там по-прежнему два собственных коммита над main. Допустим, «уточнить количество» нужно включить в «добавить корзину»:

cd "$rebase_demo"
git switch feature/cart
git log --reverse --format='%h %s' main..HEAD
git rebase -i main

В редакторе будут две строки с действием pick: сначала C, затем D. Оставь у C слово pick, у D замени его на fixup, сохрани файл и закрой редактор. fixup присоединит изменения D к предыдущему коммиту, сохранив сообщение C. Если нужно составить сообщение из обоих, вместо fixup есть squash.

Проверь результат:

git log --format='%s' main..HEAD
git rev-list --count main..HEAD
cat cart.txt

Теперь над main один коммит с сообщением C: добавить корзину, а cart.txt по-прежнему содержит items=2. Так можно убрать промежуточное исправление до отправки ветки на ревью. Перестановка и объединение коммитов описаны в интерактивном режиме git-rebase.

Как найти историю до уже завершённого rebase

После завершения --abort уже не сработает: активной операции нет. В первом примере исходная история осталась в backup/feature-before-rebase. Можно открыть её в новой ветке для проверки:

git switch -c recovered-before-rebase backup/feature-before-rebase
git log --oneline -3

Эта команда не передвигает feature/cart: она создаёт отдельную ветку на прежней вершине. Если резервной ссылки нет, начни с git reflog show feature/cart и найди запись до переноса. Не выбирай HEAD@{1} автоматически: после переключений и других команд это уже может быть другая точка. Reflog фиксирует локальные изменения ссылок.

История reflog хранится ограниченное время; после истечения записей и удаления недостижимых объектов восстановление может стать невозможным. Когда прежний коммит найден, сначала сохрани его отдельной веткой. Перемещение уже существующей ветки и его влияние на файлы разобраны в git reset.

Если ветка уже опубликована

Отправленный раньше D и новый D′ — разные коммиты. Обычный push после переписывания может быть отклонён как non-fast-forward. Это ожидаемая защита от замены истории.

Для общей ветки договорись с командой и по возможности обнови её через merge. Для личной опубликованной ветки rebase допустим, если участники согласовали замену истории и ты учёл удалённые изменения.

--force-with-lease проверяет ожидаемое состояние удалённой ссылки, но не подтверждает согласие коллег. Вариант без явного ожидаемого хеша опирается на локальную remote-tracking ссылку; фоновый fetch способен обновить её без твоего просмотра. Если команда договорилась о перезаписи, разберись с формой --force-with-lease=<ref>:<expect> и проверь точную удалённую вершину по документации git-push. Не заменяй отклонённый push на безусловный --force.

Источники