Git 钩子是每次在 Git 存储库中发生特定事件时自动运行的脚本。它们允许您自定义 Git 的内部行为,并在开发生命周期的关键点触发可自定义的操作。

在提交创建流程中执行的钩子

Git 钩子的常见用例包括鼓励制定提交策略、根据存储库的状态更改项目环境以及实现持续集成工作流程。但是,由于脚本可以无限自定义,因此您可以使用 Git 钩子来自动化或优化开发工作流程的几乎任何方面。

在本文中,我们将从概念性概述开始介绍 Git 钩子的工作原理。然后,我们将调查一些在本地和服务器端存储库中热门钩子。

概念性概述

所有 Git 钩子都是普通脚本,Git 会在存储库中发生某些事件时执行这些脚本。这使得它们非常易于安装和配置。

钩子可以驻留在本地或服务器端存储库中,并且它们只能在响应该存储库中的操作时执行。我们将在本文后面具体介绍钩子的类别。本节其余部分中讨论的配置适用于本地和服务器端钩子。

安装钩子

钩子驻留在每个 Git 存储库的 .git/hooks 目录中。当您初始化存储库时,Git 会自动使用示例脚本填充此目录。如果您看下 .git/hooks 里面的内容,您会发现以下文件:

applypatch-msg.sample       pre-push.sample
commit-msg.sample           pre-rebase.sample
post-update.sample          prepare-commit-msg.sample
pre-applypatch.sample       update.sample
pre-commit.sample

它们代表了大多数可用的钩子,但默认情况下,.sample 扩展名会阻止它们执行。要“安装”钩子,您需要删除 .sample 扩展名。或者,如果您从头开始编写新脚本,则只需添加一个与上述文件名之一匹配的新文件,减去 .sample 扩展名。

举个例子,尝试安装一个简单的 prepare-commit-msg 钩子。删除此脚本中的 .sample 扩展,并将以下内容添加到文件中:

#!/bin/sh

echo "# Please include a useful commit message!" > $1

钩子必须是可执行的,因此,如果您从头开始创建脚本,则可能需要更改脚本的文件权限。例如,要确保 prepare-commit-msg 可执行,应运行以下命令:

chmod +x prepare-commit-msg

现在,每次运行 git commit 时,您应该会看到这条消息代替了默认的提交消息。我们将在“准备提交消息”部分仔细研究其实际工作原理。现在,我们先来看看我们可以定制 Git 的哪些内部功能。

内置示例脚本是非常有用的参考,因为它们记录了传递给每个钩子的参数(因钩子而异)。

脚本语言

