Git Hooks 配合 Husky 实现 merge 时分支检查。

安装 Husky

文档地址:Husky - Git hooks

使用 pnpm

pnpm dlx husky-init && pnpm install

安装成功后,package.json会多出一条 script

"prepare": "husky install"

prepare 脚本会在每次执行完 pnpm install 或者npm install 之后自动执行

Git Merge

merge 可能存在多种情况,参考 Git Merge | Atlassian Git Tutorial

fast-forward merge

这种情况是当前分支和要合并进来的分支,分支历史没有分叉,要合并的分支是基于当前分支前进的,且当前分支从要合并的分支新建后,再也没有发生过改动。
例如我们从 main分支中新建了 dev分支,然后在dev分支中做开发,main分支没有做过任何改动,dev分支开发完成后mergemain分支

这种情况下我们使用 git reset HEAD^ --hard 的时候,整个分支会退回到 dev1-commit-3

no fast-forward merge

fast-forward merge 是我们在git merge时默认使用的方式,我们可以通过在merge的时候添加 --no-ff 参数来关闭 fast-forward 模式,在提交的时候会自动创建一个 mergecommit信息,然后合并到main分支

会新增一个历史节点,其直接父节点指向要合并的两个分支,看两张示意图

这种情况下我们使用 get reset HEAD^ --hard的时候,整个分支会回退到 dev2-commit-2

squash

通过 --squash 可以将一些不必要的 commit 进行压缩,合并的时候可以将 dev分支的历史commit进行合并,合并为一个commit
当我们 get merge --squash之后,此时文件已经合并了,但是不移动 HEAD,不提交,需要再进行一次 额外的 commit来“总结”一下,完成了最终的合并

这三种参数的示意图总结如下

merge conflict

当我们合并的两个分支存在冲突的时候,此时需要先解决完冲突,然你在重新 addcommit

GIt Merge Hooks

当我们 git merge时可能会触发四个钩子,根据 merge时的情况不同(上面四种),会执行不同的钩子

合并情况/触发钩子pre-merge-commitprepare-commit-msgcommit-msgpost-merge
fast-forward不触发不粗发不触发触发
no fast-forward触发触发触发触发
merge conflict解决冲突后 add & commit不触发触发触发不触发

最终根据合并情况,使用到的钩子情况如下

  • fast-forwardn fast-forward情况下:使用 post-merge 钩子
  • merge connflict:存在冲突时使用 prepard-commit-msg

所以我们如果要对 git merge 进行检测,最终需要用到 post-mergeprepare-commit-msg两个钩子

获取当前分支

git rev-parse --abbrev-ref HEAD

通过 Node execSync 执行上面的命令,可以获得当前所在的分支名

获取合并进来的分支名

post-merge

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
 
if [ "$?" -eq 0 ]; then
	node ./.husky/scripts/post-merge.js
else
	echo ""
fi

post-merge钩子触发时,merge操作已经结束了,已经成功的完成了合并,git reglog 已经完成了更新,所以此时可以通过 reglog 中的日志信息来获取合并进来的 分支名,该钩子会在 fast-forwardno fast-forward的情况下触发。

git reflog -1返回的日志格式

no fast-forward merge: e7cb874 HEAD@{0}: merge feat/no-fast-forward: Merge made by the 'recursive' strategy.
 
fast-forward merge: 724446f HEAD@{0}: merge feat/fast-forward: Fast-forward

根据日志信息,可以通过正则匹配的方式提取分支名

const { execSync } = require('child_process');
 
function getMergeBranch() {
  // 从 reflog 提取合并进来的分支名
  function getBranchNameFromReflog(reflogMessage) {
    const reg = /@\{\d+\}: merge (.*):/;
    return reg.exec(reflogMessage)[1];
  }
 
  const reflogMessage = execSync('git reflog -1', { encoding: 'utf8' });
  const mergedBranchName = getBranchNameFromReflog(reflogMessage);
  return mergedBranchName;
}

prepare-commit-msg

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
 
# 判断是否是冲突合并
if [ ! -z "$( ls .git/MERGE* 2>/dev/null )" ]; then
	node ./.husky/scripts/prepare-commit-msg.js
else
	echo "All fine."
fi

当 merge 存在冲突代码时,会触发该钩子,未解决冲突之前,reflog不会更新,所以不能通过 reflog信息来获取合并进来的分支。
不过在合并冲突阶段,.git/MERGE_HEAD文件中会保留合并进来分支的 hash,我们可以通过读取该文件获取赌赢的内容,再通过 git name-rev [hash]命令获取对应的分支名。
以下是通过NodeJs来判断是否是在合并中,也可以像上面代码那样用sh判断

const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
 
