Android Studio项目gradle+Git Hooks 实现提交时对提交日志和代码(checkStyle)的检查
主要解决对项目的日志和代码的规范控制,通过在git提交之前,实现对提交日志和代码的checkStye进行检查。
Git hooks 原理
钩子都被存储在 Git 目录下的 hooks 子目录中。 也即绝大部分项目中的 .git/hooks。 当你用 git init 初始化一个新版本库时,Git 默认会在这个目录中放置一些示例脚本。这些脚本除了本身可以被调用外,它们还透露了被触发时所传入的参数。 所有的示例都是 shell 脚本,其中一些还混杂了 Perl 代码,不过,任何正确命名的可执行脚本都可以正常使用 —— 你可以用 Ruby 或 Python,或其它语言编写它们。 这些示例的名字都是以 .sample 结尾,如果你想启用它们,得先移除这个后缀。
把一个正确命名且可执行的文件放入 Git 目录下的 hooks 子目录中,即可激活该钩子脚本。 这样一来,它就能被 Git 调用。
下面分两部分进行介绍,分别是提交日志部分和代码的检测。
提交日志的检查
要求
每次提交,Commit message 都包括三个部分:Header,Body 和 Footer。
<type>(<scope>): <subject>
// 空一行
<body>
// 空一行
<footer>
其中,Header 是必需的,Body 和 Footer 可以省略。
不管是哪一个部分,任何一行都不得超过72个字符(或100个字符)。这是为了避免自动换行影响美观。
Header部分只有一行,包括三个字段:type(必需)、scope(可选)和subject(必需)。
**type**用于说明 commit 的类别,只允许使用下面7个标识。
- feat: 新功能(feature)
- fix: 修补bug
- docs: 文档(documentation)
- style: 格式(不影响代码运行的变动)
- refactor: 重构(即不是新增功能,也不是修改bug的代码变动)
- perf: 性能优化(performance)
- test: 增加测试
- chore: 构建过程或辅助工具的变动
- revert: 还有一种特殊情况,如果当前commit用于撤销以前的commit,则必须以revert:开头,后面跟着被撤Commit的Header。
revert: feat(pencil): add 'graphiteWidth' option
**scope**用于说明 commit 影响的范围,比如数据层、控制层、视图层等等,视项目不同而不同。
**subject**是 commit 目的的简短描述。
Body部分的格式是固定的,必须写成This reverts commit <hash>.,其中的hash是被撤销 commit 的 SHA 标识符。
实现
我们修改commit-msg文件,由于对shell脚本不熟悉,因此通过python来实现,注意修改commit-msg文件顶部的代码
将
#!/bin/sh
修改为
#!/usr/bin/python2.6
否则识别不了python脚本
- 1、先创建一个跟commit-msg相关的配置文件(commit-msg-config.txt),并将该文件放置在commit-msg的同一目录下。我们将一些正则表达式,提示信息放在里面,便于后期修改维护,
commitMessageRegex=.+(\n\n.){0,2}
commitMessage=代码提交备注需要格式统一,格式为:\nHeader(72个字以内)+空行+Body(72个字以内,可以省略)+空行 +Footer(72个字以内,可以省略);
HeadLengthMessage=Header字数不能超过72文字
HeadFormatMessage=Head的格式为<type>(<scope>): <subject> type(必需)、scope(可选)和subject(必需) **type**用于说明commit的类别,只允许使用下面7个标识。\n**scope**用于说明commit影响的范围,比如数据层、控制层、视图层等等,视项目不同而不同。\n**subject**是commit目的的简短描述
headTypeRegex=^(feat|fix|docs|style|refactor|perf|test|chore)(\(.*\)){0,1}$|^revert$
headTypeMessage=type的可选值为: feat: 新功能(feature) fix: 修补bug\ndocs: 文档(documentation) tyle: 格式(不影响代码运行的变动) refactor: 重构(即不是新增功能,也不是修改bug的代码变动) perf: 性能优化(performance) test: 增加测试 chore: 构建过程或辅助工具的变动 revert:当前commit用于撤销以前的commit
RevertCommitHeadMessage=你的type是revert,则必须以revert:开头,后面跟着被撤Commit的Header,当前检测到你的被撤Commit的Header格式有误。
RevertMessage=你的type是revert,则必须以revert:开头,后面跟着被撤Commit的Header。如:\n revert: feat(pencil): add 'graphiteWidth' option
BodyLengthMessage=Body不能超过72个文字
FootLengthMessage=Body不能超过72个文字
- 2、修改commit-msg文件
#!/usr/bin/python2.6
#coding=utf-8
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message. The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit. The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".
# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
# This example catches duplicate Signed-off-by lines.
# test "" = "$(grep '^Signed-off-by: ' "$1" |
# sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
# echo >&2 Duplicate Signed-off-by lines.
# exit 1
# }
import re
import sys
import os
map = dict()
configFile = open('.git/hooks/commit-msg-config.txt','r')
for line in open('.git/hooks/commit-msg-config.txt'):
line = configFile.readline()
value = line.split('=',2)
if len(value)==2:
map[value[0]]=value[1]
filePath = sys.argv[1]
file = open(filePath)
content = file.read(os.path.getsize(filePath))
machObject = re.match(map["commitMessageRegex"], content, flags=0)
if not machObject:
print map["commitMessage"]
exit(1)
splitArray = content.split('\n\n',3)
if len(splitArray[0].decode('utf-8')) > 73:
print map["HeadLengthMessage"]
exit(1)
head = splitArray[0]
splitHead = head.split(':',2)
if len(splitHead) < 2 or len(splitHead)>3:
print map["HeadFormatMessage"]
exit(1)
machHead=re.match(map["headTypeRegex"],splitHead[0],flags=0)
if not machHead:
print (map["headTypeMessage"])
exit(1)
if splitHead[0]=="revert" and len(splitHead)==3:
machRevertHead = re.match(headRegex,splitHead[1],flags=0)
if not machRevertHead:
print map["RevertCommitHeadMessage"]
exit(1)
else:
print map["RevertMessage"]
exit(1)
if len(splitArray) > 1 and len(splitArray[1].decode('utf-8')) > 73:
print map["BodyLengthMessage"]
exit(1)
if len(splitArray) > 2 and len(splitArray[2].decode('utf-8')) > 73:
print map["FootLengthMessage"]
exit(1)
file.close()
其中下面这段代码用于读取我们前面创建的配置文件
map = dict()
configFile = open('.git/hooks/commit-msg-config.txt','r')
for line in open('.git/hooks/commit-msg-config.txt'):
line = configFile.readline()
value = line.split('=',2)
if len(value)==2:
map[value[0]]=value[1]
这段代码用于读取我们提交的日志,用于判断其格式是否正确
filePath = sys.argv[1]
file = open(filePath)
content = file.read(os.path.getsize(filePath))
保存之后,但我们再次通过git提交代码时,git hooks就会对日志进行检查,如果不符合格式,就会将错误打印出来,并终止提交操作。记住,commit-msg的后缀名.sample需要将其去掉,如果还是不起作用,可以通过
chmod a+x commit-msg
给予其读写权限。
提交代码的检查
原理
Android的Gradle Api原生就有checkStyle类型的task,我们需要应用checkstyle plugin,并且实现一个这样的task就可以执行检查代码风格,并且生成检查报告。代码如下:
allprojects {
...
...
apply plugin: 'checkstyle'
checkstyle {
configFile rootProject.file('checkstyle.xml')
toolVersion '6.19'
ignoreFailures false
showViolations true
}
task('checkstyle', type: Checkstyle) {
source 'src/main/java'
include '**/*.java'
exclude '**/R.java'
exclude '**/BuildConfig.java'
classpath = files()
}
}
通过hook git commit就可以来执行这个脚本,然后根据检查结果决定是否可以commit
实现
我们首先要做的有以下几件事:
- 1、编写一份checkstyle的xml文件
- 2、用
checkstyle
task的include
,exclude
将需要的和不需要的java类添加进来。 - 3、编写git hook文件调用checkstyle的task
贴上checkstyle的xml文件,大家可以根据自己实际项目需要的进行配置
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://www.puppycrawl.com/dtds/configuration_1_3.dtd">
<!--
Checkstyle configuration that checks the Google coding conventions from Google Java Style
that can be found at https://google.github.io/styleguide/javaguide.html.
Checkstyle is very configurable. Be sure to read the documentation at
http://checkstyle.sf.net (or in your downloaded distribution).
To completely disable a check, just comment it out or delete it from the file.
Authors: Max Vetrenko, Ruslan Diachenko, Roman Ivanov.
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<module name="TreeWalker">
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="maxLineLength" value="100"/>
</module>
<module name="RightCurly">
<property name="id" value="RightCurlySame"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_CATCH, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_DO"/>
</module>
<module name="RightCurly">
<property name="id" value="RightCurlyAlone"/>
<property name="option" value="alone"/>
<property name="tokens" value="CLASS_DEF, METHOD_DEF, CTOR_DEF, LITERAL_FOR, LITERAL_WHILE, STATIC_INIT, INSTANCE_INIT"/>
</module>
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[a-z][a-z0-9][a-zA-Z0-9]*$"/>
<message key="name.invalidPattern"
value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern"
value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<!-- <module name="CatchParameterName">
<property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern"
value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module> -->
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern"
value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
<message key="name.invalidPattern"
value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
<message key="name.invalidPattern"
value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
<message key="name.invalidPattern"
value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed"
value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded"
value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow"
value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded"
value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="2"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="2"/>
<property name="throwsIndent" value="4"/>
<property name="lineWrappingIndentation" value="4"/>
<property name="arrayInitIndent" value="2"/>
</module>
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
<module name="MethodParamPad"/>
<module name="ParenPad"/>
<!-- <module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module> -->
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="NonEmptyAtclauseDescription"/>
<module name="JavadocTagContinuationIndentation"/>
<module name="SummaryJavadoc">
<property name="forbiddenSummaryFragments" value="^@return the *|^This method returns |^A [{]@code [a-zA-Z0-9]+[}]( is a )"/>
</module>
<module name="JavadocParagraph"/>
<module name="AtclauseOrder">
<property name="tagOrder" value="@param, @return, @throws, @deprecated"/>
<property name="target" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF, VARIABLE_DEF"/>
</module>
<module name="JavadocMethod">
<property name="scope" value="public"/>
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingThrowsTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="minLineCount" value="2"/>
<property name="allowedAnnotations" value="Override, Test"/>
<property name="allowThrowsTagsForSubclasses" value="true"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-z0-9][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="SingleLineJavadoc">
<property name="ignoreInlineTags" value="false"/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
</module>
</module>
gradle中的exclude
文件比较好写,但是include
需要我们只对修改的文件进行检查,否则每次全工程检查,会花费大量的时间。
我们知道 git status -s
能够得到修改过的文件字符串,因此我们在gradle中调用该指令,获取修改过的文件列表。
def getChangeFiles() {
try {
String changeInfo = 'git status -s'.execute(null, project.rootDir).text.trim()
return changeInfo == null ? "" : changeInfo
} catch (Exception e) {
return ""
}
}
然后再解析这个字符串,就可以得到修改过的java文件类名集合。实现函数如下:
def filterCommitter(String gitstatusinfo) {
ArrayList<String> filterList = new ArrayList<String>();
String[] lines = gitstatusinfo.split("\\n")
for (String line : lines) {
if (line.contains(".java")) {
String[] spliters = line.trim().split(" ");
for (String str : spliters) {
if (str.contains(".java")) {
filterList.add(str)
}
}
}
}
return filterList;
}
这样我们再在gradle里面,将上述的java文件include
进去就可以,就可以实现用gradle task只对修改过的java文件做checkstyle了。
下面贴上gradle完整的代码
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.1.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
jcenter()
mavenCentral()
}
apply plugin: 'checkstyle'
checkstyle {
toolVersion '6.13'
ignoreFailures false
showViolations true
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
task checkstyle(type: Checkstyle) {
source 'app/src/main/java'
exclude '**/gen/**'
exclude '**/R.java'
exclude '**/BuildConfig.java'
if (project.hasProperty('checkCommit') && project.property("checkCommit")) {
def ft = filterCommitter(getChangeFiles());
def includeList = new ArrayList<String>()
for (int i = 0; i < ft.size(); i++) {
String spliter = ft.getAt(i)
String[] spliterlist = spliter.split("/")
String fileName = spliterlist[spliterlist.length - 1]
includeList.add("**/" + fileName)
}
if (includeList.size() == 0) {
exclude '**/*.java'
} else {
println("includeList=="+includeList)
include includeList
}
} else {
include '**/*.java'
}
configFile rootProject.file('checkstyle.xml')
classpath = files()
}
def getChangeFiles() {
try {
String changeInfo = 'git status -s'.execute(null, project.rootDir).text.trim()
return changeInfo == null ? "" : changeInfo
} catch (Exception e) {
return ""
}
}
def filterCommitter(String gitstatusinfo) {
ArrayList<String> filterList = new ArrayList<String>();
String[] lines = gitstatusinfo.split("\\n")
for (String line : lines) {
if (line.contains(".java")) {
String[] spliters = line.trim().split(" ");
for (String str : spliters) {
if (str.contains(".java")) {
filterList.add(str)
}
}
}
}
return filterList;
}
gradle文件添加了checkstyle的task之后,我们还需要做到git commit时自动检查,这时就需要修改git hook了。
我们去除./git/hook/pre-commit
文件的后缀名.sample,并在里面调用gradle task,并以task的输出结果判断是否可以commit。代码如下:
#!/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
SCRIPT_DIR=$(dirname "$0")
SCRIPT_ABS_PATH=`cd "$SCRIPT_DIR"; pwd`
$SCRIPT_ABS_PATH/../../gradlew -PcheckCommit="true" checkstyle
if [ $? -eq 0 ]; then
echo "checkstyle OK"
else
exit [[ $ERROR_INFO =~ "checkstyle" ]] && exit 1
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 --
这样,我们在通过git提交代码时,就能对修改过的代码进行检查,看是否符合我们规定的风格(checkstyle.xml)
最后感谢以下两位给我提供的思路