git rebase: порядок в локальных ветках

Самый частый вопрос, с которым я сталкиваюсь в разговорах про git: «Зачем нужен rebase и как им пользоваться?»

Основная задача rebase — наведение порядка.
Если вы часто делаете коммиты-фиксы, а потом ужасаетесь, какой бардак творится в ветке — эта команда станет вашей любимой.

Важно! Rebase стоит делать, только если вы ещё не запушили свои изменения в удалённую ветку.
Эта операция переписывает SHA (id) коммитов, поэтому другие пользователи не смогут так просто спулить ваши правки.

Давайте посмотрим, как можно «причесать» историю.

Изменить сообщение к коммиту

True story. Женя, привет!

Сообщения к коммитам ничем не отличаются от комментариев в коде.
Когда через полгода–год вам понадобится разобраться в своей (или чужой) работе, хорошие объяснения здорово сэкономят время.

Проблема в том, что иногда нам некогда писать подробные комментарии.
А иногда мы к тому же делаем опечатки.
Если перед мёрджем/отправкой пулл-реквеста у вас нашлась минутка, помогите будущему себе и коллегам-разработчикам — поправьте свои сообщения.

Допустим, у нас есть репозиторий Х с такой историей:

$ git log -5 --pretty=%s --graph

* Ipdate docs
* Fix
* Add simple benchmarks
* Fix some bug
* New spinner

Изменить комментарий к последнему коммиту можно с помощью amend:

$ git commit --amend -m 'Update docs'

Если же поправить нужно не самый последний коммит (или сразу несколько), нам поможет reword.

Для начала определимся, какие коммиты нужно отредактировать.
В нашем случае это первый, второй и четвёртый.
Просим гит начать rebase, взяв за основу последние 4 коммита:

$ git rebase -i HEAD~4

О различиях между HEAD~ и HEAD^ можно прочитать на git-scm.com.

В ответ на это откроется текстовый редактор:

pick Fix some bug
pick Add simple benchmarks
pick Fix
pick Ipdate docs

Обратите внимание на обратный порядок: сверху идут старые коммиты, снизу — свежие.
Заменяем у нужных коммитов pick на r или reword:

r Fix some bug
pick Add simple benchmarks
r Fix
r Ipdate docs

И поочерёдно правим каждое сообщение.

В итоге получим такую историю:

$ git log -5 --pretty=%s --graph

* Update docs
* Fix #42: Notifications in IE
* Add simple benchmarks
* Fix #132: URL parser
* New spinner

Удалить ненужный коммит

Бывает, что вы сделали какую-то работу, но она становится не нужна.
Или вы хотите вынести пару коммитов, сделанных неделю назад, в отдельную ветку, а из текущей удалить.

Последние N коммитов легко убрать с помощью reset.
Например, удалить последний коммит:

$ git reset --hard HEAD~

Удалить последние 7:

$ git reset --hard HEAD~6

Если же нужный коммит затесался между другими, запускаем rebase:

$ git rebase -i HEAD~3

pick Add simple benchmarks
pick Fix #42: Notifications in IE
pick Update docs

И просто удаляем ненужную строчку (для нашего примера возьмём самую верхнюю):

pick Fix #132: one more try
pick Update docs
$ git log --pretty=%s --graph

* Update docs
* Fix #42: Notifications in IE
* Fix #132: URL parser
* New spinner

Объединить несколько коммитов в один

Наверняка у вас бывало такое:

$ git log --pretty=%s --graph

* WTF #4
* Another fix for #9
* One more fix for #4
* Fixed #9
* Finally fixed #4
* Fixed #4

Вроде поправили баг, но нашлось что-то новое.
И ещё раз.
И опять.

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

git commit --amend — хорошая команда, но подходит только для обновления последнего коммита.
rebase, в свою очередь, умеет объединять несколько коммитов, даже если они не следуют друг за другом по порядку.

Например, сделать из 6 коммитов 2 довольно просто.
Запускаем rebase:

