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
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}
常用指令
1 2 3 4 5 6 7 8 pre-commit run --all-files pre-commit run <hook_id> pre-commit autoupdate 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 fail_fast: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: - id: check-added-large-files - id: trailing-whitespace 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 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 ├── pyproject.toml ├── setup.cfg └── 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 - 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 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 language: python stages: [prepare-commit-msg] - 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 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 import 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' ) 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): return issue_prefix = '#' + issue_number f.seek(0 , 0 ) f.write("%s, %s" % (issue_prefix, content)) 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 argparsefrom typing import Optionalfrom typing import Sequencedef main (argv: Optional[Sequence[str]] = None) -> int: parser = argparse.ArgumentParser() parser.add_argument('filename' , nargs='*' , help='Filenames to check.' ) args = parser.parse_args(argv) 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 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 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 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] - id: post-checkout name: post-checkout (local) entry: .git_hooks/post-checkout language: script stages: [post-checkout] - id: post-commit name: post-commit (local) entry: .git_hooks/post-commit language: script stages: [post-commit] - id: post-merge name: post-merge (local) entry: .git_hooks/post-merge language: script stages: [post-merge] - id: pre-commit name: pre-commit (local) entry: .git_hooks/pre-commit language: script stages: [commit] - id: pre-merge-commit name: pre-merge-commit (local) entry: .git_hooks/pre-merge-commit language: script stages: [merge-commit] - id: pre-push name: pre-push (local) entry: .git_hooks/pre-push language: script stages: [push] - id: prepare-commit-msg name: prepare-commit-msg (local) entry: .git_hooks/prepare-commit-msg language: script stages: [prepare-commit-msg]
遵循能够自动化的就自动化的原则。这里提供了自动创建以上所有阶段文件的脚本(如果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 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: 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 import argparsefrom typing import Optionalfrom typing import Sequencedef main (argv: Optional[Sequence[str]] = None) -> int: parser = argparse.ArgumentParser() parser.add_argument('filename' , nargs='*' , help='Filenames to check.' ) args = parser.parse_args(argv) 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),可以看官网文档。
相关文章 推荐
参考