内置脚本主要是 shell 和 PERL 脚本,但您可以使用任何您喜欢的脚本语言,只要它可以作为可执行文件运行。每个脚本中的 shebang 行 (#!/bin/sh) 定义了应如何解释您的文件。因此,要使用不同的语言,您要做的就是将其更改为解释器的路径。

例如,我们可以在 prepare-commit-msg 文件中编写一个可执行的 Python 脚本,而不是使用 shell 命令。下方钩子将与上一节中的 shell 脚本执行相同的操作。

#!/usr/bin/env python

import sys, os

commit_msg_filepath = sys.argv[1]
with open(commit_msg_filepath, 'w') as f:
    f.write("# Please include a useful commit message!")

注意第一行如何变为指向 Python 解释器。而且,我们没有使用 $1 来访问传递给脚本的第一个参数,而是使用 sys.argv [1](再说一遍,稍后会详细介绍这个)。

对于 Git 钩子来说,这是一项非常强大的功能,因为它可以让您使用最熟悉的任何语言来工作。

钩子范围

钩子是任何给定的 Git 存储库的本地钩子,它们不会被复制到新的存储库中(当您运行 git clone 时)。而且,由于钩子是本地的,因此任何有权访问存储库的人都可以对其进行修改。

这在为开发人员团队配置钩子时会产生重要影响。首先,您需要找到一种方法来确保您的团队成员之间的钩子保持最新状态。其次,您不能强迫开发人员创建看起来像某种样子的提交,只能鼓励他们这样做。

为开发人员团队维护钩子可能有点棘手,因为 .git/hooks 目录未与项目的其余部分一起克隆,也不在版本控制之下。解决这两个问题的一个简单方法是将您的钩子存储在实际的项目目录中(.git 目录上方)。这使您可以像编辑任何其他版本控制文件一样编辑它们。要安装钩子,您可以在 .git/hooks 中创建一个指向它的符号链接,或者您可以在钩子更新时将其复制粘贴到 .git/hooks 目录中。

在提交创建流程中执行的钩子

作为替代方案,Git 还提供了模板目录机制,可以更轻松地自动安装钩子。此模板目录中包含的所有文件和目录都被复制到 .git 目录中,(您每次使用 git initgit clone 时)。

下面描述的所有本地钩子都可以由存储库的负责人修改或完全取消安装。每个团队成员是否真的使用钩子完全取决于他们。考虑到这一点,最好将 Git 钩子视为一种方便的开发人员工具,而不是严格执行的开发策略。

也就是说,可以使用服务器端钩子拒绝不符合某些标准的提交。我们将在本文后面对此进行更多讨论。

本地钩子

本地钩子只影响它们所在的存储库。阅读本节时,请记住,每个开发人员都可以修改自己的本地钩子,因此您不能将它们用作强制执行提交策略的方式。但是,它们可以使开发人员更容易遵守某些准则。

在本节中,我们将探索 6 个最有用的本地钩子:

  • pre-commit
  • prepare-commit-msg
  • commit-msg
  • post-commit
  • post-checkout
  • pre-rebase

前 4 个钩子让您进入整个提交生命周期,最后 2 个钩子让您分别对 git checkoutgit rebase 命令执行一些额外的操作或安全检查。

所有的 pre- 钩子都允许您改变即将发生的操作,而 post- 钩子仅用于通知。

我们还将介绍一些有用的技巧,用于解析钩子参数并使用较低级别的 Git 命令请求有关存储库的信息。

Pre-Commit

每次运行 git commit 时,都会在 Git 要求开发人员提供提交消息或生成提交对象之前执行 pre-commit 脚本。您可以使用此钩子来检查即将提交的快照。例如,您可能要运行一些自动测试,确保提交不会破坏任何现有功能。

不向 pre-commit 脚本传递任何参数,并且以非零状态退出会中止整个提交。我们来看看内置 pre-commit 钩子的简化(更详细)版本。如果此脚本发现任何空格错误,则中止提交,如 git diff-index 命令所定义(默认尾部空格、仅包含空格的行以及行初始缩进中的空格后跟制表符被视为错误)。

#!/bin/sh

# Check if this is the initial commit
if git rev-parse --verify HEAD >/dev/null 2>&1
then
    echo "pre-commit: About to create a new commit..."
    against=HEAD
else
    echo "pre-commit: About to create the first commit..."
    against=4b825dc642cb6eb9a060e54bf8d69288fbee4904
fi

# Use git diff-index to check for whitespace errors
echo "pre-commit: Testing for whitespace errors..."
if ! git diff-index --check --cached $against
then
    echo "pre-commit: Aborting commit due to whitespace errors"
    exit 1
else
    echo "pre-commit: No whitespace errors :)"
    exit 0
fi

为了使用 git diff-index,我们需要弄清楚我们要将索引与哪个提交引用进行比较。通常,这是 HEAD,但是,在创建初始提交时不存在 HEAD,所以我们第一个任务是考虑这种极端情况。我们使用 git rev-parse --verify 来完成此操作,它只是检查参数 (HEAD) 是否为有效引用。>/dev/null 2>&1 部分会静音 git rev-parse 的任何输出。HEAD 或空提交对象都存储在 against 变量中,以便与 git diff-index 一起使用。4b825d... 哈希是一个神奇的提交 ID,代表一个空提交。

git diff-index --cached 命令将提交与索引进行比较。通过传递 --check 选项,我们要求它在更改引入空格错误时向我们发出警告。如果是,我们通过返回退出状态 1 来中止提交,否则我们以 0 退出,提交工作流程照常继续。

这只是 pre-commit 钩子的一个示例。它碰巧使用现有的 Git 命令来测试提议的提交引入的更改,但是您可以在 pre-commit 中做任何您想做的事情,包括执行其他脚本、运行第三方测试套件或使用 Lint 检查代码风格。

准备提交消息

prepare-commit-msg 钩子在 pre-commit 钩子后调用,用提交消息填充文本编辑器。这是修改压缩或合并提交自动生成的提交消息的好地方。

将一到三个参数传递给 prepare-commit-msg 脚本:

  1. 包含消息的临时文件的名称。您可以通过就地更改此文件来更改提交消息。
  2. 提交类型。可以是 message-m-F 选项)、template-t 选项)、merge(如果提交是合并提交)或 squash(如果提交压缩了其他提交)。
  3. 相关提交的 SHA1 哈希。仅当给出了 -c-C--amend 选项时才会给出。

