DoneSpeak

在Git项目中使用pre-commit统一管理hooks

字数: 3.8k时长: 18 min
2021/06/15 Share

Unix 哲学

提供”锋利“的小工具、其中每一把都意在把一件事情做好。

–《程序员修炼之道 - 从小工到专家》

写在前面

如果你使用Git,那你一定懂得纯文本的魅力并喜爱上shell这样的脚本语言。

在很多时候,我更喜欢能够通过脚本语言进行配置的工具,而不是直接安装到编辑器的工具。一是因为脚本可以放在项目中与更多的人共享,以保持规范一直;二是脚本自动触发的操作无需要记更多的快捷键或者点击一点鼠标;再来则是脚本语言可以做更多灵活的操作,而不受软件开发者的约束。这大概也是我一直喜欢用Git指令,而不是编译器提供给我的Git工具。

本文将继续讲解git hooks,介绍一款能够帮助我们更好地管理和利用git hooks的工具。期望找到的工具有如下的功能:

  • 只需要提供配置文件,自动从中央hooks仓库获取脚本
    • 如果有多个项目,就不需要再每个项目都拷贝一份hooks了
  • 可以定义本地脚本仓库,允许开发人员自定义脚本,且无需修改配置文件
    • 开发人员会有一些脚本以完成的自定义操作
    • 无需修改配置文件是指可以直接指向一个目录,并执行里面的所有hooks或者指定一个无需上传到git的本地配置文件
  • 每个阶段允许定义多个脚本
    • 多个脚本可以使得功能划分而无需整合到一个臃肿的文件中
  • 脚本支持多种语言

pre-commit 概要

不要被这个pre-commit的名字迷惑,这个工具不仅仅可以在pre-commit阶段执行,其实可以在git-hooks的任意阶段,设置自定义阶段执行,见的stages配置的讲解。(这个名字大概是因为他们开始只做了pre-commit阶段的,后续才拓展了其他的阶段)。

安装pre-commit

在系统中安装pre-commit

1
2
3
4
5
6
7
brew install pre-commit
# 或者
pip install pre-commit

# 查看版本
pre-commit --version
# pre-commit 2.12.1 <- 这是我当前使用的版本

在项目中安装pre-commit

1
2
3
4
cd <git-repo>
pre-commit install
# 卸载
pre-commit uninstall

按照操作将会在项目的.git/hooks下生成一个pre-commit文件(覆盖原pre-commit文件),该hook会根据项目根目录下的.pre-commit-config.yaml执行任务。如果vim .git/hooks/pre-commit可以看到代码的实现,基本逻辑是利用pre-commit文件去拓展更多的pre-commit,这个和我上一篇文章的逻辑是类似的。

安装/卸载其他阶段的hook。

1
2
3
4
5
6
pre-commit install
pre-commit uninstall
-t {pre-commit,pre-merge-commit,pre-push,prepare-commit-msg,commit-msg,post-checkout,post-commit,post-merge}
--hook-type {pre-commit,pre-merge-commit,pre-push,prepare-commit-msg,commit-msg,post-checkout,post-commit,post-merge}

# 如 pre-commit install --hook-type prepare-commit-msg

常用指令

1
2
3
4
5
6
7
8
# 手动对所有的文件执行hooks,新增hook的时候可以执行,使得代码均符合规范。直接执行该指令则无需等到pre-commit阶段再触发hooks
pre-commit run --all-files
# 执行特定hooks
pre-commit run <hook_id>
# 将所有的hook更新到最新的版本/tag
pre-commit autoupdate
# 指定更新repo
pre-commit autoupdate --repo https://github.com/DoneSpeak/gromithooks

更多指令及指令参数请直接访问pre-commit官方网站。

添加第三方hooks

1
2
3
cd <git-repo>
pre-commit install
touch .pre-commit-config.yaml

如下为一个基本的配置样例。

.pre-commit-config.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 该config文件为该项目的pre-commit的配置文件,用于指定该项目可以执行的git hooks

# 这是pre-commit的全局配置之一
fail_fast: false

