写点什么

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

用户头像
DoneSpeak
关注
发布于: 3 小时前

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 installpre-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阶段再触发hookspre-commit run --all-files# 执行特定hookspre-commit run <hook_id># 将所有的hook更新到最新的版本/tagpre-commit autoupdate# 指定更新repopre-commit autoupdate --repo https://github.com/DoneSpeak/gromithooks
复制代码


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

添加第三方 hooks

cd <git-repo>pre-commit installtouch .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 argparsefrom typing import Optionalfrom 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类似。


  • 脚本与代码仓库紧密耦合,并且与代码仓库一起分发。

  • Hooks 需要的状态只存在于代码仓库的 build artifact 中(比如应用程序的 pylint 的 virtualenv)。

  • linter 的官方代码仓库没有提供 pre-commit metadata.


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 argparsefrom typing import Optionalfrom 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),可以看官网文档。

相关文章

推荐

参考

发布于: 3 小时前阅读数: 3
用户头像

DoneSpeak

关注

Let the Work That I've Done Speak for Me 2018.05.10 加入

Java后端开发

评论

发布
暂无评论
在Git项目中使用pre-commit统一管理hooks