pre-commit 一样,以非零状态退出会中止提交。

我们已经看了一个编辑提交消息的简单示例,现在我们来看一个更有用的脚本。使用问题跟踪器时,常见的惯例是在单独的分支中解决每个问题。如果您在分支名称中包含问题编号,则可以编写一个 prepare-commit-msg 钩子,自动将其包含在该分支的每条提交消息中。

#!/usr/bin/env python

import sys, os, re
from subprocess import check_output

# Collect the parameters
commit_msg_filepath = sys.argv[1]
if len(sys.argv) > 2:
    commit_type = sys.argv[2]
else:
    commit_type = ''
if len(sys.argv) > 3:
    commit_hash = sys.argv[3]
else:
    commit_hash = ''

print "prepare-commit-msg: File: %s\nType: %s\nHash: %s" % (commit_msg_filepath, commit_type, commit_hash)

# Figure out which branch we're on
branch = check_output(['git', 'symbolic-ref', '--short', 'HEAD']).strip()
print "prepare-commit-msg: On branch '%s'" % branch

# Populate the commit message with the issue #, if there is one
if branch.startswith('issue-'):
    print "prepare-commit-msg: Oh hey, it's an issue branch."
    result = re.match('issue-(.*)', branch)
    issue_number = result.group(1)

    with open(commit_msg_filepath, 'r+') as f:
        content = f.read()
        f.seek(0, 0)
        f.write("ISSUE-%s %s" % (issue_number, content))

首先,上面的 prepare-commit-msg 钩子向您展示了如何收集传递给脚本的所有参数。然后,它调用 git symbolic-ref --short HEAD 来获取与 HEAD 相对应的分支名称。如果此分支名称以 issue- 开头,则它会重写提交消息文件内容,将问题编号包含在第一行中。因此,如果您的分支名称是 issue-224,这将生成以下提交消息。

ISSUE-224 

# Please enter the commit message for your changes. Lines starting 
# with '#' will be ignored, and an empty message aborts the commit. 
# On branch issue-224 
# Changes to be committed: 
#   modified:   test.txt

