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: falserepos:# 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可选配置项。

key worddescription
idthe id of the hook - used in pre-commit-config.yaml.
namethe name of the hook - shown during hook execution.
entrythe entry point - the executable to run. entry can also contain arguments that will not be overridden such as entry: autopep8 -i.
languagethe 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仓库来写。

如下为我的项目的目录根本构造(残缺我的项目见文末的源码门路):

├── 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 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)    # .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 pythonimport 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)    # .git/COMMIT_EDITMSG    print("commit_msg file is " + args.filename[0])if __name__ == '__main__':    exit(main())

prepare-commit-msg

#!/bin/bashecho "commit_msg file is $1"

到这里pre-commit的次要性能就解说实现了,如果须要理解更多的性能(如定义git template),能够看官网文档。

相干文章

举荐

  • 本文章源码 Donespeak/Gromithooks
  • 定义全局Git Hooks和自定义Git Hooks
  • 通过Git Hook关联Tapd和Commit

参考

  • pre-commit | A framework for managing and maintaining multi-language pre-commit hooks. @pre-commit.com
  • pre-commit | Supported hooks @pre-commit.com
  • 一个值得参考的.pre-commit-config.yaml @github
  • Git钩子:自定义你的工作流 用python写git hooks
  • Packaging Python Projects @python.org 会给出一个从创立到公布的流程介绍