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