repos:
# hook所在的仓库
- repo: https://github.com/pre-commit/pre-commit-hooks
# 仓库的版本,可以直接用tag或者分支,但分支是容易发生变化的
# 如果使用分支,则会在第一次安装之后不自动更新
# 通过 `pre-commit autoupdate`指令可以将tag更新到默认分支的最新tag
rev: v4.0.1
# 仓库中的hook id
hooks:
# 定义的hook脚本,在repo的.pre-commit-hooks.yaml中定义
- id: check-added-large-files
# 移除尾部空格符
- id: trailing-whitespace
# 传入参数,不处理makedown
args: [--markdown-linebreak-ext=md]
# 检查是否含有合并冲突符号
- id: check-merge-conflict
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.0.0
hooks:
- id: pretty-format-yaml
# https://github.com/macisamuele/language-formatters-pre-commit-hooks/blob/v2.0.0/language_formatters_pre_commit_hooks/pretty_format_yaml.py
# hook脚本需要的参数,可以在该hook脚本文件中看到
args: [--autofix, --indent, '2']

run之后,pre-commit会下载指定仓库代码,并安装配置所需要的运行环境。配置完成之后可以通过pre-commit run --all-files运行一下添加的hooks。下表为.pre-commit-hooks.yaml可选配置项。

key word description
id the id of the hook - used in pre-commit-config.yaml.
name the name of the hook - shown during hook execution.
entry the entry point - the executable to run. entry can also contain arguments that will not be overridden such as entry: autopep8 -i.
language the language of the hook - tells pre-commit how to install the hook.
files (optional: default '') the pattern of files to run on.
exclude (optional: default ^$) exclude files that were matched by files
types (optional: default [file]) list of file types to run on (AND). See Filtering files with types.
types_or (optional: default []) list of file types to run on (OR). See Filtering files with types. new in 2.9.0.
exclude_types (optional: default []) the pattern of files to exclude.
always_run (optional: default false) if true this hook will run even if there are no matching files.
verbose (optional) if true, forces the output of the hook to be printed even when the hook passes. new in 1.6.0.
pass_filenames (optional: default true) if false no filenames will be passed to the hook.
require_serial (optional: default false) if true this hook will execute using a single process instead of in parallel. new in 1.13.0.
description (optional: default '') description of the hook. used for metadata purposes only.
language_version (optional: default default) see Overriding language version.
minimum_pre_commit_version (optional: default '0') allows one to indicate a minimum compatible pre-commit version.
args (optional: default []) list of additional parameters to pass to the hook.
stages (optional: default (all stages)) confines the hook to the commit, merge-commit, push, prepare-commit-msg, commit-msg, post-checkout, post-commit, post-merge, or manual stage. See Confining hooks to run at certain stages.

开发hooks仓库

上面已经讲解了在项目中使用第三方的hooks,但有部分功能是定制化需要的,无法从第三方获得。这时候就需要我们自己开发自己的hooks仓库。

As long as your git repo is an installable package (gem, npm, pypi, etc.) or exposes an executable, it can be used with pre-commit.

只要你的git仓库是可安装的或者暴露为可执行的,它就可以被pre-commit使用。这里演示的项目为可打包的Python项目。也是第一次写这样的项目,花了不少力气。如果是不怎么接触的Python的,可以跟着文末的Packaging Python Projects ,也可以模仿第三方hooks仓库来写。

如下为项目的目录基本结构(完整项目见文末的源码路径):

1
2
3
4
5
6
7
8
9
10
├── README.md
├── pre_commit_hooks
│ ├── __init__.py
│ ├── cm_tapd_autoconnect.py # 实际执行的脚本
│ ├── pcm_issue_ref_prefix.py # 实际执行的脚本
│ └── pcm_tapd_ref_prefix.py # 实际执行的脚本
├── .pre-commit-hooks.yaml # 配置 pre-commit hooks entry
├── pyproject.toml
├── setup.cfg # 项目信息,配置hook entry point执行的脚本
└── setup.py

一个含有pre-commit插件的git仓库,必须含有一个.pre-commit-hooks.yaml文件,告知pre-commit插件信息。.pre-commit-hooks.yaml的配置可选项和.pre-commit-config.yaml是一样的。

.pre-commit-hooks.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 该项目为一个pre-commit hooks仓库项目,对外提供hooks

