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
brew install pre-commit
# 或者
pip install pre-commit
# 查看版本
pre-commit --version
# pre-commit 2.12.1 <- 这是我当前使用的版本
复制代码
在项目中安装pre-commit
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。
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
复制代码
常用指令
# 手动对所有的文件执行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
cd <git-repo>
pre-commit install
touch .pre-commit-config.yaml
复制代码
如下为一个基本的配置样例。
.pre-commit-config.yaml
# 该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
可选配置项。
开发 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 仓库来写。
如下为项目的目录基本结构(完整项目见文末的源码路径):
├── 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
# 该项目为一个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
...[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
# 根据分支名,自动添加commit message前缀以关联issue和commit。## 分支名 | commit 格式# --- | ---# issue-1234 | #1234, message# issue-1234-fix-bug | #1234, messageimport sys, os, refrom subprocess import check_outputfrom typing import Optionalfrom typing import Sequencedef 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 命令讲解中看到。
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
即可使用。
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
类似。
local hooks 可以使用支持additional_dependencies
的语言或者 docker_image
/ fail
/ pygrep
/ script
/ system
。
# 定义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)。
- 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
#!/bin/bash:<<'COMMENT'chmod +x install-git-hooks.sh./install-git-hooks.sh# intall with initializing customized hooksCUSTOMIZED=1 ./install-git-hooks.shCOMMENTSTAGES="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}installPreCommitif [ "$CUSTOMIZED" == "1" ]; then touchCustomizedGitHook initPreCommitConfigYamlelse touchPreCommitConfigYamlfipreCommitInstallignoreCustomizedGitHook
复制代码
添加 Makefile,提供make install-git-hook
安装指令。该指令会自动下载 git 仓库中的install-git-hooks.sh
文件,并执行。此外,如果执行CUSTOMIZED=1 make install-git-hook
则会初始化 customized 的 hooks。
Makefile
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
#!/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
#!/bin/bash
echo "commit_msg file is $1"
复制代码
到这里pre-commit
的主要功能就讲解完成了,如果需要了解更多的功能(如定义 git template),可以看官网文档。
相关文章
推荐
参考
评论