Привет, Хабр! В этой статье я расскажу о Git hooks и о том, как они могут помочь с некоторыми насущными кейсами организации создания commit’ов и commit message. Пост основан на реальном опыте из моей практики: как я упрощал то, что всем надоело делать руками. Я уверен, что хуки могут оказаться полезны почти каждому разработчику. Ведь все мы пишем в сообщении коммита чуть больше, чем «fixed what was broken», верно?

Обо мне

Меня зовут Роман Горбатенко, я Java-разработчик в компании DINS, на момент написания текста тружусь в команде Contact Center. Занимаюсь разработкой больше 3-х лет и прошел путь от личинки стажера до middle девелопера. Считаю Git одним из самых полезных инструментов. Многие не используют его возможности на полную, — надеюсь, мне удастся это немного исправить.

Итак, о проблеме, для затравки

На прошлом месте работы релизный процесс включал в себя ручной сбор commit’ов, относящихся к задачам, которые входят в этот самый релиз (не правда ли, здорово?). В связи с этим, команда корабля решила облегчить себе жизнь и завести правило, по которому commit message должен обязательно (!) предваряться префиксом с названием ветки. 

Известно, что любое повторяющееся действие порождает случайные ошибки. И вообще, программисты не любят в повторение, мы любим в оптимизацию. Выход был найден — работу по добавлению префикса возложили на механизм Git hooks.

Ниже я расскажу, как создать prepare-commit-msg хук, немного опишу типы хуков и принципы их работы для тех, кто не знал забыл, расскажу, как завести наконец правило написания сообщений commit’ов и даже его соблюдать. Помимо этого, приведу несколько полезных штук, которые я подметил в процессе исследования и дам ссылки на источники, которые меня вдохновляли.

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

О самих Git hooks и основных типах

Если вкратце, hook — кастомный скрипт, выполняющийся до или после событий вроде commit, push. Вот и все, действительно очень просто. Каждый проект после инициализации содержит папку .git/hooks, в которой Git ищет скрипты для выполнения при наступлении событий.  

Хуки разделяются на клиентские (локальные) и серверные (удаленные). Локальные хуки не затрагивают мою команду, и их можно оптимизировать под себя как угодно. 

На схеме ниже показано время действия части локальных и серверных хуков относительно основных событий — commit и push.

Локальные хуки бывают нескольких типов и срабатывают после создания commit’a:

  1. pre-commit выполняется каждый раз при вызове git commit. На этом этапе можно выполнять различные проверки commit’ов.

  2. prepare-commit-msg выполняется после pre-commit и позволяет работать с commit message (круто, то, что надо!)

  3. commit-msg похож на prepare-commit-msg, но вызывается после того, как вы ввели commit message. Это, например, хорошая возможность высветить предупреждение разработчикам о том, что сообщение commit’a не соответствует принятым стандартам.

  4. post-commit вызывается сразу после commit-msg хука. К примеру, так можно триггерить отправку письма начальнику после каждого commit’a.

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

В статье я последовательно рассмотрю prepare-commit-msg и commit-msg.

Думаю, теории хватит. Наша цель практическая, больше информации вы сможете найти самостоятельно или по ссылкам на источники в конце статьи.

Про Git hooks на Windows

В конце статьи будет дана ремарка на счет работы хуков на Windows, поскольку запуск скриптов написанных на Python немного отличается. За исключением этого момента, содержание статьи верно и для хуков на Windows

Если в процессе прочтения вам не захочется разбираться, как устроены хуки, или удобнее смотреть в код целиком, переходите в GitHub репозиторий с кодом из этой статьи. 

Перейдем к имплементации

prepare-commit-msg hook

Для начала нужно определиться, на каком языке можно/нужно писать скрипты, которые будут исполняться как хуки. Строго говоря, писать можно на любом скриптовом языке, будь то Bash, Python, Ruby, Perl, Rust, Swift или Go.

Я пробовал Bash и Python, последний зашел больше, т.к. Bash крайне тяжело поддается с наскока, без опыта чтения/писания на нем. В статье приведены скрипты именно на Python, поскольку я могу ручаться за то, что в них написано. 

