Git hooks工具是一些命令脚本,当仓库发生变化时会自动执行。这让你在开发周期内可以定制Git内在行为并触发想要的操作。
Git hooks常用场景包括鼓励一个提交准则,根据仓库环境更改项目环境,实施持续集成工作流。但是,因为脚本可以无限制定制,因此实际上你可以使用Git hooks自动化和优化工作流的各个方面。
在这篇文章里,我们将开始介绍如何使用Git hooks.然后,我们将探究在本地和远程仓库常用的hooks方法。
概述
所有的Git hooks工具都是当仓库中特定事件发生时执行的脚本命令。因此安装和配置他们其实很容易。Hooks(钩子)要么放在本地要么放在远程仓库,而且只响应所在仓库的行为。这篇文章稍后会讲到hooks的分类。本章节剩下的部分讨论的配置可以用在本地和服务端hooks.
安装钩子(Hooks)
Hooks放在仓库的.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
大多数可用的hooks就是这些,但是.sample后缀默认不能执行。为了安装一个hook,你仅仅需要移除.sample后缀。或者,如果你写了一个新脚本,你只需要使用上面这些文件名,去除.sample后缀创建一个文件即可。
举个例子,为了安装prepare-commit-msg这个hook,你可以移除这个脚本的.sample后缀,然后在文件中添加如下内容:
#!/bin/sh
echo "# Please include a useful commit message!" > $1
Hooks执行,需要修改创建的脚本的执行权限,比如,为了确保prepare-commit-msg可执行,你需要执行如下命令修改它的执行权限:
chmod +x prepare-commit-msg
你将在执行git commit 命令时看到这句话,而不是默认的消息。我们将在Prepare Commit Message章节详细探讨这个脚本是如何工作的。现在我们已经清楚我们可以定制Git的内部功能这个事实。
内置的样例脚本是非常有参考价值的,因为他们标注了每个hook需要传入的参数(每个hook有所不同)。
脚本语言
每个脚本的第一句(#!/bin/sh)定义了脚本如何解析。因此,如果你使用其他语言,你需要修改它指向你的解析器
内置的脚本几乎都是shell和perl脚本。但是你可以使用任何可以执行的脚本语言。
比如,我们可以在prepare-commit-msg中写一个可执行的Python脚本,而不是使用shell命令。下面的hook将实现之前章节同样的效果。
#!/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 hooks非常有用的功能,因为它可以让你使用你熟悉的语言。
Hooks的作用范围
Hooks作用于当前仓库,当你执行git clone时他们不会被复制到新仓库。并且,因为他们是作用于存在于所在仓库,所以有权访问仓库就能修改hooks。
这对团队开发者而言有大的影响。首先,你需要确保所有团队成员都能确保hooks最新,其次,你不能强制所开发者们按照指定的方式创建提交,你仅仅可以鼓励他们这么做。
维护开发者们的hooks需要一点技巧,因为.git/hooks目录不会和项目一起被克隆,也不受版本控制。一个解决这个问题的简单的方案就是把hooks放在真正的项目目录(放在.git目录外面)。这让你可以像一般版本控制的文件一样编辑他们。为了安装hook,你可选择创建一个符号链接指向.git/hooks,也可以当hook文件更新时将它拷贝到.git/hooks。
作为替代方案,Git也提供一个模版目录的方法来让你更容易自动化安装hooks。在模版文件下的所有文件和目录,在你使用git init 和 git clone命令时时都会拷贝到.git目录中。
所有下面讲到的本地hooks都会被修改,或者被仓库所有者卸载。这完全看开发者是否使用了hook。我们应该清楚的意识到,最好把Git hooks看作帮助开发者的工具而不是严格限制开发的规定。当然,使用服务器端的hooks拒绝一些不准收标准的提交是可以做到的。我们将在文章中继续探讨这个话题。
本地hooks
本地hooks只能作用于当前仓库,当你在读这个章节的时候,记住每个开发者可以修改它本地的hooks,因此你不能把它作为一个强制提交规定。当然,他们可以让开发者更倾向去特定指导规范。在本章节,我们将探讨六个常用的本地hooks:
pre-commit
prepare-commit-msg
commit-msg
post-commit
post-checkout
pre-rebase
前4个hook让你可以为整个提交生命周期插入行为,而最后面2个hook则可以让你分别为git checkout 和 git rebase命令添加额外行为或者安全检查。
所有pre-开头的hook可以让你在即将发生的事情上修改行为,而post-开头的hook用来发出通知。
我们也将了解一些用来分析hook参数和利用底层的git命令请求仓库信息的技巧。
提交前(Pre-Commit)
在你每次执行git commit命令时,pre-commit脚本会在Git请求你输入提交日志或者生成提交对象前执行。你可以使用这个hook监控即将被提交的快照。比如,你可以使用它执行一些自动测试,来确保新的提交不会破话已经存在的功能。
没有参数传递给pre-commit脚本,并且非零状态时候退出会暂停整个提交。让我们看一个内置的pre-commit hook的简单版本。这个脚本会在遇到空白错误时终止提交,就像git diff-index命令定义的那样(末尾空格、只有空格的行、起始行使用tab产的空格都被默认认为是错误的)。
#!/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要么空对象会复制给git diff-index需要的against变量。4b825d…这个hash值是一个特殊的提交ID,代表一个空提交。
git diff-index –cached 命令用来比较一个提交而不是索引。通过传入–check 选项来告诉它当有空白错误引入时需要给我们发送警告。如果有空白警告,我们通过返回一个为1的状态中止提交,或者我们返回0并且让提交正常进行。
这是pre-commithook的其中一个例子,一般我们用它来结合其它Git命令工具来对提交的修改运行测试,但是你也可以在pre-commithook中做任何你想做的事情,包括执行其他脚本,运行第三方测试工具,或者用Lint检查代码风格。
准备提交信息(Prepare Commit Message)
prepare-commit-msghook 在pre-commithook执行后操作输入提交信息的文本编辑器后调用,这是为压缩(squashed)或者合并提交时修改自动生成提交信息的最佳时机。
One to three arguments are passed to the prepare-commit-msg script:
1到3个参数需要传递给prepare-commit-msghook的脚本:
- 包含提交信息的临时文件名称。你通过修改这个文件来修改提交信息。
- 提交类型。可能是消息提交(使用了-m or -F选型),模板提交(-t选项),合并提交(该提交是一个merge合并提交),或者压缩(squash)提交(该提交是压缩其他提交)。
- 相关提交的SHA1哈希值,仅仅当被提供-c, -C, 或者 –amend选项时候需要加入这个参数。
和pre-commit一样,当非零状态退出时中止提交。
我们已经看见一个简单编辑提交日志的例子,但是让我们看一些更有用的脚本。当使用问题追踪时,一个通常有用的做法是把每个问题放在一个单独的分支。如果你在分支名上包含问题的追踪代号,你可以使用prepare-commit-msg hook来自动包含代号到分支的每个提交上。
#!/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 hook能够收集所有传入脚本的参数,然后,它调用git symbolic-ref –short HEAD命令来获取分支名称,如果分支名以issure-开头,它会重写提交日志,在第一行包含issure代号。因此,如果你的分支名为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 hook时记住它甚至会在你使用git commit的-m
选项来提交时会运行。这意味着上面的脚本会自动插入ISSUE-[#]字符串,而不管是否让你编辑提交日志。你可以通过看看第二个参数(commit_type)是否合消息一致来处理这个情况。
然后,没有-m
选项,prepare-commit-msg hook允许用户在生成ISSUE-[#]字符串后编辑提交日志,使用者可能会删掉者些内容,因此这对使用者来说变成一种方便,而不是强制的提交日志的规定。因此,你需要下一章节的commit-msg hook。
提交日志(Commit Message)
commit-msg hook跟prepare-commit-msg hook类似,但是它是在用户输入提交日志时调用。在这里提示团队成员,他提交的日志不符合团队的标准更合适。
这个hook唯一需要传入的参数是包含这个日志的文件的名称,如果你不喜欢使用者输入的日志信息,它会修改这个文件(跟prepare-commit-msg 类似)或者它通过非零退出来中止提交。
比如,下面的脚本用来确保使用者没有删除前面章节提到的prepare-commit-msg hook生成的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)
但是使用者每次创建提交时都会调用这个脚本,你应该除了检查提交日志外避免做太多事情,如果你需要通知其他服务提交已经发生,你应带使用commit-msg hook。
提交后(Post-Commit)
提交后hook在commit-msg hook后立即执行,它不能改变git commit操作的结果,所以它主要用来发送通知。
这个脚本不需要传参,它的退出状态完全不会影响提交结果。大部分提交后hook的脚本,会去访问刚刚创建的新提交。你能使用git rev-parse HEAD 去获取这个新的提交的SHA1哈希值,或者你能使用git log -1 HEAD获取新提交的所有信息。
比如,如果你想在每次提交一个新的快照时候邮件通知你的老板(不过对大多数工作流来说很可能没意义),你可以使用如下的post-commithook:
#!/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-commithook来触发本地的持续集成是可能的,但是多数时候你会想在post-receive hook时候做持续集成,这会在服务器端执行而不是开发者主机上,post-receive hook也会在每次开发者执行推送的时候执行,这让它更适合用来做持续集成。
切换后(Post-Checkout)
切换后hook跟提交后hook类似,但是它在执行git checkout命令切换一个引用成功后执行。这个时候如果用它来清理你工作目录生成的文件非常有必要,否则会导致一些困扰。
This hook accepts three parameters, and its exit status has no affect on the git checkout command.
这个hook接受三个参数,它的输出状态不影响git checkout命令的执行。
切换前的HEAD的引用
切换后的新的HEAD的引用
一个标志,判断这次切换是针对分支还是文件的标志。这个标志分别为1和0.
对于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))
hook脚本的当前工作目录总是仓库的根目录,所以os.walk('.')
会遍历出仓库所有的文件,然后我们删除.pyc后缀的文件。
你可以使用post-checkouthook根据你切换后的分支去修改工作目录的内容,比如,你可能在主要代码库中使用了插件分支存储所有的插件。如果这些插件需要许多其他分支不需要的二进制数据,当你在插件分支上时你能选择性地构建他们。
rebase合并前(Pre-Rebase)
pre-rebasehook在git rebase
修改之前调用,可以用它防止一些严重的事情发生。
hook使用2个参数:这个系列分叉出来的上游分支,以及正在rebase合并的分支。当rebase发生在当前分支第二个参数为空。非零状态退出中止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的当前分枝是否已经合并入锅下一个分支(通常是主干分支),如果已经合并了,那如果执行rebase的会就会带来问题,所以这个脚本中止这样的rebase合并.
服务端hooks
服务端hooks跟本地的基本一样,他们只是放在服务端仓库(也叫做中央仓库,或者开发者公共仓库)而已。当hooks放在正式仓库中,那他们就可以用来作为拒绝某些提交的规定的方法了。
文章后面的内容我们来讨论三个服务端的hooks:
- 推送接受前(pre-receive)
- 推送更新中(update)
- 推送接受后(post-receive)
所有这些hooks可以在推送过程中的不同环节发挥作用。
服务端hooks将结果输出到客户端控制台,因此很容返回信息给开发者。但是,你要记住这些脚本不会返回终端的控制权给开发者,除非他们执行完成,因此你最好不要执行耗时操作。
推送接受前(Pre-Receive)
当有人使用git push命令推送提交到仓库时,pre-receivehook就会执行。它总是存放在远程仓库(推送目标仓库),而不是推送发起仓库。
这个hook在提交引用被更新前执行,因此可以在这里添加任何你想要的开发规定。如果你不希望某个人推送,不喜欢某些格式写的提交信息,或者不喜欢提交里所做的修改,你可以拒绝这个推送。当然你不能限制开发者生成不合格的提交,但是你可以在pre-receivehook中阻止他们推送不合规的提交到正式仓库。
这个脚本不需要入参,但是每个被推送的引用都会按照如下所示的标准输入格式,以一个独立的一行传递给脚本:
<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)
再次,这跟其他的hooks不同,信息是通过标准输入而不是命令行参数传递给脚本的。当你将上面的脚本放在远程仓库的.git/hooks目录后,然后推送master分支到远程分支,你将在控制台看到如下输出:
b6b36c697eb2d24302f89aa22d9170dfe609855b 85baa88c22b52ddd24d71f05db31f4e46d579095 refs/heads/master
你可以使用这些SHA1哈希值,以及一些Git的底层命令,来查看发生了哪些修改,常用的场景如下:
拒绝包含上游rebase合并的修改。
阻止非线性合并(non-fast-forward merges)
检查用户是否有正确的权限去修改(大多数时候用语集中式的工作流)
如果多个提交引用被推送, pre-receive返回了非零状态会中止所有的引用推送。如果你想要一个一个地接受或者拒绝分支,你需要使用更新推送hook。
更新推送(Update)
The update hook is called after pre-receive, and it works much the same way. It’s still called before anything is actually updated, but it’s called separately for each ref that was pushed. That means if the user tries to push 4 branches, update is executed 4 times. Unlike pre-receive, this hook doesn’t need to read from standard input. Instead, it accepts the following 3 arguments:
更新推送hook在pre-receive之后,并且类似,它也是在推送接受前(实际更新前)被调用,但是它分别被每个提交引用被推送合并前调用。这意味着如果尝试推送4个分支,更新推送hook会执行4次。和pre-receive不同,更新推送hook不需要读取标准输入,而是接受三个参数:
正在更新的引用的名称
存储在引用里的旧的对象名
存储在引用里的新的对象名
这和pre-receive传入的类似,但是更新推送会分别被每个引用调用,因此你可以拒绝一些引用而允许另外一些引用。
#!/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)
上面的更新推送hook会输出分支以及新旧提交的哈希值。当推送多个分支到远程仓库时,你将看到每个分支执行的打印状态。
接受推送后(Post-Receive)
post-receivehook在推送操作成功后调用,这使得它是发出成功后通知的最佳地方,在许多工作流中,这比post-commit更适合用来发送通知,因为这个时候公共服务器已经有更新的代码,而不是仅仅存在于本地个人的机器上。接受推送后hook通常用来给其他开发者发送邮件或者触发持续集成系统。
这个脚本不需要参数,但是和pre-receive一样需要通过标准输入被发送相同的信息。
总结
这篇文章,我们学习了git hooks 是如何用来修改内部行为以及在当仓库发生某些事件时如何发送通知的。 hooks脚本通常是存放在.git/hooks目录中,安装和定制他们非常容易。
我们也了解了最常见的本地和服务端hooks,他们使得我们可以介入整个开发周期。我们可以在提交创建和推送过程的所有环节定制化发生的行为。只要学会一点脚本知识,我们就可以在git仓库中做任何事情。