$ git rebase -i HEAD~6

pick Fixed #4
pick Finally fixed #4
pick Fixed #9
pick One more fix for #4
pick Another fix for #9
pick WTF #4

В гите есть два вида слияния: fixup и squash.
Разница между ними в том, что squash склеивает все комментарии в один, а fixup использует только комментарий основного коммита (того, в который вливаются остальные).

Если вы попросите гит смёрджить коммит, по умолчанию он вольёт его в предыдущий.
Так как нас это не устраивает, сначала поменяем очерёдность коммитов.
Для этого просто меняем строчки местами:

pick Fixed #4
pick Finally fixed #4
pick One more fix for #4
pick WTF #4
pick Fixed #9
pick Another fix for #9

Следующее действие похоже на переименование: мы заменяем pick на f (fixup) или s (squash), в зависимости от нужного результата.

pick Fixed #4
f Finally fixed #4
f One more fix for #4
f WTF #4
pick Fixed #9
s Another fix for #9

Если вы выбрали squash, гит даст вам ещё одну возможность отредактировать сообщение для итогового коммита.

В результате получаем красивую историю:

$ git log --pretty=%s --graph

* Fixed #9
* Fixed #4

Автоматизировать слияние

Процесс объединения можно упростить.
Допустим, ваша история сейчас состоит из двух коммитов:

$ git log --pretty=%s --graph

* Fixed #9
* Fixed #4

Вы поправили пару файлов и хотите влить эти изменения в коммит Fixed #4.
Чтобы не заниматься ручной перестановкой, можно сделать так:

$ git commit --fixup=123456

123456 в данном случае = SHA коммита, в который мы хотим влить изменения.

Гит создаст коммит с сообщением fixup! Fixed #4.
Сделаем ещё несколько изменений, но на этот раз подготовим коммит для сквоша:

$ git commit --squash=567890

Если всё готово, запускаем rebase:

$ git rebase -i --autosquash HEAD~4

pick Fixed #4
fixup fixup! Fixed #4
pick Fixed #9
squash squash! Fixed #9

Смотрите: гит самостоятельно расставил все коммиты в нужном порядке и заодно заменил флаг pick на нужный.

История получится такая же чистая:

$ git log --pretty=%s --graph

* Fixed #9
* Fixed #4

Разделить один коммит на несколько

Противоположная предыдущей ситуация: вы сделали кучу изменений, закрыли 8 тасков, но накидали это всё в 2 коммита:

$ git log --pretty=%s --graph

* Update tests, fix #6, #7, #8
* Fix #1, #2, #3, #4, #5

Откатить их в случае необходимости будет сложно — слишком много зависимостей.
Да и ревьюеру кода не позавидуешь.
Решение простое: разделяем коммиты на логические части:

$ git rebase -i HEAD~2

pick Fix #1, #2, #3, #4, #5
pick Update tests, fix #6, #7, #8

Отмечаем коммит, который хотим разбить, словом edit:

edit Fix #1, #2, #3, #4, #5
pick Update tests, fix #6, #7, #8

Гит откатится к коммиту Fix #1... и остановится.
Далее у нас полная свобода действий.
Отменяем коммит:

$ git reset HEAD~

Выборочно добавляем файлы, связанные с первым таском:

$ git add ./task-1-file.txt

Делаем первый коммит:

$ git commit -m 'Fix #1: Update docs'

Повторяем по вкусу:

$ git add ./task-2-file.txt
$ git commit -m 'Fix #2: New notification system'
...

Когда результат нас удовлетворит, возобновляем rebase:

$ git rebase --continue

Повторив эти шаги несколько раз, можно получить в меру подробную историю:

$ git log --pretty=%s --graph

* Update tests
* Fix #9
* Fix #8
* Fix #7
...

Заключение

Rebase — чудесный и мощный инструмент.
Главное — не бояться.
Описанные выше приёмы помогут вам сделать историю понятной, а поиск по ней лёгким.
Попробуйте применить их перед следующим пушем.