Clean Git History, или Тёмная сторона VCS

в 12:43, , рубрики: Git, ozon tech, rebase, VCS, коммит, чистый код

Всем привет! Меня зовут Маша, и я Golang Backend Developer в компании Ozon. В этой статье я хотела бы поговорить о теме, так или иначе объединяющую все сферы нашего любимого мира IT. А именно — VCS Git.

Clean Git History, или Тёмная сторона VCS - 1

Без системы контроля версий сейчас невозможно представить ни один проект. Это оплот любой кодовой базы и мощнейший инструмент, с помощью которого эту базу можно изменять и отслеживать. Однако нередко чистотой истории изменений пренебрегают, полагаясь на старое доброе «И так сойдёт!», абсолютно игнорируя при этом сложность понимания и поддержки такой истории в будущем.

В этой статье я рассмотрю причины такого подхода, его недостатки, а также способы решения проблем, к которым он приводит. Я выделила несколько основных принципов, которые помогут прийти к чистой истории изменений проекта. Давайте же вместе исследуем эту темную, неизведанную сторону Git, которая нам в этом поможет.

Внимание! Данная статья актуальна для проектов с Git Feature Branch Workflow, где при мердже feature branch в master не используется squash коммитов.

Проблема «грязных» историй

Вот бывает, смотришь ты историю коммитов проекта — и всё, казалось бы, хорошо и понятно, но вдруг натыкаешься на такое: 

Clean Git History, или Тёмная сторона VCS - 2

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

Как же можно было оставить историю Git в таком состоянии? Так много коммитов, у всех из них одинаковые бессмысленные названия, а в коде, скорее всего, изменений на одну строку. Те, кто будут смотреть эту историю, вряд ли смогут понять, что тут происходило. Ведь все коммиты можно было объединить в один, содержащий итоговые правки и дать ему осмысленное название – иными словами, сохранить историю Git в чистоте.

К сожалению, пример выше — это не плод моего воображения, а довольно частая практика. Особенно страшно видеть подобное в исполнении опытных разработчиков. Грязная история часто остаётся, когда приходится раз за разом исправлять что-то небольшое, или экспериментировать с кодом до тех пор, пока он не заработает в тестовом окружении.

Но ведь это так просто — не делать подобных ошибок! Давайте разберёмся, что же такое Clean Git History, в чём её преимущества, как можно её добиться, а также на практике выйдем за рамки пресловутых commit/push и научимся сохранять историю Git в чистоте.

Концепт Clean Git History

Clean Git History, или чистая история Git — это такая история изменений проекта, которая соответствует трём ключевым критериям:

1. Рефлективность

Описание каждого коммита истории должно отражать сущность его изменений.

2. Атомарность

Каждый коммит должен быть атомарен и логически завершён. У него не должно быть зависимостей от других коммитов настолько, насколько это возможно.

3. Прозрачность

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

Основные правила ведения чистой истории

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

1. Давать коммитам осмысленные названия

Описания коммитов всегда должны четко и кратко отражать суть всего, что было вами сделано за одну итерацию. Даже если изменения были незначительными, это не повод оставлять Git message без внимания. Если же, наоборот, хочется описать все детали, стоит использовать первую строку для краткого резюме, а подробную информацию дать уже на последующих.

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

Clean Git History, или Тёмная сторона VCS - 3

2. Следить за количеством коммитов

Не стоит создавать десятки коммитов со схожими правками, однако не нужно и пытаться уместить в один изменения на тысячи строк. Коммитов должно быть ровно столько, сколько логически завершённых блоков было в вашей работе, — не больше и не меньше.

Clean Git History, или Тёмная сторона VCS - 4

3. Разделять логику коммитов

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

Во-первых, это позволит легче ориентироваться в общей истории изменений. Во-вторых, так будет проще справляться с потенциальными конфликтами при изменении истории. И наконец, от таких коммитов всегда легче избавиться при необходимости.

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

  • написании бизнес-логики;

  • создании тестов;

  • рефакторинге кода;

  • генерации кода (например, из файлов proto и т. д.).

Clean Git History, или Тёмная сторона VCS - 5

