关于git:在Git项目中使用precommit统一管理hooks

6次阅读

共计 13342 个字符,预计需要花费 34 分钟才能阅读完成。

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 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}

# 如 pre-commit install --hook-type prepare-commit-msg

罕用指令

# 手动对所有的文件执行 hooks,新增 hook 的时候能够执行,使得代码均符合规范。间接执行该指令则无需等到 pre-commit 阶段再触发 hooks
pre-commit run --all-files
# 执行特定 hooks
pre-commit run <hook_id>
# 将所有的 hook 更新到最新的版本 /tag
pre-commit autoupdate
# 指定更新 repo
pre-commit autoupdate --repo https://github.com/DoneSpeak/gromithooks

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

增加第三方 hooks

cd <git-repo>
pre-commit install
touch .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 可选配置项。

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 仓库来写。

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

├── 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, message

import sys, os, re
from subprocess import check_output
from typing import Optional
from typing import Sequence

def 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:   - 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 hooks
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

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 argparse
from typing import Optional
from 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),能够看官网文档。

相干文章

举荐

  • 本文章源码 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 会给出一个从创立到公布的流程介绍
正文完
 0