// 从 .git/MERGE_HEAD (sha) 提取合并进来的分支名
function getMergeBranch() {
  try {
    const mergeHeadPath = path.resolve(process.cwd(), '.git/MERGE_HEAD');
    const mergeHeadSha = fs.readFileSync(mergeHeadPath, { encoding: 'utf8' });
    const mergeBranchInfo = execSync(`git name-rev ${mergeHeadSha}`);
    return / (.*?)\n/.exec(mergeBranchInfo)[1];
  } catch (err) {
    return '';
  }
}

另外需要注意的时,因为我们使用了两个钩子post-mergeprepare-commit-merge,当no fast-forward 情况下时,这两个钩子都会执行,merge conflict 冲突模式下,只会触发 prepare-commit-merge ,不会触发 post-merge,所以我们需要判断当前是否是需要解决冲突的情况,如果是存在冲突的情况,才去执行 prepare-commit-msg钩子。

可以通过检测 .git/MERGE_MSG文件是否存在,以及其中的内容来判断当前是否处于解决冲突中

const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
 
function isMergingConflict() {
  // 是否合并中
  const mergeMsgPath = path.resolve(process.cwd(), '.git/MERGE_MSG');
  const isMerging = fs.existsSync(mergeMsgPath);
  if (!isMerging) {
    return false;
  }
 
  try {
    const mergeMsg = fs.readFileSync(mergeMsgPath, { encoding: 'utf8' });
    return /\n# Conflicts:\n/.test(mergeMsg);
  } catch (err) {}
  return false;
}

Husky 会自动退出当前终端进程

如果我们想在commit或者merge的时候通过readline来提示用户输入一些内容,会失效,例如如下代码

rl.question(`确定要将 ${mergeBranch} 分支合并到当前分支吗?(y/n) `, (answer) => {
	if (answer === 'y') {
		rl.close();
		process.exit(0);
	} else {
		console.log('\x1B[32m撤销合并中...\x1B[0m');
		console.log('\x1B[36mgit reset --merge HEAD@{1}\x1B[0m');
		execSync('git reset --merge HEAD@{1}');
		console.log('\x1B[34m已撤销合并\x1B[0m');
		rl.close();
		process.exit(0);
	}
});

会直接跳过用户输入阶段,Mac 上的解决办法是 添加

exec < /dev/tty

window 下使用一下代码有效

exec < /dev/console 

参考:

不是可执行文件的错误

如果在执行文件修改 commit 之后报错下面的错误,是因为 sh 脚本没有被识别为可执行文件,Git 不会同步文件的修改,问题表现为代码同步到了其他分支,但是并不会同步到 .git/hooks文件中,因此会导致钩子失效

hint: The '.husky/prepare-commit-msg' hook was ignored because it's not set as executable.
hint: You can disable this warning with `git config advice.ignoredHook false`.

执行 hmod +x .husky/prepare-commit-msg 之后再次 commit 就好了

Git hooks 一览

官方链接

Hook时机说明
pre-applypatchgit am执行前
applypatch-msggit am执行前
post-applypatchgit am执行后不影响git am的结果
pre-commitgit commit执行前可以用git commit --no-verify绕过
commit-msggit commit 执行前可以用git commit --no-verify绕过
post-commitgit commit执行后不影响git commit的结果
pre-merge-commitgit merge 执行前可以用git merge —no-verify绕过
prepare-commit-msggit commit 执行后,编辑器打开之前
pre-rebasegit rebase 执行前
post-checkoutgit checkout 或 git switch执行后如果不使用—no-checkout参数,则在git clone 之后也会执行
post-mergegit commit 执行后再执行git pull时也会被调用
pre-pushgit push执行前
pre-receivegit-receive-pack执行前
update
post-receivegit-receive-pack执行后不影响git-receive-pack的结果
post-update当git-receive-pack对git push做出反应并更新仓库中的引用时
push-to-checkout当git-receive-pack对git push做出反应并更新仓库中的引用时,以及当推送试图更新当前被签出的分支,且 receive.denyCurrentBranch配置被设置为 updateInstead时
pre-auto-gcgit fc —auto执行前
post-rewrite执行git commit —amend或git rebase时
sendemail-validategit send-email执行前
fsmonitor-watchman配置core.fsmonitor被设置为 .git/hooks/fsmonitor-watchman或.git/hooks/fsmonitor-watchmanv2时
p4-pre-submitgit-p4 submit 执行前可以用git-p4 submit —no-verify绕过
p4-prepare-changelistgit-p4 submit执行后,编辑器启动前可以用git-p4 submit --no-verify绕过
p4-changelistgit-p4 submit执行并编辑完changelist message可以用git-p4 submit --no-verify绕过
p4-post-changelistgit-p4 submit执行后
post-index-change索引被写入到read-cache.c do_write_locked_index

参考