likes
comments
collection
share

git merge commits 绕过 pre-commit hook

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

背景

在我们的项目中使用了 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上面了

git merge commits 绕过 pre-commit hook

基于此,我们希望在merge commit的时候,忽略图片的检查压缩。

git hooks

git hook其实就是git在特定事件(比如commit、push、merge等)触发前后会执行的特定脚本。我们最广泛使用的其实就是pre-commit hook, 通常在提交前lint代码,以确保糟糕的代码不会被提交。 默认情况下,每一个使用git 来管理代码的工程的git hooks脚本都位于 .git/hooks 文件夹下(当然你也可以通过core.hooksPath配置修改),如图

git merge commits 绕过 pre-commit hook

可以看到这个git 存放了很多hooks,但是初始的时候,这些文件都有.sample后缀,这些都是默认的样式脚本,如果你想让某个hook生效,要将其后缀.sample删除。比如将pre-commit.sample脚本名称改为pre-commit,并添加如下内容

#!/usr/bin/env shecho 'this is pre-commit hook...'
exit 1

然后你会发现每次执行git commit的时候就会打印 this is pre-commit hook...,然后程序就自动退出了git merge commits 绕过 pre-commit hook。如果你对shell不太熟悉,你也可以使用 js 来编写(前提是电脑已经安装了node)

#!/usr/bin/env nodeconsole.log('this is pre-commit hook...');
process.exit(1);

效果和上面一样。

在执行git commit的时候,可以加上--no-verify参数,这将会忽略pre-commit 和 commit-msg 两个hook,这时我们写的pre-commit 就不会生效了。

以下是主要的git hook执行流程

git merge commits 绕过 pre-commit 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)时,就会报如下错误

git merge commits 绕过 pre-commit hook

这是因为当不处于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
  fireadonly 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