Целевая аудитория, мотивация
Надеюсь, что пост окажется полезным для тех, кто на среднем уровне знаком с 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
Ссылки
- Полный код сценария на Github;
- Книга Pro Git, глава 8;
- Одно из мнений об оформлении коммитов;
- О протакливании пустой ветки на StackOverflow;
- О регулярных выражениях в Python можно почитать сначала на Хабре, затем в официальной документации;
- Стандартная библиотека Python.
P.S.
Начинающие авторы всегда оговариваются о факте первого поста на Хабре и просят направлять сообщения насчет огрехов оформления текста в личку. Сделал это и я. :)
В комментариях буду рад замечаниям по коду, а также информации о том, как портировать сценарий на Python 3 (нужно ли нечто большее, чем убрать # -*- coding: utf-8 -*-
и вызовы decode()
?).
Автор: nicronom