- id: pcm-issue-ref-prefix
name: Add issue reference prefix for commit msg
description: Add issue reference prefix for commit msg to link commit and issue
entry: pcm-issue-ref-prefix
# 实现hook所使用的语言
language: python
stages: [prepare-commit-msg]
- id: pcm-tapd-ref-prefix
name: Add tapd reference prefix for commit msg
description: Add tapd reference prefix for commit msg
entry: pcm-tapd-ref-prefix
# 实现hook所使用的语言
language: python
stages: [prepare-commit-msg]
# 强制输出中间日志,这里不做配置,由用户在 .pre-commit-config.yaml 中指定
# verbose: true
- id: cm-tapd-autoconnect
name: Add tapd reference for commit msg
description: Add tapd reference for commit msg to connect tapd and commit
entry: cm-tapd-autoconnect
# 实现hook所使用的语言
language: python
stages: [commit-msg]

其中中的entry为执行的指令,对应在setup.cfg中的[options.entry_points]配置的列表。

setup.cfg

1
2
3
4
5
6
...
[options.entry_points]
console_scripts =
cm-tapd-autoconnect = pre_commit_hooks.cm_tapd_autoconnect:main
pcm-tapd-ref-prefix = pre_commit_hooks.pcm_tapd_ref_prefix:main
pcm-issue-ref-prefix = pre_commit_hooks.pcm_issue_ref_prefix:main

以下是pcm-issue-ref-prefix对应的python脚本,该脚本用于根据branch name为commit message添加issue前缀的一个prepare-commit-msg hook。

pre_commit_hooks/pcm_issue_ref_prefix.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 根据分支名,自动添加commit message前缀以关联issue和commit。
#
# 分支名 | commit 格式
# --- | ---
# issue-1234 | #1234, message
# issue-1234-fix-bug | #1234, message

import sys, os, re
from subprocess import check_output
from typing import Optional
from typing import Sequence

def main(argv: Optional[Sequence[str]] = None) -> int:
commit_msg_filepath = sys.argv[1]

# 检测我们所在的分支
branch = check_output(['git', 'symbolic-ref', '--short', 'HEAD']).strip().decode('utf-8')
# 匹配如:issue-123, issue-1234-fix
result = re.match('^issue-(\d+)((-.*)+)?$', branch)
if not result:
# 分支名不符合
warning = "WARN: Unable to add issue prefix since the format of the branch name dismatch."
warning += "\nThe branch should look like issue-<number> or issue-<number>-<other>, for example: issue-100012 or issue-10012-fix-bug)"
print(warning)
return
issue_number = result.group(1)
with open(commit_msg_filepath, 'r+') as f:
content = f.read()
if re.search('^#[0-9]+(.*)', content):
# print('There is already issue prefix in commit message.')
return
issue_prefix = '#' + issue_number
f.seek(0, 0)
f.write("%s, %s" % (issue_prefix, content))
# print('Add issue prefix %s to commit message.' % issue_prefix)

if __name__ == '__main__':
exit(main())

这里用commit_msg_filepath = sys.argv[1]获取commit_msg文件的路径,当然,你也可以用argparse获取到。部分阶段的参数列表可以在pre-commit官网的install命令讲解中看到。

1
2
3
4
5
6
7
8
9
10
11
12
13
import argparse
from typing import Optional
from typing import Sequence

def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument('filename', nargs='*', help='Filenames to check.')
args = parser.parse_args(argv)
# .git/COMMIT_EDITMSG
print("commit_msg file is " + args.filename[0])

if __name__ == '__main__':
exit(main())

只要在需要配置的项目中按照如下配置.pre-commit-config.yaml即可使用。

1
2
3
4
5
6
7
8
repos:
- repo: https://github.com/DoneSpeak/gromithooks
rev: v1.0.0
hooks:
- id: pcm-issue-ref-prefix
verbose: true
# 指定hook执行的阶段
stages: [prepare-commit-msg]

本地hooks

pre-commit 也提供了local的hook,允许在entry中配置执行指令或指向本地一个可执行的脚本文件,使用起来和husky类似。

  • 脚本与代码仓库紧密耦合,并且与代码仓库一起分发。
  • Hooks需要的状态只存在于代码仓库的build artifact中(比如应用程序的pylint的virtualenv)。
  • linter的官方代码仓库没有提供pre-commit metadata.