使用 prepare-commit-msg 时要记住的一件事是,即使用户使用 git commit-m 选项传递消息,它也会运行。这意味着上面的脚本将自动插入 ISSUE-[#] 字符串,无需用户对其进行编辑。您可以通过查看第二个参数 (commit_type) 是否等于 message 来处理这种情况。

但是,如果没有 -m 选项,prepare-commit-msg 钩子确实允许用户在消息生成后对其进行编辑,所以这实际上更像是一个方便的脚本,而不是强制执行提交消息策略的一种方法。为此,您需要在下一节中讨论 commit-msg 钩子。

提交消息

commit-msg 钩子很像 prepare-commit-msg 钩子,但它是在用户输入提交消息调用的。这是警告开发人员他们的信息不符合团队标准的适当场所。

传递给这个钩子的唯一参数是包含消息的文件名。如果它不喜欢用户输入的消息,它可以就地更改该文件(就像使用 prepare-commit-msg 一样),也可以通过以非零状态退出来完全中止提交。

例如,以下脚本检查以确保用户没有删除上一节中 prepare-commit-msg 钩子自动生成的 ISSUE-[#] 字符串。

#!/usr/bin/env python

import sys, os, re
from subprocess import check_output

# Collect the parameters
commit_msg_filepath = sys.argv[1]

# Figure out which branch we're on
branch = check_output(['git', 'symbolic-ref', '--short', 'HEAD']).strip()
print "commit-msg: On branch '%s'" % branch

# Check the commit message if we're on an issue branch
if branch.startswith('issue-'):
    print "commit-msg: Oh hey, it's an issue branch."
    result = re.match('issue-(.*)', branch)
    issue_number = result.group(1)
    required_message = "ISSUE-%s" % issue_number

    with open(commit_msg_filepath, 'r') as f:
        content = f.read()
        if not content.startswith(required_message):
            print "commit-msg: ERROR! The commit message must start with '%s'" % required_message
            sys.exit(1)

虽然每次用户创建提交时都会调用此脚本,但除了检查提交消息外,您应该避免做很多事情。如果您需要通知其他服务快照已提交,则应改用 post-commit 钩子。

Post-Commit

post-commit 钩子在 commit-msg 钩子后立即被调用。它无法更改 git commit 操作的结果,因此它主要用于通知目的。

该脚本不带任何参数,其退出状态不会以任何方式影响提交。对于大多数 post-commit 脚本,您需要访问刚刚创建的提交。您可以使用 git rev-parse HEAD 来获取新提交的 SHA1 哈希,或者您可以使用 git log -1 HEAD 来获取其所有信息。

例如,如果您想在每次提交快照时给老板发送电子邮件(对于大多数工作流程来说可能不是最好的主意),您可以添加下方 post-commit 钩子。

#!/usr/bin/env python

import smtplib
from email.mime.text import MIMEText
from subprocess import check_output

# Get the git log --stat entry of the new commit
log = check_output(['git', 'log', '-1', '--stat', 'HEAD'])

# Create a plaintext email message
msg = MIMEText("Look, I'm actually doing some work:\n\n%s" % log)

msg['Subject'] = 'Git post-commit hook notification'
msg['From'] = 'mary@example.com'
msg['To'] = 'boss@example.com'

# Send the message
SMTP_SERVER = 'smtp.example.com'
SMTP_PORT = 587

session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
session.ehlo()
session.starttls()
session.ehlo()
session.login(msg['From'], 'secretPassword')

session.sendmail(msg['From'], msg['To'], msg.as_string())
session.quit()

可以使用 post-commit 来触发本地持续集成系统,但大多数时候您需要在 post-receive 钩子中这样做。它在服务器上运行,而不是在用户的本地计算机上运行,并且每当任何开发人员推送他们的代码时它都会运行。这使其成为更合适进行持续集成的场所。

Post-Checkout

post-checkout 钩子的工作原理与 post-commit 钩子很相似,但是只要您成功使用 git checkout 签出引用,它就会被调用。这对于清理工作目录中生成的文件非常有用,否则这些文件会造成混乱。

此钩子接受三个参数,其退出状态对 git checkout 命令没有影响。

  1. 前一个 HEAD 的引用
  2. 新 HEAD 的引用
  3. 告诉您这是分支签出还是文件签出的标记。标记分别为 10

生成的 .pyc 文件在切换分支后仍然存在时,Python 开发人员会遇到一个常见问题。解释器有时会使用这些 .pyc 而不是 .py 源文件。为避免混淆,您可以在每次签出新分支时使用下方 post-checkout 脚本删除所有 .pyc 文件:

#!/usr/bin/env python

import sys, os, re
from subprocess import check_output

# Collect the parameters
previous_head = sys.argv[1]
new_head = sys.argv[2]
is_branch_checkout = sys.argv[3]

if is_branch_checkout == "0":
    print "post-checkout: This is a file checkout. Nothing to do."
    sys.exit(0)

print "post-checkout: Deleting all '.pyc' files in working directory"
for root, dirs, files in os.walk('.'):
    for filename in files:
        ext = os.path.splitext(filename)[1]
        if ext == '.pyc':
            os.unlink(os.path.join(root, filename))

钩子脚本的当前工作目录始终设置为存储库的根目录,因此 os.walk ('.') 调用会迭代存储库中的每个文件。然后,我们检查其扩展,如果它是 .pyc 文件,则将其删除。

您还可以使用 post-checkout 钩子根据签出的分支来更改工作目录。例如,您可以使用 plugins 分支将所有插件存储在核心代码库之外。如果这些插件需要大量的二进制文件,而其他分支不需要,则只有在 plugins 分支上时,才能有选择地构建它们。

Pre-Rebase

pre-rebase 钩子是在 git rebase 更改任何内容之前调用的,这使得它成为确保不会发生可怕的事情的好地方。

这个钩子有两个参数:克隆序列的上游分支和正在变基的分支。变基当前分支时,第二个参数为空。要中止变基,请以非零状态退出。

例如,如果您想完全禁止在存储库中使用变基,您可以使用下方 pre-rebase 脚本:

#!/bin/sh

# Disallow all rebasing
echo "pre-rebase: Rebasing is dangerous. Don't do it."
exit 1

现在,每次运行 git rebase,都会看到以下消息:

pre-rebase: Rebasing is dangerous. Don't do it.
The pre-rebase hook refused to rebase.

要查看更深入的示例,请查看随附的 pre-rebase.sample 脚本。这个脚本在何时不允许变基方面更明智一些。它会检查您正在尝试变基的主题分支是否已经合并到 next 分支(假定为主线分支)中。如果是,您可能会因为变基该分支而遇到麻烦,所以脚本会中止变基。

服务器端钩子

服务器端钩子的工作原理与本地钩子类似,不同的是它们位于服务器端存储库(例如,中央存储库或开发人员的公共存储库)中。附加到官方存储库时,其中一些可以作为通过拒绝某些提交来强制执行政策的一种方式。

在本文其余部分中,我们将讨论 3 个服务器端钩子:

  • pre-receive
  • 更新
  • post-receive

所有这些钩子都可以让您对 git push 流程的不同阶段做出反应。

服务器端钩子的输出通过管道传送到客户端的控制台,因此向开发人员发送消息非常容易。但是,您还应该记住,这些脚本在完成执行之前不会返回对终端的控制权,因此在执行长时间运行的操作时应谨慎行事。

Pre-Receive

每当有人使用 git push 将提交推送到存储库时,都会执行 pre-receive 钩子。它应始终位于作为推送目标的远程存储库中,而不是原始存储库中。

钩子会在更新任何引用之前运行,因此它是强制执行您想要的任何类型的开发策略的好地方。如果您不喜欢谁在推送,不喜欢提交消息的格式化或者提交中包含的更改,您可以直接拒绝。虽然您无法阻止开发人员提交格式错误的提交,但您可以通过 pre-receive 来拒绝这些提交,从而阻止它们进入官方代码库。

该脚本不接受任何参数,但是每个被推送的引用都会在标准输入的单独一行上按以下格式传递给脚本:

<old-value> <new-value> <ref-name>

您可以使用一个非常基本的 pre-receive 脚本来看看这个钩子是如何工作的,该脚本只需读取推送的引用并打印即可。

#!/usr/bin/env python

import sys
import fileinput

# Read in each ref that the user is trying to update
for line in fileinput.input():
    print "pre-receive: Trying to push ref: %s" % line

# Abort the push
# sys.exit(1)

同样,这与其他钩子略有不同,因为信息是通过标准输入而不是作为命令行参数传递给脚本的。将上述脚本放在远程存储库的 git/hooks 目录中并推送 main 分支后,您将在控制台中看到如下内容:

b6b36c697eb2d24302f89aa22d9170dfe609855b 85baa88c22b52ddd24d71f05db31f4e46d579095 refs/heads/main

您可以使用这些 SHA1 哈希以及一些较低级别的 Git 命令来检查即将引入的更改。部分常用案例包括:

  • 拒绝涉及上游变基的更改
  • 防止非快进合并
  • 检查用户是否具有进行预期更改的正确权限(主要用于集中式 Git 工作流程)

如果推送了多个引用,则从 pre-receive 返回的非零状态会中止所有引用。如果您想根据具体情况接受或拒绝分支,则需要改用 update 钩子。

更新

update 钩子在 pre-receive 后调用,其工作方式大致相同。它在实际更新任何东西之前仍然被调用,但是对于每个推送的引用都会单独调用。这意味着,如果用户尝试推送 4 个分支,则 update 将执行 4 次。与 pre-receive 不同,此钩子不需要从标准输入中读取。相反,它接受以下 3 个参数:

  1. 正在更新的引用的名称
  2. 存储在引用中的旧对象名称
  3. 存储在引用中的新对象名称

这与传递给 pre-receive 的信息相同,但由于为每个引用单独调用 update,因此您可以拒绝一些引用,同时允许其他引用。

#!/usr/bin/env python

import sys

branch = sys.argv[1]
old_commit = sys.argv[2]
new_commit = sys.argv[3]

print "Moving '%s' from %s to %s" % (branch, old_commit, new_commit)

# Abort pushing only this branch
# sys.exit(1)

上面的 update 钩子只是输出分支和旧的/新的提交哈希。将多个分支推送到远程存储库时,您将看到每个分支都执行 print 语句。

Post-Receive

post-receive 钩子会在成功推送操作后被调用,这使其成为执行通知的好地方。对于许多工作流程来说,这是比 post-commit 更适合触发通知的地方,因为更改可以在公共服务器上使用,而不是只存在于用户的本地计算机上。向其他开发人员发送电子邮件和触发持续集成系统是 post-receive 的常见用例。

该脚本不接受任何参数,但通过标准输入发送的信息与 pre-receive 的信息相同。

摘要

在本文中,我们了解了如何使用 Git 钩子来改变内部行为,并在存储库中发生某些事件时接收通知。钩子是位于 .git/hooks 存储库中的普通脚本,这使得它们非常易于安装和自定义。

我们还研究了一些最常见的本地和服务器端钩子。这些可以让我们进入整个开发生命周期。现在,我们知道如何在提交创建流程以及 git push 流程的每个阶段执行可自定义的操作。只要有一点脚本知识,就可以使用 Git 存储库完成几乎所有事情。