Обозначать, на каком языке написан скрипт, я буду специальным символом шебанг (#!), за которым следует путь до интерпретатора конкретного языка. В случае с Mac или Linux стандартный путь к интерпретатору для Python представлен /usr/bin.

Итого получается:

#!/usr/bin/python3

Для Bash путь чуть другой:

#!/bin/bash

Уже почти время написать наш хук. 

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

Ниже я реализую скрипт, который из ветки вида «ABC-123-bugfix» вытащит префикс вида «ABC-123», затем из commit message вида «initial commit message» сделает итоговое сообщение «[ABC-123] initial commit message».

Механизм работы моего prepare-commit-msg hook

Для лучшего понимания идеи ниже приведена схема, которая повторяет формулировку задачи:

Ниже находится листинг самого скрипта prepare-commit-msg hook:

1    #!/usr/bin/python3
2  
3    import re
4    import sys
5    from subprocess import check_output
6  
7    commit_msg_filepath = sys.argv[1]
8    branch = (
9        check_output(["git", "symbolic-ref", "--short",  
10   "HEAD"]).decode("utf-8").strip()
11   )
12
13   regex = r"^[A-Z]{1,9}-[0-9]{1,9}"
14
15   found_obj = re.match(regex, branch)
16
17   if found_obj:
18       prefix = found_obj.group(0)
19       with open(commit_msg_filepath, "r+") as f:
20           commit_msg = f.read()
21           if commit_msg.find(prefix) == -1:
22               f.seek(0, 0)
23               f.write(f"[{prefix}] {commit_msg}")

Разберу построчно, что же тут происходит. 

  • Line 1 — шебанг.

  • Lines 3-5 — блок импортов.

  • Line 7 — получение файла, в который сохраняется изначальное commit message.

  • Lines 8-10 — получаем имя ветки.

  • Line 13 — регулярное выражение, по которому я сверяю, что имя ветки содержит название Jira-тикета вида ABC-123.

  • Lines 15 — по регулярке получаем сам префикс (ветка может помимо тикета содержать что-то еще, отсекаем лишнее).

  • Lines 17-18 — читаем текст commit message.

  • Line 21 — проверяю, что сообщение коммита уже не содержит префикс, который я собираюсь добавлять.

  • Line 22 — устанавливаем «каретку»  в начало текстового файла, у нас ведь префикс.

  • Line 23 — заключаем наш префикс в [ ] перед исходным сообщением.

Дополнительно отмечу лишь один момент — проверка на наличие префикса необходима, поскольку редактирование commit message средствами той же Intellij Idea приводит к повторному появлению префикса. Я совсем этого не хочу.

Результат выполнения prepare-commit-msg hook

Создам commit с сообщением и увижу в результате префикс, который добавлен хуком:

(base) ➜  python_git_hooks git:(ABC-123) ✗ git commit -m "my commit message"
[ABC-123 c80a30a] [ABC-123] my commit message
 1 file changed, 1 insertion(+), 1 deletion(-)

Результат команды git log для последнего commit:

commit c80a30a163ac08cbdcb7a345b91823870dc8b184 (HEAD -> ABC-123)
Author: Elanlum <email@example.com>
Date:   Thu Oct 21 12:11:56 2021 +0300

    [ABC-123] my commit message

commit-msg hook

На примере данного хука я продемонстрирую еще один способ автоматизации commit message. 

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

С этой задачей отлично справится Git hook — он будет сообщать разработчикам об ошибке в случае, если они забыли добавить в сообщение ключевое слово.

#!/usr/bin/python3

import re
import sys

green_color = "\033[1;32m"
red_color = "\033[1;31m"
color_off = "\033[0m"
blue_color = "\033[1;34m"
yellow_color = "\033[1;33m"

commit_msg_filepath = sys.argv[1]

regex = r"Add: |Created: |Fix: |Update: |Rework:"
error_msg = "Commit message format must match regex " + regex

with open(commit_msg_filepath, "r+") as file:
    commit_msg = file.read()
    if re.search(regex, commit_msg):
        print(green_color + "Good Commit!" + color_off)
    else:
        print(red_color + "Bad commit " + blue_color + commit_msg)
        print(yellow_color + error_msg)
        print("commit-msg hook failed (add --no-verify to bypass)")
        sys.exit(1)

Механизм работы: проверяем наличие ключевого слова в тексте сообщения коммита. Если совпадение найдено, хвалим программиста, если нет, напоминаем ему о забывчивости и отменяем создание коммита.

Результат выполнения commit-msg hook

В случае отсутствия одного из «тэгов» хук сообщает, что нам нужно его добавить и НЕ создает коммит. Также он сообщает, что этот механизм можно обойти.

➜  git commit -m "text of commit"

Bad commit [ABC-123] text of commit
Commit message format must match regex Add: |Created: |Fix: |Update: |Rework:
commit-msg hook failed (add --no-verify to bypass)

В случае, если тэг присутствует, хук хвалит разработчика за заботу о нервах тимлида.

➜  git commit -m "Rework: text of commit"

Good Commit!
[ABC-123 e993c7a] [ABC-123] Rework: text of commit
 1 file changed, 1 deletion(-)

Включаем хуки

Как я уже отмечал выше, работа Git hooks на Windows немного отличается, потому рассмотрим в отдельности системы.

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

Mac OS и Linux

1. Сделать файл хука исполняемым. Это очень просто:

chmod +x /path_to_hooks/hook_name

2. Поместить файл хука в папку проекта или общую папку.

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

A. Поместить их в специальную директорию .git/hooks в каждый (!) проект. Эта директория уже содержит файлы хуков с расширением .sample. Достаточно убрать .sample и вставить в файл скрипт, как все заработает.

Еще раз: Git hook должен называться точно так же, как .sample файл, но не иметь расширения. Проще всего заменить содержимое .sample файла и затем убрать расширение

Можно представить, как это неудобно — каждый раз для нового проекта добавлять хуки заново. Я вроде бы хотел избавиться от повторяющихся действий, разве нет?

B. Существует и второй способ, более элегантный, если подразумевается, что ваши хуки применимы для всех проектов. Нужно добавить в глобальный конфиг Git параметр core.hooksPath, значение которого содержит путь до глобальной папки с хуками, откуда Git будет все тянуть в первую очередь.

Команда довольно простая и многим знакомая:

git config --global core.hooksPath /your_path_to_hooks_folder

Проверим, что конфиг сохранился:

git config --global --list

И увидим в списке глобальных переменных что-то вроде:

core.hookspath=/usr/local/.../git/hooks

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

3. Git hooks добавлены и готовы к работе. Вы восхитительны!

Windows

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

Выход есть — можно сделать shell скрипт, который будет запускать Python скрипт. Нужно всего-то несколько действий:

1. Сделать собственно сам shell скрипт:

#!/bin/sh
COMMIT_MSG_FILE=$1
python .git/hooks/prepare-commit-msg.py "$COMMIT_MSG_FILE"

Нам нужен соответствующий шебанг - #!/bin/sh

Аргумент COMMIT_MSG_FILE содержит путь до временного текстового файла, который содержит commit message, нам нужно передать этот путь дальше Python скрипту, который выполнит основную работу.

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

3. Нужно удалить шебанг для интерпретатора Python из скрипта (например, у меня это prepare-commit-msg.py) и поместить сам Python файл рядом с самим Git hook.

Полезные замечания

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

  • Может случиться так, что программист не будет знать, что у него под капотом орудует хук и что-то правит. 

  • Пути до интерпретаторов разные на разных системах (Python на Windows запросто может не быть), значит нельзя гарантировать, что хуки будут работать у всех разработчиков в команде из коробки.

  • Добавлять хуки во много проектов = превратить их поддержку в итеративный ад (я все еще хочу избежать повторяющихся действий).

Думаю, читатель уже догадался, что для работы Git hooks на Windows (и некоторых сборок Linux) понадобится установить Python 3, на Mac OS он имеется по умолчанию. Проверял работоспособность на версии 3.9, но и с более ранними проблем не возникнет.

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

Полезные ссылки

Вместо заключения

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

Комментарии (17)


  1. Ryzhyj
    21.10.2021 15:40

    даёшь статейку в топ хабра!


  1. alexac
    21.10.2021 18:27
    +2

    Стоит еще особо отметить, что использовать большинство хуков и тем более каким-нибудь скриптом сборки включать их для всех и каждого — плохая идея. Большинство хуков вызываются не только в тот момент, который ожидает автор хука, но и в еще и большом количестве неочевидных моментов. В гипотетической ситуации, когда кто-то добавляет в pre-commit хук вызов линтера, он практически гарантировано в какой-либо момент ломает кому-нибудь rebase. А если вместо линтера вызывается реформаттер, то это зачастую провоцирует гору конфликтов на каждый коммит затронутый ребэйзом. Ограничения на сообщение еще выглядят разумно, но что-то сложнее туда лучше не запихивать — слишком сложно отловить все моменты, когда это будет стрелять кому-нибудь в ногу.


    1. Elanlum Автор
      21.10.2021 18:28

      Спасибо за ценный совет!


    1. Yser
      22.10.2021 04:16

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

      А можете подробнее про вот это и какие потенциальные шаги в стороны могут быть?

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


      1. vindi
        22.10.2021 09:48
        +2

        Именно линтер эффективно ставить на препуш хук. Плюсы - при локальном взаимодействии ничего не сломается. Минусы - пуш станет операцией не мгновенной.

        Я у себя в проекте поставил линтер в сборке на MR. Если есть ошибка то её вижу в пайплайне реквеста и просто такие мр откладывются до исправления ошибок. Выбор подхода зависит от политики работы в команде.


        1. alexandrtumaykin
          22.10.2021 15:54

          аналогично поступил


    1. tabtre
      22.10.2021 09:58

      В гипотетической ситуации, когда кто-то добавляет в pre-commit хук вызов линтера, он практически гарантировано в какой-либо момент ломает кому-нибудь rebase


      А как линтер сломает rebase? он же не меняет код и не создает новых коммитов


      1. alexac
        22.10.2021 10:26

        В процессе ребэйза по факту множество раз происходит 3-way merge, который не обязан выдавать результат, который понравится линтеру. Особенно если где-нибудь в цепочке коммитов возникал конфликт. Гораздо правильнее при этом разрешить конфликт некрасиво, закончить ребэйз, и потом починить стиль, чем удовлетворить в какой-то момент линтер и чинить этот конфликт в каждом последующем коммите. С линтером в хуке, мы починили конфликт, продолжаем ребэйз, и в каждом коммите натыкаемся либо на линтер, либо на конфликты, либо на необходимость выключить линтер.


        1. tabtre
          22.10.2021 11:54

          А у вас линтер не дает запушить коммит если он не удовлетворяет требованиям? я предполагал он просто отчет делает


  1. vindi
    22.10.2021 09:43

    Увидел в статье регулярку по глаголам:

    regex = r"Add: |Created: |Fix: |Update: |Rework:"

    И думаю - знакомая история. А потом вспоминаю, так это же соглашение об именования коммитов!

    https://www.conventionalcommits.org/en/v1.0.0/

    Я тоже использую хуки гита, но в качестве инструмента проверки сообщения пользуюсь тем, что доступны по ссылке выше. Стандартизация таких вещей сильно помогает не захламлять историю коммитов в гит. А за счёт того что инструмент поддерживается комьюнити - риск словить баг работы скрипта (повешенного на хук) снижается.


    1. Elanlum Автор
      22.10.2021 11:37

      Очень интересная штуковина! Я так понял, это просто соглашение, которое используется разными имплементациями - например плагином для Intelij IDEA или Python gitlint, который по сути и является commit-msg хуком, который я тут описал.

      Что еще из раздела Tooling for Conventional Commits вы используете? Там так много всего


      1. vindi
        23.10.2021 13:31

        Основной стек это javascript, поэтому использую те вещи которые к нему относятся (husky, commitlint, etc). И ченджлоги удобные формировать легче через спец скрипты, уже написанные сообществом


    1. alexandrtumaykin
      22.10.2021 15:57

      у себя на работе автоматизировал создание тегов и наполнение changelog, через скрипт в CI, который работает по этому соглашению


      1. Yser
        22.10.2021 19:18

        а можете статью, хотя бы небольшую? Очень интересно


        1. alexandrtumaykin
          22.10.2021 19:45

          интересное предложение ) попробую написать


          1. Yser
            23.10.2021 19:32

            подписался, буду ждать)


            1. alexandrtumaykin
              27.10.2021 19:24

              Проба пера ) https://habr.com/ru/post/585422/