Как прийти к Clean Git History

Итак, мы имеем некую историю коммитов (уже существующих или создающихся в данный момент) и хотим её изменить таким образом, чтобы она соответствовала критериям Git Clean History.

Давайте теперь на практике посмотрим, какими способами это можно сделать.

Перед началом

Для наглядности я создала небольшой проект и сделала в нём пять коммитов с соответствующими названиями.

$ git log --oneline --graph --decorate --all

* 3887aa2 (HEAD -> feature-branch) commit 5
* 884fe33 commit 4
* eb38304 commit 3
* 9c2daaf commit 2
* fb7ab41 (main) commit 1

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

Основные команды я буду показывать через CLI, а также дам к ним документацию для дальнейшего изучения. Если вам сложно или непривычно работать с Git в CLI, в интернете можно легко найти информацию о реализации каждого из описанных ниже приёмов в вашей любимой IDE.

Сейчас нам важно понять принцип работы с инструментами изменения истории. Тогда, конечно же, будет неважно, через какой интерфейс вы будете выполнять эти команды, ведь принцип будет ясен. 

С большой силой приходит большая ответственность

Как уже было сказано, Git — это безумно мощный инструмент. Однако вся его сила прямо пропорциональна опасности работы с ним.

Если вы ещё не пользовались приведёнными командами, прежде чем делать это с продовой кодовой базой, определенно стоит потренироваться в их выполнении на тестовом репозитории — до полного понимания всех принципов. 

Основные приёмы

Команды и приёмы для ведения чистой истории Git здесь представлены в готовом к использованию виде. Но вы, безусловно, всегда вольны изменить их на свой вкус.

Force push

Документация: 

Флаг --force — костяк всех приёмов для поддержания чистоты истории Git. Его мы используем с командой push, когда хотим перезаписать историю Git в удалённом репозитории и сделать её такой же, как локальная.

Вместо --force можно пользоваться более безопасным флагом --force-with-lease. Он работает так же, однако не даст вам перезаписать удалённую историю, если кто-то из коллег добавил туда новые коммиты.

$ git push --force-with-lease origin feature-branch

Применение:

Допустим, у вас есть несколько синхронизированных коммитов в ветке локально и на сервере. Если вы изменяете эти коммиты локально, то, чтобы сделать их такими же в удалённом репозитории, нужно использовать флаг --force.

Если же у вас есть ветка, которой ещё нет на сервере, и вы что-то меняете в ней локально, то при пуше на сервер добавлять флаг не нужно (так как мы и так пушим её туда впервые).

Внимание! Перед пушем с флагом обязательно проверьте ветку, в которую вы пушите. Ни в коем случае это не должна быть master!

Почему?

Основная задача ветки master — гарантировать, что в ней находится 100% рабочий проверенный код. Благодаря этому её всегда можно использовать как референс. Соответственно, все изменения, вносимые в эту ветку, должны проходить тщательное ревью и тестирование. --force push же внесёт изменения без проверок, в результате чего, во-первых, есть шанс сломать проект, а во-вторых, ваши коллеги, не знающие об изменениях, продолжат работать со старой версией репозитория.

Amend

Документация: 

Это, наверное, один из самых используемых инструментов среди приведённых мной. Данный флаг позволяет исправить последний сделанный коммит, а также изменить его commit message (правки могут ограничиваться лишь этим). Ваши старые изменения останутся, если не будут затронуты новыми. В противном случае они перезапишутся.

 git commit --amend

Пример:

# индексируем изменённые файлы
$ git add .

# коммитим новые изменения, заодно меняя commit message
$ git commit --amend -m "better commit message"

[feature-branch 7aa55d8] better commit message
 Date: Sun Apr 30 18:58:15 2023 +0300
 1 file changed, 1 insertion(+), 1 deletion(-)
 
# проверяем наши коммиты и видим изменённый commit message последнего коммита
$ git log --oneline --graph --decorate --all

* 7aa55d8 (HEAD -> feature-branch) better commit message
* 97f9c9f commit 4
* eb38304 commit 3
* 9c2daaf commit 2
* fb7ab41 (main) commit 1

