Git. Автоматическая проверка сообщения коммита на стороне сервера с помощью Python

  • Tutorial

Целевая аудитория, мотивация


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

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

Перехватчики Git


Система Git предоставляет богатый набор перехватчиков (hooks), позволяя запускать в нужные моменты пользовательские сценарии, как на стороне сервера, так и на клиентской стороне. Если говорить о моменте выполнения команды push на сервере, то за это отвечает файл update в подкаталоге hooks каталога репозитория. Этот файл запускается системой для каждой проталкиваемой на сервер ветки.

На вход перехватчик update принимает параметры:
  • ссылка на ветку, в которую происходит push;
  • ссылка на последний коммит в ветке, в которую происходит push;
  • ссылка на последний коммит, присланный в ветку.

На выходе перехватчик должен вернуть код завершения: 0 — разрешить push, 1 — запретить push. При этом весь вывод сценария в стандартный поток вывода возвращается клиенту.

Постановка задачи


Требования к формату сообщения сформированы под воздействием обоснованных мнений в Сети и исходя из принятого в компании процесса разработки.

Описываются они следующим шаблоном:
projectName-taskId action annotation

detailsString1
detailsString2
...
detailsStringN

Пояснения:
  • projectName-taskId — ссылка на issue в Jira;
  • action — краткое обозначение сути коммита с помощью слов из списка допустимых вариантов: feature, fix, style, refactor и т.д. Если к коммиту по смыслу подходят два и более варианта, то слова разделяются слэшем (например, fix/refactor);
  • annotation — краткое описание изменений в коммите;
  • максимальная длина первой строки равняется 100 символам;
  • details — необязательная секция. Это подробное описание изменений в коммите (максимальная длина строк — 80 символов). Эта секция сообщения может быть разбита на блоки, между которыми ставится пустая строка;
  • между annotation и details обязательно должна быть пустая строка.

Исходя из требований к формату вырисовывается несложный алгоритм:
  • получение списка всех коммитов, которые были присланы командой push (это можно сделать с помощью команды git rev-list);
  • для каждого коммита — получение сообщения (с помощью команды git cat-file и потокового текстового редактора sed) и проверка формата (с помощью регулярного выражения и проверок строк на длину);
  • возврат кода завершения и, при необходимости, сообщения с пояснением, почему push был отклонен.

Стандартная библиотека Python, кодирование


В данном разделе приводятся лишь краткие фрагменты кода для иллюстрации использования стандартной библиотеки языка и команд Git. Ссылку на полную версию сценария можно найти в конце текста.

Главные строки сценария:
import sys
...
if __name__ == "__main__":
    sys.exit(main())

Каждый .py-файл является модулем — набором данных, пользовательских типов и функций. __name__ — это встроенный атрибут модуля, в случае запуска сценария из командной строки этот атрибут устанавливается равным специальному значению __main__. В случае импортирования модуля другим модулем __name__ будет содержать имя импортируемого файла. Благодаря приведенному выше условному выражению обеспечивается возможность использования файла и как модуля, и как самостоятельного сценария. sys.exit() возвращает код завершения сценария, который в свою очередь возвращается функцией main(), содержащей основную логику.

Далее реализация функции для выполнения консольных команд:
import subprocess
...
def runBash(commandLine):
    process = subprocess.Popen(commandLine, shell=True, stdout=subprocess.PIPE)
    out = process.stdout.read().strip()
    return out

subprocess.Popen() создает дочерний процесс, запуская на выполнение программу, информация о которой передана в аргументах. В данном случае запускается стандартная командная оболочка (bash по умолчанию для Unix-систем), ей передается на выполнение строка commandLine, текстовый результат выполнения команды направляется в открываемый дочерним процессом канал, содержимое которого возвращается функцией. strip() возвращает копию строки без ведущих и завершающих пробельных символов.

Теперь, используя функцию runBash(), достаточно просто получить список коммитов:
import sys
...
COMMAND_LIST = "git rev-list {}..{}"
...
def main():
    refOld = sys.argv[2]
    revNew = sys.argv[3]
    commits = runBash(COMMAND_LIST.format(refOld, revNew)).split("\n")
    ...
    for commit in commits:
        ...

В массиве sys.argv содержатся передаваемые Git аргументы командной строки. С помощью мощной функции format() в данном случае происходит подстановка аргументов в строку.

Имя проекта удобно хранить в настройках Git, потому что проектов может быть много (соответственно, и git-репозиториев), и прописать имя константой в коде сценария не получится. Чтобы установить имя проекта для репозитория достаточно выполнить команду git config --add project.name HABR

Тогда функция для получения имени проекта будет выглядеть следующим образом:
COMMAND_PROJECT_NAME = "git config project.name"
...
def getProjectName():
    return runBash('git config project.name')

Функция для проверки отдельного коммита:
COMMAND_COMMIT_MESSAGE = "git cat-file commit {} | sed '1,/^$/d'"
...
def checkCommit(hash):
    commitMessage = runBash(COMMAND_COMMIT_MESSAGE.format(hash))
    return checkMessage(commitMessage)

Проверка первой строки сообщения к коммиту с помощью регулярного выражения:
import re
...
def checkFirstLine(line):
    ...
    expression = r"^({0}\-\d+ )?({1})(\/({1}))* .*".format(
        getProjectName(), AVAILABLE_ACTIONS
    )
    if not re.match(expression, line):
        ...

И последний нюанс. Сценарий предназначен для запуска интерпретатором Python версии 2.7, а в git-репозитории используется кодировка UTF-8. Чтобы совместить два этих обстоятельства первые строки файла должны выглядеть так:
#!/usr/local/bin/python
# -*- coding: utf-8 -*-