local hooks可以使用支持additional_dependencies 的语言或者 docker_image / fail / pygrep / script / system

1
2
3
4
5
6
7
8
9
10
11
12
13
# 定义repo为local,表示该repo为本地仓库
- repo: local
hooks:
- id: pylint
name: pylint
entry: pylint
language: system
types: [python]
- id: changelogs-rst
name: changelogs must be rst
entry: changelog filenames must end in .rst
language: fail # fail 是一种用于通过文件名禁止文件的轻语言
files: 'changelog/.*(?<!\.rst)$'

自定义本地脚本

在文章开篇也有说到,希望可以提供一个方法让开发人员创建自己的hooks,但提交到公共代码库中。我看完了官方的文档,没有找到相关的功能点。但通过上面的local repo功能我们可以开发符合该需求的功能。

因为local repo允许entry执行本地文件,所以只要为每个阶段定义一个可执行的文件即可。下面的配置中,在项目更目录下创建了一个.git_hooks目录,用来存放开发人员自己的脚本。(可以注意到这里并没有定义出全部的stage,仅仅定义了pre-commit install -t支持的stage)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
- repo: local
hooks:
- id: commit-msg
name: commit-msg (local)
entry: .git_hooks/commit-msg
language: script
stages: [commit-msg]
# verbose: true
- id: post-checkout
name: post-checkout (local)
entry: .git_hooks/post-checkout
language: script
stages: [post-checkout]
# verbose: true
- id: post-commit
name: post-commit (local)
entry: .git_hooks/post-commit
language: script
stages: [post-commit]
# verbose: true
- id: post-merge
name: post-merge (local)
entry: .git_hooks/post-merge
language: script
stages: [post-merge]
# verbose: true
- id: pre-commit
name: pre-commit (local)
entry: .git_hooks/pre-commit
language: script
stages: [commit]
# verbose: true
- id: pre-merge-commit
name: pre-merge-commit (local)
entry: .git_hooks/pre-merge-commit
language: script
stages: [merge-commit]
# verbose: true
- id: pre-push
name: pre-push (local)
entry: .git_hooks/pre-push
language: script
stages: [push]
# verbose: true
- id: prepare-commit-msg
name: prepare-commit-msg (local)
entry: .git_hooks/prepare-commit-msg
language: script
stages: [prepare-commit-msg]
# verbose: true

遵循能够自动化的就自动化的原则。这里提供了自动创建以上所有阶段文件的脚本(如果entry指定的脚本文件不存在会Fail)。install-git-hooks.sh会安装pre-commit和pre-commit支持的stage,如果指定CUSTOMIZED=1则初始化.git_hooks中的hooks,并添加customized local hooks到.pre-commit-config.yaml

install-git-hooks.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#!/bin/bash

:<<'COMMENT'
chmod +x install-git-hooks.sh
./install-git-hooks.sh
# intall with initializing customized hooks
CUSTOMIZED=1 ./install-git-hooks.sh
COMMENT

STAGES="pre-commit pre-merge-commit pre-push prepare-commit-msg commit-msg post-checkout post-commit post-merge"

installPreCommit() {
HAS_PRE_COMMIT=$(which pre-commit)
if [ -n "$HAS_PRE_COMMIT" ]; then
return
fi

HAS_PIP=$(which pip)
if [ -z "$HAS_PIP" ]; then
echo "ERROR:pip is required, please install it mantually."
exit 1
fi
pip install pre-commit
}

touchCustomizedGitHook() {
mkdir .git_hooks
for stage in $STAGES
do
STAGE_HOOK=".git_hooks/$stage"
if [ -f "$STAGE_HOOK" ]; then
echo "WARN:Fail to touch $STAGE_HOOK because it exists."
continue
fi
echo -e "#!/bin/bash\n\n# general git hooks is available." > "$STAGE_HOOK"
chmod +x "$STAGE_HOOK"
done
}

preCommitInstall() {
for stage in $STAGES
do
STAGE_HOOK=".git/hooks/$stage"
if [ -f "$STAGE_HOOK" ]; then
echo "WARN:Fail to install $STAGE_HOOK because it exists."
continue
fi
pre-commit install -t "$stage"
done
}