Применение:

Использовать --amend можно в самых разных ситуациях: когда нужно изменить commit message или код последнего коммита, избежать добавления грязных (пустых) коммитов при тестировании или изменения одного-двух символов в коде.

Reset

Документация: 

Данная команда вернёт файлы проекта к состоянию выбранного коммита, при этом отбросив всё, что происходило после него. Однако, если у вас до этого были незакоммиченные изменения, выполнение этой команды с большой вероятностью приведёт к возникновению конфликтов.

$ git reset [<commit>]

Чтобы избежать конфликтов, используйте reset с флагом --hard. Благодаря этому все незакоммиченные локальные изменения пропадут и конфликтов, соответственно, не будет. Однако стоит заранее позаботиться о незакоммиченном коде (см. раздел «Дополнительные приёмы»).

$ git reset --hard [<commit>]

Пример:

# хотим отбросить два последних коммита
$ git reset --hard HEAD~2

# видим, какой коммит теперь ведущий
HEAD is now at eb38304 commit 3

# убеждаемся, что в истории стало на два коммита меньше
$ git log --oneline --graph --decorate --all

* eb38304 (HEAD -> feature-branch) commit 3
* 9c2daaf commit 2
* fb7ab41 (main) commit 1

Применение:

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

Revert

Документация: 

Эта команда позволяет отменить изменения одного определённого коммита посредством создания нового с обратными изменениями. 

Команда revert похожа на reset, однако между ними есть отличие. В то время как reset убирает все коммиты, следующие за тем, к которому мы хотим откатиться, revert убирает изменения только одного коммита, создавая при этом новый.

$ git revert [<commit>]

Вам будет предложено выбрать название для нового коммита, но можно оставить стандартное: Revert “[<reverted commit message>]”.

Пример:

# хотим отменить изменения коммита eb38304
$ git revert eb38304

# видим, что в истории появился новый коммит с обратными изменениями
$ git log --oneline --graph --decorate --all
* dd742c3 (HEAD -> feature-branch) Revert “commit 3” 
* 3887aa2 commit 5
* 884fe33 commit 4
* eb38304 commit 3
* 9c2daaf commit 2
* fb7ab41 (main) commit 1

Применение:

Представим, что вы решили удалить ненужный код в одном из коммитов, однако спустя время оказалось, что код всё-таки нужен и стоит его вернуть. Тут вам идеально подойдет команда revert.

Clean Git History, или Тёмная сторона VCS - 6

Interactive rebase

Документация: 

Interactive rebase — это самое мощное оружие в нашем арсенале. С его помощью можно к каждому из коммитов применить определённое действие, так или иначе направленное на изменение истории.

Чтобы начать процесс, нужно выполнить соответствующую команду с флагом --interactive и указать коммит, с которого мы хотим начать процесс rebase. Для изменения будут доступны коммиты, следующие за указанным.

$ git rebase --interactive [<commit>]

Пример:

# например, начнём процесс rebase с четвёртого коммита от ведущего
$ git rebase --interactive HEAD~4
# следующая команда аналогична предыдущей
$ git rebase --interactive fb7ab419 # fb7ab419 — хеш первого коммита (commit 1) 

Далее в текстовом редакторе откроется файл определённого вида, где в хронологическом порядке будут представлены все коммиты, начиная с того, который идёт после указанного при вводе команды, и заканчивая самым новым. 

Также там будет памятка по использованию команды interactive rebase с указанием того, какие действия можно совершать в отношении коммитов. Сами же действия выполняются последовательно сверху вниз.

# файл может выглядеть так:
pick 9c2daaf commit 2
pick eb38304 commit 3
pick 97f9c9f commit 4
pick 8614442 commit 5
# дальше идёт памятка 

Мы видим, как и ожидали, что коммиты расположены в хронологическом порядке. Но что это за слово “pick” слева от каждого из них? pick — это как раз одно из тех действий, которые мы можем совершать в отношении коммитов. Для каждого действия есть определённая команда. Чтобы её выполнить, нужно указать её название слева от коммита. После того как мы выбрали желаемые действия в отношении коммитов, файл нужно сохранить — и процесс rebase начнётся.

