简介

你或者你的同事、朋友是否有遇到过提交的代码无法运行的问题?

可能是某个 class 中某个语句缺少了结尾分号,导致了在部署的时候编译出错。

他也可能是由于系统的某个微小的特性改变了,导致单元测试不通过。

或者更加严重的,这个问题并没有能够被及时发现,而是用户那边提出来的。

在我及我的朋友身上已经发生过几次这种事情了,就是在提交 merge 或者 rebase 到 master 分支之前,并没有经过编译测试,就直接将代码修改提交到了服务器上。这并不安全,并且是应该极力避免的。

如何避免呢?下面我将向你介绍 pre-commit 这个 git hooks(Git 钩子函数)。

Git hooks

Git hooks 其实就是一个 shell 函数,它可以在特定的操作发生前/后被执行。你可以在你项目的 .git/hooks 路径下找到,比如 .git/hooks/pre-commit.sh.sample。如果要让这个脚本生效,我们需要将该文件末尾的 .sample 后缀去掉。

Pre-commit 这个钩子,从名字也能看出,它将会在你每次提交 commit 之前执行,比如 git commit -m “初始化提交”。这个脚本便会在你提交之前先执行。如果这个脚本执行到最后的退出代码为 0 时,就代表执行成功,然后 commit 将会被接着执行,但如果这个脚本返回了非 0 的退出代码,则代表这个脚本执行失败了(比如编译失败)则后面的 commit 操作将不会再被执行。

以下便是初始化时 pre-commit.sample 脚本的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".

if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=4b825dc642cb6eb9a060e54bf8d69288fbee4904
fi

# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --bool hooks.allownonascii)

# Redirect output to stderr.
exec 1>&2

# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
# Note that the use of brackets around a tr range is ok here, (it's
# even required, for portability to Solaris 10's /usr/bin/tr), since
# the square bracket bytes happen to fall in the designated range.
test $(git diff --cached --name-only --diff-filter=A -z $against |
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
cat <<\EOF
Error: Attempt to add a non-ASCII file name.

This can cause problems if you want to work with people on other platforms.

To be portable it is advisable to rename the file.

If you know what you are doing you can disable this check using:

git config hooks.allownonascii true
EOF
exit 1
fi

# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

拒绝不合格的 commit 提交

通过 pre-commit 这个 Git hook,我们能够很轻易地实现过滤不想要的提交。当我们的脚本退出代码为 1 的时候,他检测到有错误发生,然后 pre-commit 脚本执行失败。在这种情况下,这个 commit 将不会被提交,当然他也不会出现在任何分枝上。

我们可以使用这个钩子来创建一个测试控制器脚本。这个脚本应该构建我们的项目,然后运行所有的测试代码,来保证当前改变并不会影响应用的行为,最好再测试一下本应用能否正常启动。

我最近就创建了一个 pre-commit 脚本,它能够运行我项目的测试控制代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash
#
# This pre-commit hook runs test harness before commit.
# PASTE THIS FILE INTO .git/hooks/ IN YOUR REPOSITORY TO PREVENT UNSAFE COMMITS!

echo "PRE COMMIT HOOK START"
#Run test harness
bash scripts/testharness.sh

if [ $? -ne 0 ]; then
echo "CODE IS NOT READY TO COMMIT"
exit 1
fi

echo "PRE COMMIT HOOK FINISHED WITH SUCCESS"

首先,这个脚本需要运行一个 bash 脚本 script/testharness.sh。

然后,通过 if [ $1 -ne 0 ]; then 检查上一个脚本执行的退出代码是否为 0,即代表成功(0代表失败)

我的 pre-commit 脚本看起来比较简单。真正核心的指令都在 testharness.sh 这个脚本里面。

testharness.sh

维基百科上说

在软件开发中,test harness 或者自动测试化框架是一个软件以及测试数据和配置项的组合,用于软件单元测试,具体则是通过验证它的运行状态以及监控它的行为和输出来测试一个软件单元。

我想编写一个让我不再提交错误代码的脚本。我也是一个普通人,我不可能在我每次提交前都仔细认真地检查每行代码,所以,总是可能发生错误。

我只用了五个步骤,来帮助我部署一个整洁的没有低级错误的代码到我的代码仓库中:

  • 编译项目
  • 运行单元测试和集成测试
  • 运行 Web 应用
  • 在运行着的 Web 程序上执行自动化测试
  • 关闭 Web 应用并清理现场

我写了一个 testharness.sh 来自动执行这个任务而不是每次都手动地去执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#!/bin/bash

# VARIABLES
PORT=9000
APP_VERSION="0.0.1-SNAPSHOT"

# FUNCTIONS
function log {
printf "\n--- $1 ---\n"
}

function build {
log "RUNNING MVN BUILD"
mvn clean package -P dev
if [ "$?" -ne 0 ]; then
log "INVALID BUILD"
exit 1
fi
}

function shutdownServerOnPort {
log "CLEANING UP PORT"
kill -9 $(lsof -t -i:$PORT)
}

function runApp {
log "RUNNING RESERVATION_API"
java -jar target/ticketarea-reservation-api-$APP_VERSION.jar &
}

function runAutomatedTests {
sleep 20s
log "RUNNING AUTOMATED TESTS"
mvn clean test -P automated-tests
if [ "$?" -ne 0 ]; then
log "AUTOMATED TESTS FAILED"
shutdownServerOnPort
exit 1
fi
}

function tearDown {
shutdownServerOnPort
exit 0
}

# PROCESS
log "STARTING TEST HARNESS BUILD"
build
shutdownServerOnPort
runApp
runAutomatedTests
log "TEST HARNESS FINISHED SUCCESSFULLY"
tearDown

首先,我编译了整个项目。

第二步,运行单元测试和集成测试。因为我是用的 maven 管理项目,所以只需要执行 mvn clean packge 即可。

紧接着,这个项目就已经存在于 /target 目录下了。所以,在第三步中,我执行 runApp 来运行这个 web 应用,并且等待它的执行结果。

第四步,运行自动化测试,在第三步的时候就触发了一系列的请求。自动化测试可能会耗费不少的时间(甚至几分钟)所以这是一个去厨房弄杯咖啡的好时机。

第五步,tearDown 只是通过 kill 掉程序对应的进程来关闭这个 Web 应用,然后清理现场。清理现场这个函数其实也可能会在其他几步中被调用(在发生错误后,退出程序前)。在清理完成后,即退出脚本。

现在让我们试试提交一些改动,从而触发自动测试等的执行。

Test harness 的使用

Git pre-commit script in action

正如你所看到的一样,脚本执行成功,因此 commit 也顺利提交到了我的主分支。

测试脚本执行其实超过了一分钟。如果我们时间比较紧急,比如我们必须尽快地修复生产环境的 Bug,我们可以用 -n 或者 —-no-verify 命令跳过所有的钩子。比如

1
git commit -nm ”快速修复”

总结

正如上文所述,git hooks 非常有用。我这儿仅仅是列举了其中的一个, pre-commit,能够避免开发者提交不符合的代码。

通过将测试框架脚本和 pre-commit 结合,便能够确保你的提交不会在编译的时候就因为一些低级错误挂掉,或者提交后改变了应用的行为。

这个策略让我在日常的代码编写中受益良多。