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),可以看官网文档。
相关文章 推荐
参考