Теперь рассмотрим каждое действие подробно.

Pick

Использовать данный коммит. 

Если для коммита указано действие pick, значит, он останется без изменений. Именно поэтому в самом начале процесса rebase у всех коммитов стоит “p”.

p, pick [<commit>]

Reword

Использовать данный коммит, но поменять его commit message.

По-простому — переименовать коммит. Само новое сообщение к коммиту на этом этапе нигде писать не нужно. После того как вы сохраните файл, у вас спросят, как вы хотите назвать коммит.

r, reword [<commit>]

Squash & Fixup

Взять изменения указанного коммита и слить их с предыдущим коммитом (тем, что на строку выше).

С помощью этих команд можно объединять сразу несколько коммитов, указав команды для каждого из них.

squash и fixup делают одно и то же, но с небольшим отличием: 

  • при squash commit messages всех соединённых коммитов будут объединены с описанием коммита, в который попадут все изменения; 

  • при fixup у коммитов, подвергшихся слиянию, будут удалены commit messages. Commit message останется только у самого верхнего коммита, в который попадут все изменения.

s, squash [<commit>]
f, fixup [-C | -c] [<commit>]

Edit

Использовать данный коммит, но остановиться для действия amend.

Эта команда позволяет во время процесса rebase остановиться на выбранном коммите и произвести над ним уже знакомую нам операцию amend.

e, edit [<commit>]

Все окошки процесса rebase закроются — и файлы перейдут в состояние выбранного коммита. После этого необходимо внести изменения в код и всего лишь проиндексировать их. Создавать новый коммит с флагом --amend не нужно — это произойдёт автоматически.

git add .

После этого следует продолжить процесс rebase.

git rebase --continue

Drop

Удалить коммит. 

Также это можно сделать, просто удалив всю строку коммита.

d, drop [<commit>]

Reorder

Изменить порядок коммитов. 

Для этого нет специальной команды, так как сделать это можно, меняя строки коммитов местами.

Пример

Начнём interactive rebase, указав хеш коммита, идущего перед тем, начиная с которого мы будем менять историю.

$ git rebase --interactive fb7ab419 # fb7ab419 — хеш первого коммита (commit 1)

Откроется файл со всеми коммитами, начиная со второго (commit 2), поскольку он идёт после указанного первого (commit 1). 

Представим, что в результате interactive rebase мы решили совершить над коммитами следующие действия: 

r 9c2daaf commit 2 # reword
p eb38304 commit 3 # pick
s 97f9c9f commit 4 # squash с предыдущим
d 8614442 commit 5 # drop

После сохранения файла нас попросят указать новое commit message для коммита с командой r. Редактируем файл и сохраняем:

better commit 2 message
# Please enter the commit message for your changes. Lines starting
# статус rebase ...

Ниже в комментариях файла с новым commit message будет указан статус процесса rebase: какие команды будут выполнены далее, сколько их осталось, какие изменения коснутся кода и т. д.

После будет предложено изменить commit message у коммита с командой s, а также коммита выше (с которым объединится указанный коммит).

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

edited commit 3

# This is the commit message #2:

edited squashed commit 4

# Please enter the commit message for your changes. Lines starting
# статус rebase ...

Если бы мы выбрали fixup вместо squash, нам бы предложили изменить commit message только у самого верхнего коммита, так как все объединяющиеся коммиты свои сообщения потеряли бы.

После успешного завершения процесса rebase мы увидим сообщение о его результатах:

[detached HEAD 15433c0] better commit 2 message
 Date: Sun Apr 30 15:46:20 2023 +0300
 1 file changed, 2 insertions(+)
[detached HEAD 578ddda] edited commit 3
 Date: Sun Apr 30 15:46:27 2023 +0300
 1 file changed, 1 insertion(+), 1 deletion(-)
Successfully rebased and updated refs/heads/feature-branch.

Можно убедиться, что история теперь выглядит так, как мы и задумывали. А именно: первый коммит изменения не затронули, у второго изменился commit message, четвёртый был объединён с третьим с сохранением commit messages у обоих, а пятый был отброшен:

$ git log --graph --decorate --all
* commit 578dddaf08cba7707decf79446942b4cd962c766 (HEAD -> feature-branch)
| Author: John Doe <john_doe@gmail.com>
| Date:   Sun Apr 30 15:46:27 2023 +0300
|
|   edited commit 3
|
|   edited squashed commit 4
|
* commit 15433c09b140710f9b8347fcb1b48284c9f80cd6
| Author: John Doe <john_doe@gmail.com>
| Date:   Sun Apr 30 15:46:20 2023 +0300
|
|   better commit 2 message
|
* commit fb7ab419cb936c099def554f07131c34a8044ce9 (main)
| Author: John Doe <john_doe@gmail.com>
| Date:   Sun Apr 30 15:28:44 2023 +0300
|
|   commit 1
Clean Git History, или Тёмная сторона VCS - 7

Дополнительные приёмы

Вкратце расскажу ещё о паре инструментов, которые могут быть полезны, в том числе и для чистой истории. Например, зная, как безопасно хранить изменения кода, можно более осознанно подходить к созданию чистовых коммитов.

Stash

Документация: 

Бывает, работаешь над новым функционалом, — и вдруг срочно нужно исправить что-то в другой ветке. В такой ситуации (и во многих других) на помощь придёт команда stash.

Она позволяет убирать изменённые файлы в специальное место внутри директории .git на временное хранение. Таким образом, файлы репозитория примут состояние последнего коммита. Позже, когда понадобится, вы так же легко сможете применить к коду сохранённые ранее изменения.

Работает stash по принципу стека. Поэтому добавляться и применяться её элементы будут в соответствующем порядке (LIFO).

Создание элемента stash:

# создадим элемент stash
$ git stash 

# создадим элемент stash с сообщением (это считается хорошей практикой) — прямо как commit message 
$ git stash save "some changes message"

Просмотр элементов stash:

# посмотрим, какие изменения лежат в stash-стеке
$ git stash list

stash@{0}: On feature-branch: some changes message
stash@{1}: On feature-branch: refactoring in process
stash@{2}: On feature-branch: almost implemented new feature

Применение элемента stash:

# применим изменения верхнего элемента stash
$ git stash pop

# применение изменения определённого элемента stash (индекс можно посмотреть в git stash list)
$ git stash pop stash@{2}

# создание патча на основе изменения элемента stash (про файлы patch см. ниже)
$ git stash show -p stash@{2} > my_feature.patch

Patch

Документация: 

Patch — это файл определённого формата, отражающий изменения кода, например разницу между локально измененным незакоммиченным кодом и кодом последнего коммита или между каким-либо коммитом и коммитом, предшествующим ему.

Такие файлы можно создавать, применять, а также, конечно, делиться ими с другими разработчиками.

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

Создание файла patch:

# создадим файл patch из локальных изменений
$ git diff > my_feature.patch

# создадим файл patch из изменений коммита
$ git format-patch [<commit>]

Применение файла patch:

# применим файл patch
$ git apply my_feature.patch

# применим файл patch одновременно с созданием коммита на основе изменений кода
# (будет работать только с файлом patch, созданным с помощью команды format-patch)
# флаг --signoff в истории Git указывает, кто применил patch
$ git am --signoff < my_feature.patch

Выводы

В завершение, хотелось бы еще раз кратко резюмировать, почему же так важно придерживаться чистой истории Git:

  • история изменений проекта будет понятна и логична;

  • в ней будет легко ориентироваться и при необходимости так же легко изменять;

  • в случае неполадок будет легче откатиться;

  • и в конце концов, история коммитов заслуживает права быть чистой ничуть не меньше, чем того заслуживает сам код!

Надеюсь, теперь наши истории Git станут немножечко чище, а мир — немножечко лучше :) Спасибо за внимание!

Clean Git History, или Тёмная сторона VCS - 8

Все приведённые в статье примеры историй Git являются вымышленными, однако, к сожалению, основанными на реальных событиях.

Полезные ресурсы

Автор: Мария Петрова

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js