likes
comments
collection
share

Git高级操作: Git钩子

作者站长头像
站长
· 阅读数 2

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命令时,上面定制化过的提交信息会作为默认提交信息。我们接下来会在Prepare Commit Message的段落详细审视这是如何工作的。至于现在只需要明白我们是可以通过钩子脚本来定制化Git的某些内部功能。

内置的样例脚本的内容有关于各种钩子可以传递的参数文档,所以可以作为创建新脚本的参考。

脚本语言

内置的脚本语言基本上是shell或者perl脚本,但是实际上你可以使用任何能够作为可执行脚本运行的语言。脚本文件的第一行(#!/bin/sh)定义了应该使用哪种脚本解释器。所以要使用其他的语言,只需要将第一行改为新的执行器的路径即可。

举个例子,我们通过修改脚本解释器路径,让prepare-commit-msg文件执行Python脚本而不是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(shell方式)来获取第一个参数的引用,而是使用了sys.argv[1](python方式)(下面会详细解释这个地方)

Git提供的这个能力非常强大,这将允许你在创建钩子的时候使用任何你习惯的脚本语言。

钩子的范围

在指定Git仓库中,钩子都是存在于本地的,它们不会跟随git clone命令被复制到新的仓库中去,所以任何对于当前仓库有权限的人都可以对其进行修改。

这个特性对于为开发团队配置钩子产生了深远的影响。首先,你需要找到一种方式让钩子脚本们在团队成员之间保持同步。其次,你没法强制开发者按照指定方式创建提交——只能鼓励他们这么做。

为整个开发团队维护钩子有点棘手,因为.git/hooks目录不会像项目的其他部分一样被git clone下去,也不在Git的版本管理范畴内。一种简单的解决方案是把钩子文件件放在实际项目目录中(也就是放在.git目录之外)。这样做可以保证他们的行为与其他被版本管理系统管理的文件一样。在这种情况下安装钩子可以对.git/hooks路径创建连接符号(symlink)或者就是简单拷贝黏贴到.git/hooks路径下。

Git高级操作: Git钩子

此外,Git也提供了一种称为模板目录Template Directory的机制可以自动安装钩子。在模板目录下的所有除了以.开头的文件,在使用git init或者git clone命令时都会被自动复制到.git目录下。

下面一小节介绍的本地钩子都可以被仓库的所有者改变——甚至完全卸载。这完全取决于团队成员自己是否使用那个钩子。基于此,最好是把Git钩子当做方便开发者自己的工作,而不是严格的开发规范来使用。

与之相对应的,我们反而可以使用服务端的Git钩子来拒绝不符合规范的提交。关于这一点我们会在本文的稍后部分进行讨论。

本地钩子

本地钩子只会影响本地仓库。既然你已经读到这里,想必一定会记得每个开发者自己可以修改本地钩子,所以无法将其作为一种提交规范强制执行。不过它们可以让开发者能够更方便地遵循某种指导方针。

在本小节,我们会介绍6中最常用的本地钩子:

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

前四个可以用于提交的完整生命周期,后两个用于执行在git checkoutgit rebase之后的安全检查。

所有pre-开头的钩子都是在实际动作执行前会被触发,post-开头的则是在实际动作执行之后被触发。

接下来我们还会需要使用一些底层的Git命令来解析钩子参数或者查询仓库信息。

Pre-Commit

每一次执行git commit命令时,在要求填入提交信息或者生成提交对象之前,pre-commit脚本会被触发执行。可以利用这个钩子检查即将要提交的仓库快照。比如说你可能会想在这个时点执行一些测试,以保证新的提交不会破坏已有的功能。

pre-commit脚本不需要传入参数,脚本执行退出信号不为0时(non-zero signal)会终止整个提交。下面我们看看内置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...是用来表示空提交的魔法字符。

git diff-index会比较提交与索引。通过传入--check选项会让其检查到此次提交引入了空白错误时发出一个警告。如果命令返回了警告,脚本会返回退出状态为1以便退出提交,否则会返回退出状态0以便提交流程继续。

这仅是pre-commit钩子的一种示例。我们只是恰巧使用这个例子来对即将提交的代码进行一些简单的测试,你仍然可以使用pre-commit来做任何想做的事情,甚至引用其他脚本,或者执行第三方的测试套件,或者使用Lint检查代码风格。

Prepare Commit Message

执行完成pre-commit钩子脚本之后会触发prepare-commit-msg钩子,它会弹出含有提交信息的文本编辑器。在这一步可以用来修改squash或者merge命令自动生成的提交信息。

prepare-commit-msg脚本接受的三个参数如下:

  1. 储存提交信息的临时文件名称。你可以直接修改这个文件的内容来改变提交信息。
  2. 提交类型。可以是message-m或者-F选项),template-t选项),merge(如果本次提交时合并提交),或者squash(如果提交squash了其他提交)
  3. 关联提交的SHA1哈希值。仅当使用-c, -C, --amend选项时可传。