touchPreCommitConfigYaml() {
PRE_COMMIT_CONFIG=".pre-commit-config.yaml"
if [ -f "$PRE_COMMIT_CONFIG" ]; then
echo "WARN: abort to init .pre-commit-config.yaml for it's existence."
return 1
fi
touch $PRE_COMMIT_CONFIG
echo "# 在Git项目中使用pre-commit统一管理hooks" >> $PRE_COMMIT_CONFIG
echo "# https://donespeak.gitlab.io/posts/210525-using-pre-commit-for-git-hooks/" >> $PRE_COMMIT_CONFIG
}

initPreCommitConfigYaml() {
touchPreCommitConfigYaml
if [ "$?" == "1" ]; then
return 1
fi

echo "" >> $PRE_COMMIT_CONFIG
echo "repos:" >> $PRE_COMMIT_CONFIG
echo " - repo: local" >> $PRE_COMMIT_CONFIG
echo " hooks:" >> $PRE_COMMIT_CONFIG
for stage in $STAGES
do
echo " - id: $stage" >> $PRE_COMMIT_CONFIG
echo " name: $stage (local)" >> $PRE_COMMIT_CONFIG
echo " entry: .git_hooks/$stage" >> $PRE_COMMIT_CONFIG
echo " language: script" >> $PRE_COMMIT_CONFIG
if [[ $stage == pre-* ]]; then
stage=${stage#pre-}
fi
echo " stages: [$stage]" >> $PRE_COMMIT_CONFIG
echo " # verbose: true" >> $PRE_COMMIT_CONFIG
done
}

ignoreCustomizedGitHook() {
CUSTOMIZED_GITHOOK_DIR=".git_hooks/"
GITIGNORE_FILE=".gitignore"
if [ -f "$GITIGNORE_FILE" ]; then
if [ "$(grep -c "$CUSTOMIZED_GITHOOK_DIR" $GITIGNORE_FILE)" -ne '0' ]; then
# 判断文件中已经有配置
return
fi
fi
echo -e "\n# 忽略.git_hooks中文件,使得其中的脚本不提交到代码仓库\n$CUSTOMIZED_GITHOOK_DIR\n!.git_hooks/.gitkeeper" >> $GITIGNORE_FILE
}

installPreCommit
if [ "$CUSTOMIZED" == "1" ]; then
touchCustomizedGitHook
initPreCommitConfigYaml
else
touchPreCommitConfigYaml
fi
preCommitInstall
ignoreCustomizedGitHook

添加Makefile,提供make install-git-hook安装指令。该指令会自动下载git仓库中的install-git-hooks.sh文件,并执行。此外,如果执行CUSTOMIZED=1 make install-git-hook则会初始化customized的hooks。

Makefile

1
2
3
4
5
6
7
8
install-git-hooks: install-git-hooks.sh
chmod +x ./$< && ./$<

install-git-hooks.sh:
# 如果遇到 Failed to connect to raw.githubusercontent.com port 443: Connection refused
# 为DNS污染问题,可在https://www.ipaddress.com/查询域名,然后写入hosts文件中
# 见:https://github.com/hawtim/blog/issues/10
wget https://raw.githubusercontent.com/DoneSpeak/gromithooks/v1.0.1/install-git-hooks.sh

在.git_hooks中的hook文件可以按照原本在.git/hooks中的脚本写,也可以按照pre-commit的hook写。

prepare-commit-msg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python

import argparse
from typing import Optional
from typing import Sequence

def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument('filename', nargs='*', help='Filenames to check.')
args = parser.parse_args(argv)
# .git/COMMIT_EDITMSG
print("commit_msg file is " + args.filename[0])

if __name__ == '__main__':
exit(main())

prepare-commit-msg

1
2
3
#!/bin/bash

echo "commit_msg file is $1"

到这里pre-commit的主要功能就讲解完成了,如果需要了解更多的功能(如定义git template),可以看官网文档。

相关文章

推荐

参考

CATALOG
  1. 1. 写在前面
  2. 2. pre-commit 概要
  3. 3. 安装pre-commit
  4. 4. 添加第三方hooks
  5. 5. 开发hooks仓库
  6. 6. 本地hooks
  7. 7. 自定义本地脚本
  8. 8. 相关文章
    1. 8.1. 推荐
    2. 8.2. 参考