git merge commits 绕过 pre-commit hook
背景
在我们的项目中使用了 lint-staged + husky 工具来规范化团队的代码提交。其中在pre-commit这个git hook中,执行了npx lint-staged
.lintstagedrc.yml
文件如下,包含了代码lint和图片压缩
'*.{js,jsx,ts,tsx}':
- 'npx prettier --write'
- 'npx eslint --fix'
- 'git add'
'*.{png,jpg,jpeg,gif}':
- 'image-optimize-command'
- 'git add'
但是此图片压缩命令对一张图片是可以多次执行的(尽管是无损压缩),这样会导致一个问题,当开发者需要合并主分支到其特性分支时,这个时候如果主分支添加了图片,此时在merge的过程中又发生了冲突,解决完冲突之后,重新commit(merge --continue),将会执行git re-commit hook,从而再次压缩图片。这样在gitlab MR页面,我们将会看到一些图片的变更,但是这些图片的变更并不是特性分支引起的,从而对开发者和reviewer产生一些不必要的疑惑信息。
比如,assets/img文件夹下的图片并不是我本次feature的改动,但却显示在MR上面了
基于此,我们希望在merge commit的时候,忽略图片的检查压缩。
git hooks
git hook其实就是git在特定事件(比如commit、push、merge等)触发前后会执行的特定脚本。我们最广泛使用的其实就是pre-commit hook, 通常在提交前lint代码,以确保糟糕的代码不会被提交。 默认情况下,每一个使用git 来管理代码的工程的git hooks脚本都位于 .git/hooks 文件夹下(当然你也可以通过core.hooksPath配置修改),如图
可以看到这个git 存放了很多hooks,但是初始的时候,这些文件都有.sample
后缀,这些都是默认的样式脚本,如果你想让某个hook生效,要将其后缀.sample
删除。比如将pre-commit.sample
脚本名称改为pre-commit
,并添加如下内容
#!/usr/bin/env sh
echo 'this is pre-commit hook...'
exit 1
然后你会发现每次执行git commit的时候就会打印 this is pre-commit hook...
,然后程序就自动退出了。如果你对shell不太熟悉,你也可以使用 js 来编写(前提是电脑已经安装了node)
#!/usr/bin/env node
console.log('this is pre-commit hook...');
process.exit(1);
效果和上面一样。
在执行git commit的时候,可以加上--no-verify
参数,这将会忽略pre-commit 和 commit-msg 两个hook,这时我们写的pre-commit 就不会生效了。
以下是主要的git hook执行流程
husky原理
但是,项目中的.git目录是不会提交到远程代码仓库中的,这也意味着我们本地的修改只能对自己生效,而团队成员是无法共享的。这个时候就可以使用husky这个工具来帮忙了。
在项目的package.json中添加一个postinstall script来初始化husky
{
"script": {
"postinstall": "husky install"
}
}
源码如下:
import cp = require('child_process')
import fs = require('fs')
import p = require('path')
// Logger
const l = (msg: string): void => console.log(`husky - ${msg}`)
// Git command
const git = (args: string[]): cp.SpawnSyncReturns<Buffer> =>
cp.spawnSync('git', args, { stdio: 'inherit' })
export function install(dir = '.husky'): void {
if (process.env.HUSKY === '0') {
l('HUSKY env variable is set to 0, skipping install')
return
}
// Ensure that we're inside a git repository
// If git command is not found, status is null and we should return.
// That's why status value needs to be checked explicitly.
if (git(['rev-parse']).status !== 0) {
return
}
// Custom dir help
const url = 'https://typicode.github.io/husky/#/?id=custom-directory'
// Ensure that we're not trying to install outside of cwd
if (!p.resolve(process.cwd(), dir).startsWith(process.cwd())) {
throw new Error(`.. not allowed (see ${url})`)
}
// Ensure that cwd is git top level
if (!fs.existsSync('.git')) {
throw new Error(`.git can't be found (see ${url})`)
}
try {
// Create .husky/_
fs.mkdirSync(p.join(dir, '_'), { recursive: true })
// Create .husky/_/.gitignore
fs.writeFileSync(p.join(dir, '_/.gitignore'), '*')
// Copy husky.sh to .husky/_/husky.sh
fs.copyFileSync(p.join(__dirname, '../husky.sh'), p.join(dir, '_/husky.sh'))
// Configure repo
const { error } = git(['config', 'core.hooksPath', dir])
if (error) {
throw error
}
} catch (e) {
l('Git hooks failed to install')
throw e
}
l('Git hooks installed')
}
可以看到其原理非常简单,husky会在我们项目的根目录中创建.husky目录,然后将git 的hooks目录修改为.husky。
我们在.husky中创建相应的git hook文件,然后提交到远程仓库,团队成员也就都可以共享git hooks了。
husky 详细使用文档请参考typicode.github.io/husky
问题解决
在了解git hooks和husky原理之后,其实这个问题就很好解决了,我们可以对pre-commit hook进行修改
之前的.husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
可以看到,我们其实对所有的commit都执行了lint-staged, 那其实只要在merge解决完冲突之后的commit不执行lint-staged就好了。不过有什么方式能知道当前是否处于merge状态呢?
当处于merge状态时,git 会存在一个MERGE_HEAD
,可以通过执行
git rev-parse -q --verify MERGE_HEAD
来确定当前是否处于merge状态,如果正在进行merge,此命令将会返回一个commit id。
所以,我们可以很快想到解决方案,可以在commit之前先执行该命令,然后判断返回值是否为空,如果不为空,就跳过lint-staged。
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
MERGE_HEAD=$(git rev-parse -q --verify MERGE_HEAD)
if [ -n $MERGE_HEAD ]; then
echo 'skip pre-commit hook...'
exit 0
fi
npx lint-staged
这个方案对于存在merge 冲突的情况是没有问题的,但是当我们进行普通的commit(执行git commit
)时,就会报如下错误
这是因为当不处于merge状态时,执行git rev-parse -q --verify MERGE_HEAD
命令将返回1的错误码。此时,husky.sh
中判断如果上一个命令返回了非0的退出码时,就会强制退出程序
husky.sh
源码如下
#!/usr/bin/env sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename -- "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
readonly husky_skip_init=1
export husky_skip_init
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
if [ $exitCode = 127 ]; then
echo "husky - command not found in PATH=$PATH"
fi
exit $exitCode
fi
知道了原因之后其实也很好解决,在pre-commit
脚本中不执行husky.sh
不就好了吗?
#!/usr/bin/env sh
MERGE_HEAD=$(git rev-parse -q --verify MERGE_HEAD)
if [ -n $MERGE_HEAD ]; then
echo 'skip pre-commit hook...'
exit 0
fi
npx lint-staged
这个方案确实可行,但是如果不执行husky.sh的话,在一些情况下就无法使用husky提供的一些辅助功能,比如在CI环境中设置HUSKY="0"
可以跳过所有的git hooks等。
有没有更优解呢?
其实当处于merge状态时,git会在.git目录中生成一个MERGE_HEAD
文件,不是merge状态则没有,所以可以判断这个文件是否存在来解决此问题。
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
MERGE_HEAD="$(git rev-parse --show-toplevel)/.git/MERGE_HEAD"
if [ -e $MERGE_HEAD ]; then
echo 'skip pre-commit hook...'
exit 0
fi
npx lint-staged
这就是我们项目中对此问题的最终解决方案,当处于merge 状态并进行commit时,忽略lint-staged。这样就不会重复对图片进行压缩,从而保持MR的清爽。但是你可能会发现,忽略了lint-staged,代码lint也会被忽略了。因为我们在CI中会重新执行lint,所以即使在merge 解决完冲突之后提交了不符合规范的代码,在随后的CI中也会发现,并不会将错误规范的代码合入主分支。
Ps: 其实这个解决方案类似于执行
git commit --no-verify
其实当存在merge冲突时,如果只想忽略图片压缩命令也很简单,可以写两个lint-staged配置文件,根据不同的状态采用不同的配置文件即可
.lintstagedrc.yml
'*.{js,jsx,ts,tsx}':
- 'npx prettier --write'
- 'npx eslint --fix'
- 'git add'
'*.{png,jpg,jpeg,gif}':
- 'image-optimize-command'
- 'git add'
.lintstagedrc-lint-only.yml
'*.{js,jsx,ts,tsx}':
- 'npx prettier --write'
- 'npx eslint --fix'
- 'git add'
.husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
MERGE_HEAD="$(git rev-parse --show-toplevel)/.git/MERGE_HEAD"
LINT_ONLY="$(git rev-parse --show-toplevel)/.lintstagedrc-lint-only.yml"
if [ -e $MERGE_HEAD ]; then
npx lint-staged -c LINT_ONLY
exit 0
fi
npx lint-staged
转载自:https://juejin.cn/post/7140959058676154405