pre-commit一样,当退出状态非0时退出提交。

我们之前已经见过一个简单的用于编辑提交信息的例子,现在我们来看一下更有使用价值的脚本。如果开发团队使用问题跟踪软件来管理需求和缺陷,比如Jira, BugZillaRedmine等,通常惯例是为每一个issue指定一个独立分支。如果团队规约中规定分支名需要包含issueid,你可以定制prepare-commit-msg自动把issue id填写到提交信息中。

#!/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 number添加到提交信息的第一行。比如说你的分支名称为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

需要注意的一点是,即便用户使用-m选项执行git commit命令并传入了提交信息,prepare-commit-msg也仍然会执行。也就是说上面的脚本会自动插入ISSUE-[#]之类的字符串而不是事先给用户编辑的机会。你可以通过判断传入的第二个参数(commit_type)是否为message来加以处理。

不过没有传入-m选项时,prepare-commit-msg钩子倒是允许用户在生成提交信息之后再来编辑它。所以这个钩子确实只是一个方便生成提交信息的脚本,而不太适合作为强制提交信息规范。至于这一点,你可能更需要的是下一小节会讨论的commit-msg钩子。

Commit Message

commit-msgprepare-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获取最近这次提交的所有信息。

比如如果在每一次提交之后你想通过email通知你的老板(可能不是什么好主意),你可以将如下脚本添加到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. 一个用于区分本次checkout是针对分支的还是针对文件的。选项值分别为10

一个Python开发者常见的场景是,生成的.pyc文件会跟随分支切换。而解释器有时候会优先使用.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))

对于钩子脚本来说,当前工作目录总是被定位于Git项目的根目录,因此os.walk('.')命令会递归地查找整个仓库文件夹,找到.pyc文件并删除它。

你也可以使用post-checkout钩子根据要检出的分支对工作目录进行修改。比如当你使用plugins分支来存储核心代码仓库以外的插件时。假设这些插件依赖许多其他分支不需要的二进制文件,你可以选择性的在切换到plugins分支时才去编译构建。

Pre-Rebase

pre-rebase钩子在git rebase执行之前被触发,在此时机可以进行检查以避免发生破坏性的事情。

该钩子接受两个参数:上游分支,和进行rebase的分支。当rebase的分支为当前分支时,第二个参数为空。钩子脚本退出状态码非0时,退出rebase。

举例来说假设你在仓库中禁止任何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脚本查看更加深入的样例。这一脚本中的内容对于禁止rebase操作,含有稍微智能一些的逻辑。其中的一个逻辑为检查当前要进行rebase的分支是否已经被合并入next分支(即假设的主分支)。如果已经被合并过,那么很可能会产生问题,所以脚本会中断这次rebase操作。

服务端钩子

服务端的钩子与本地钩子类似,只是他们存在于服务端仓库(比如一个中心仓库,或者开发者的共有仓库)。当作为中心节点仓库使用时,这些钩子可以通过拒绝某些提交来强制执行提交规范。

我们会在接下来的篇幅中讨论如下三个服务端钩子

  • pre-receive
  • update
  • 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

进阶使用pre-receive钩子时可以利用这些SHA1哈希值,配合一些底层的Git命令,可以用来检查即将被引入的代码修改。一些常见的用例包括:

  • 拒绝涉及rebase上游分支的变更
  • 阻止非快速前进的合并
  • 检查用户是否拥有足够的权限(通常用于中心化的Git工作流)

如果多个引用同时被推送,返回非0的退出状态码会中断所有推送。如果你想一个一个的判断是否接受或者拒绝,可以使用update钩子。

Update

update钩子会在pre-receive之后被触发执行,它们的工作模式也基本类似。该钩子仍然是在发生任何改变之前被执行,但它是根据推送的多个引用分别被调用。也就是说如果用户尝试推送4个分支,update会执行4次。与pre-receive不同,该钩子不需要从标准输入读取信息,而是接受下面三个参数:

  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仓库的任何流程中做任何想做的事情。