А проверка длин строк осуществляется с помощью decode():
if len(line.decode("utf-8")) > LENGTH_MAX:
    ...

Тестирование, совершенствование


В первый же день обкатки реализованного перехватчика во время одной из попыток сделать push было получено следующее сообщение об ошибке:
fatal: Invalid revision range 0000000000000000000000000000000000000000..b12e460740edf4ea41984a676834bee71479aa52

Коммиты были оформлены правильно, особенность заключалась в том, что на сервер проталкивалась новая ветка. Команда git rev-list на это не рассчитана, пришлось обрабатывать ситуацию особым образом:
import sys
...
COMMAND_LIST = "git rev-list {}..{}"
COMMAND_FOR_EACH = "git for-each-ref --format='%(objectname)' 'refs/heads/*'"
COMMAND_LOG = "git log {} --pretty=%H --not {}"
...
ref = sys.argv[1]
refOld = sys.argv[2]
revNew = sys.argv[3]
if refOld == REF_EMPTY:
    headList = runBash(COMMAND_FOR_EACH)
    heads = headList.replace(ref + "\n", "").replace("\n", " ")
    commits = runBash(COMMAND_LOG.format(revNew, heads)).split("\n")
else:
    commits = runBash(COMMAND_LIST.format(refOld, revNew)).split("\n")

Однако этого оказалось недостаточно в случае, когда проталкивалась новая ветка без коммитов. В этом случае сообщение об ошибке выглядело так:
usage: git cat-file (-t|-s|-e|-p|<type>|--textconv) <object>
or: git cat-file (--batch|--batch-check) < <list_of_objects>

Для исправления необходимо завершать сценарий с успешным кодом завершения в случае отсутствия коммитов:
for commit in commits:
    if len(commit) == 0:
        sys.exit(0)

Заключение


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

Программное окружение, в котором работает сценарий:
  • FreeBSD 9.1 amd64
  • Python 2.7.3
  • Git 1.8.2

Ссылки



P.S.


Начинающие авторы всегда оговариваются о факте первого поста на Хабре и просят направлять сообщения насчет огрехов оформления текста в личку. Сделал это и я. :)

В комментариях буду рад замечаниям по коду, а также информации о том, как портировать сценарий на Python 3 (нужно ли нечто большее, чем убрать # -*- coding: utf-8 -*- и вызовы decode()?).
Метки:
Поделиться публикацией
Похожие публикации
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 15
  • +1
    Автоматическая проверка сообщения коммита в моей практике, привела к росту коммитов с текстом типа «111111» ;)
    • 0
      Тут всё дело в отлаженности процесса. У нас в компании за пустые или бестолковые коментарии к коммитам просто коллеги бьют канделябрами.
      • 0
        тоже так решили в итоге, но я про эффект «только автоматического контроля»
    • +8
      Смысл было городить питон, вы на питоне «эмулируете» баш :)
      • +1
        Чтобы с чего-то начинать освоение. Кстати, может быть приведете примеры задач, когда баша уже не хватает и есть смысл использовать Python? Спасибо.
      • +1
        runBash = subprocess.check_output
        • +3
          Подставлять параметры в командную строчку через .format — ужасно. Если в параметре попадется пробел или более страшные символы (например если какая-то команда сфейлится и выдаст не хэш коммита, а ошибку), может быть много неожиданностей. Нужно передавать команду списком типа [«git», «log», param, "--pretty"].
        • 0
          Формат и смысл комментариев к коммитам очень важен. Но предоставленное решение немного кардинально. На сколько я понимаю, «официально» поменять в Git можно только последний коммит, и то желательно до отсылки на сервер.

          Но вот что делать при описанном подходе если программист шлет разом 5 коммитов на сервер, и часть из них не попадают под формат? Отказывать в действии — тратить время программиста на исправление старых коммитов, а это обычным --amend не делается.

          При использовании подобной автоматики, я бы отказывал только в случае ошибки в последнем коммите. А о всех проблемных предыдущих слал оповещение ответственным людям, что бы провели воспитательные работы с автором коммитов :)
          • +2
            «официально» поменять в Git можно только последний коммит

            Почему же? Сообщение можно поменять у любого количества коммитов. В простейшем случае помогает git reset, в более запущенных — git rebase --interactive

            и то желательно до отсылки на сервер

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

            если программист шлет разом 5 коммитов

            Если часть из этих коммитов не пройдет проверку, то программисту будет выдано подробное сообщение, в каких коммитах (выводится хэш) и в каких из строчек сообщений проблемы. После чего он поправит все и снова сделает push.
            • –3
              Да, но --amend для изменения последнего коммита придуман, а rebase и прочее, это уже действие в обход. Сообщение поменять можно, но не естественным путем — workaround.

              Я не пользовался rebase в своем опыте, но подозреваю, что частые игры с rebase глядишь и приведут к правильному комментарию в коммите, но потерянному куску кода.
            • +1
              Менять можно любые коммиты. Каждое изменение — фактически новый коммит, т.к. меняется SHA, ничего страшного.

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

              Поменять месседжи 10 последних коммитов просто — git rebase HEAD~10, нужным комитам поставили «edit» вместо «pick», быстро пробежались и все поправили :)
              • +1
                Может не хватает опыта у меня с advanced Git, что бы отнестись к этому подходу спокойно. Но звучит убедительно, я обращу внимание на такой подход ;)
            • 0
              В моей прошлой конторе мы отправляли на сервер только по одному коммиту, который получался сквошем. Ну и была проверка на правильность комментариев в том числе.
            • +2
              Логично было бы дополнить аналогичным скриптом в `pre-commit` хуке, чтобы он предупреждал о некорректном формате сообщения.

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