阅读视图

发现新文章,点击刷新页面。

Hg hooks 实践历程

故事的开始

相信使用 Hg 的同学们已经非常熟悉上面这几句话了,我们每次在提交或者拉取代码时总需要手动执行某些命令,实在繁琐。

但现在已经 2022 年了,难道就没有更好的解决方法吗?

有的,我们知道,在 Git 有个东西叫做 hooks(钩子),可以在特定事件发生之前或之后执行特定动作。

同样的,Hg 也有 hooks,不过并不像 Git 一样生态蓬勃发展,也没有太多现有的开源工具可供大家使用。

本文就来介绍一下我们从 0 到 1 的 Hg hooks 实践过程,同时也希望能够起到抛砖引玉的作用。

石器时代

在没有引入 Hg hooks 之前,我们常常会面对几种情况:

  1. 有同学在提交代码时忘记执行 yarn lintyarn test
  2. 修改了 project-config 的常量,却忘记通知大家,或者有人错过了这条信息。

这都有可能会导致其他小伙伴拉取代码后,发现页面上的某个功能突然异常,花费一段时间排查才发现原来是没有执行 yarn setup

可能有部分同学会想到,那我自定义一个命令在提交或者拉取代码时自动做这件事不就好了吗?

比如这样:

1
2
3
> yarn lint && hg commit -m "xxx"

> hg pull --update && yarn setup

这样也不是不行,但是会存在一些问题:

  1. 由于每个人的拉取代码的命令不一样,如果项目开发流程发生变化,则每个人都需要同步修改
  2. 有些同学习惯使用图形化界面,比如 SourceTree、vscode-hg 等,则无法自定义操作命令

因此,我们另辟蹊径,寻找更好的解决方案。

青铜时代

我们最主要想解决的问题就是:

  1. 在提交代码前自动执行 yarn lintyarn test,不通过则直接终止提交。
  2. 在拉取代码后,检测到如果 project-config 目录发生改动,则自动执行 yarn setup
  3. 还有更多:
    1. 检查 commit message 规范
    2. 统一代码的格式化风格

这些都可以通过 Hg hooks 解决,所以开始之前,我们先对 Hg hooks 做一个简单的认识。

Hg hooks 介绍

Hg hooks 能做什么,这次再介绍一遍:它可以在特定事件发生之前或之后执行特定动作。

特定事件,指的就是我们在对 Hg 仓库进行操作时的一些钩子,比如提交前(precommit)、提交后(commit),可以在这里查看全部 hooks 列表:hooks

下面介绍一下如何使用 hook,我们可以通过以下两个文件进行配置:

  1. ~/.hgrc:全局的,将对所有 hg 仓库起作用。
  2. 项目根目录的 .hg/hgrc :仅对当前仓库起作用。

比如我们想要实现一个简单的需求:在提交代码前进行 yarn lint

首先编辑 .hg/hgrc文件:

1
2
[hooks]
precommit = ./bin/hooks/precommit.sh # 这个路径是相对于项目根目录的

然后编写脚本 bin/hooks/precommit.sh(也可以使用 python):

1
2
3
4
5
6
7
8
#!/bin/bash

PATH=/usr/local/bin:/opt/homebrew/bin:$PATH

yarn lint

# lint 没有通过直接退出
if [ $? -ne 0 ]; then exit 1; fi

这里需要特别指出,之所以需要重新声明 PATH 变量:

  1. hooks 脚本的运行环境取决于同学提交代码的地方,比如通过 SourceTree 提交,由于环境不一样,就可能会出现 yarn: command not found 的报错,参见:‘Git Command Not found’ in the custom action for SourceTree - Stack Overflow
  2. 每个同学安装 hg 的方式可能不一样,有通过 brew、pip、甚至自己手动编译的,它们的可执行文件路径不一样。
    • 可以通过 which hg 查看这个命令的可执行文件路径。

这样,一个简单的 hook 就配置完成了,这时候提交代码就会触发 precommit.sh

1
2
3
> hg commit -m "ci: precommit hooks"

$ eslint '**/*.js' --cache --fix

当 hook 脚本的 exit code 不为 0 的时候,则会终止当前的 Hg 操作,对于某些具有事务性的 hook(e.g. pretxncommit),还会自动进行回滚。

可以通过以下链接对 Hg hooks 进行更深入地学习:

Hg hooks 实践

提交代码前(precommit)

这里需要用到的 hook 是 precommit,它的运行时机在提交之前,exit code 非 0 时将终止提交。

precommit.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
#!/bin/bash

PATH=/usr/local/bin:/opt/homebrew/bin:$PATH

if [ "$SKIP_LINT" = "1" ]; then
exit 0
fi

# 需要 lint 的项目
apps=(
'miniprogram'
'dashboard'
'core'
)

for app in "${apps[@]}"; do
# 判断是否修改该项目,无则跳过 lint
has_change=$(hg status | grep "${app}")

if [ -z "$has_change" ]; then continue; fi

cd "$app" || exit 1

yarn && yarn lint

# lint 是否报错,是则直接退出脚本
if [ $? -ne 0 ]; then exit 1; fi

cd -
done

# 针对当前修改或新增的文件批量进行 prettier 格式化
hg status | grep -E "^(M|A).*.(js|json|wxss)$" | sed 's|^M||g; s|^A||g' | xargs ./node_modules/.bin/prettier --write >/dev/null 2>&1

比较浅显易懂,由于是 Monorepo 架构,所以仅针对当前改动的子项目执行 yarn lint ,当 lint 不通过时终止提交;然后仅对当前变更的文件做 prettier 格式化,并且忽略这行命令的输出和错误。

ps:其实这里的 prettier 机制有点问题,原本的目的是仅格式化当前提交的文件,但 Hg 没有 staging area 的概念,故只能粗暴处理,如果有更好的解决方法欢迎指教。

  • hg commit 可以只提交指定的部分文件,所以是有 changed files 和 commited files 两个概念,但是没有找到办法获取 commited files,参见:Mercurial pre-commit hook: How to tell apart changed and committed files - Stack Overflow
  • 另一种思路:使用 pretxncommit 钩子,就可通过 $Hg_NODE 变量拿到当前 commit 的信息,但缺点是 pretxncommit 阶段将不能再对文件进行改动,则格式化后需要重新提交一遍。

随着版本迭代,在 precommit 钩子中增加了检测 utils、test 目录改动则自动执行单元测试 :

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
# 修改以下文件需要执行单元测试
apps=(
'miniprogram/utils miniprogram/test'
)

for app in "${apps[@]}"; do
dir=($app)
pass=0

for d in "${dir[@]}"; do
# 判断是否修改工具方法、测试用例,无则跳过
has_change=$(hg status | grep "${d}")

if [ -z "$has_change" ]; then continue; fi

# 同一个项目只执行一次
[ $pass -eq 1 ] && break

cd "$d" || exit 1
yarn && yarn test

# test 是否报错,是则直接退出脚本
if [ $? -ne 0 ]; then exit 1; fi

pass=1

cd -
done
done

拉取代码后(changegroup)

主要想解决的问题是:当拉取代码后,检测到 project-config 目录发生变更,则执行 yarn setup

首先要解决第一点,如何获取从远端拉取代码所改动的文件?有下面几种方法:

  1. hg incoming:显示远端中的新 commit

    • 缺点:该方法只是显示新的 commit,后面仍需要再进行一次 pull 才能将新 commit 拉取下来,导致拉取代码时间翻倍。
  2. hg pull:在拉取代码之后、进行 update 或 rebase 之前,通过 hg log 对比本地 head 和 远端拉取下来的 head。

  3. hooks:

    1. update:工作目录发生改变时,所以只要进行提交、储藏、切换分支都会触发,不考虑
    2. incoming:每一个新的 commit 被传入时都会触发一次,过于频繁,不考虑
    3. changegroup:在 push、pull、unbundle 时都会触发,但多个 commit 被传入也只会触发一次,可考虑
    4. 还有一些不太满足的 hooks 不一一介绍了。

在一开始,我们曾尝试使用第二种方法解决该问题:

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
hgpl() {
no_shelve=$(hg shelve | grep "nothing changed")

hg pull

# 改动文件
regex='\bproject-config'

# 获取本次 pull 变更集的改动文件列表,判断是否有改动相关文件
# @link:https://stackoverflow.com/questions/3277334/what-files-will-be-changed-vs-added-when-i-do-an-hg-pull-and-hg-update
has_change=$(hg log --verbose -r .:tip | grep "files:" | grep -E "$regex")

# 参考 hg update --rebase 的实现,先尝试 rebase,如果不需要 rebase,则直接 update
# @link:https://stackoverflow.com/questions/35327163/what-is-the-rebase-command-used-in-hg-pull-rebase
# @link:https://www.mercurial-scm.org/repo/hg/file/tip/hgext/rebase.py#l2172
has_rebase=$(hg rebase -b . -d 'last(branch(.))' | grep "nothing to rebase")

if [ ! -z "$has_rebase" ]; then
hg update
fi

# 有改动相关文件,需要执行 yarn setup
if [ ! -z "$has_change" ]; then
yarn setup
fi

# 如果之前有 shelve,需要恢复 shelve
if [ -z "$no_shelve" ]; then
hg unshelve
fi
}

这个方法可以很好地工作,它可以满足:

  • 拉取代码时自动储藏、恢复本地改动
  • 当两端都同时修改 project-config 时,可以 update 或者 rebase 后再统一 yarn setup

后来发现使用 changegroup hook 配合 hg log 一样可以解决问题,于是就有了 changegroup.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash

PATH=/usr/local/bin:/opt/homebrew/bin:$PATH

# 改动文件
regex='\bproject-config'

# 获取本次变更集的改动文件列表,判断是否有改动相关文件
# @see:https://stackoverflow.com/questions/3277334/what-files-will-be-changed-vs-added-when-i-do-an-hg-pull-and-hg-update
has_change=$(hg log -v -r $Hg_NODE: | grep "files:" | grep -E "$regex")

# 有改动相关文件,需要执行 yarn setup
if [ ! -z "$has_change" ]; then
cd $(hg root) || exit 1
yarn setup
cd -
fi

因此 hgpl 可以精简成这样:

1
2
3
4
5
6
7
8
9
10
hgpl() {
has_shelve=$(hg shelve | grep "nothing changed")

hg pull --rebase

# 如果之前有 shelve,需要恢复 shelve
if [ -z "$has_shelve" ]; then
hg unshelve
fi
}

commit message 检查(pretxncommit)

使用 pretxncommit 钩子可对当前提交信息进行检查,如检查 commit message:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash

PATH=/usr/local/bin:/opt/homebrew/bin:$PATH

if [ "$SKIP_LINT" = "1" ]; then
exit 0
fi

shelve_user="shelve@localhost"

commit_user=$(hg tip --template {user})

# 因为 hg shelve 也会触发 pretxncommit 钩子,所以要进行忽略
if [ "$commit_user" == "$shelve_user" ]; then
exit 0
fi

commit_message=$(hg tip --template {desc})

echo "[msg] $commit_message"

echo "$commit_message" | ./node_modules/.bin/commitlint

铁器时代?

这个标题之所以打上一个问号,是因为该方案仍在 POC 阶段,尚未落地实施,但也可作为一个对未来的展望。

迄今为止,我们的 Hg hooks 已经能够满足大部分场景了,那还存在些什么问题呢?

相信不少同学已经发现这样操作会存在有一个很明显的问题,那就是:hooks 配置如何同步?

我们知道 .hg 目录是不会加入版本控制的,这是非常合理且必要的,因为 hooks 本身是一些权限极高的可执行脚本,所以出于安全考虑(你也不想你 clone 一个仓库后,它会自动执行某些你不想执行的命令),因此是不会有任何一个 VSC 会将 hooks 加入版本控制的。

可是这就会导致:

  1. 假如项目新增了一个 hook,需要通知项目成员同步修改本地的 hooks 配置。
  2. 新成员加入项目,需要手动配置 hooks。

如果这个问题不能得到解决,那归根到底还是无法绕过通知项目成员手动操作的过程。

所幸,以上问题在 Git 中同样存在,并且已经有很多非常成熟的方案,如: huksypre-commit

那有没有人在 Hg 生态上解决这个问题呢?粗略找到了两个:

  1. husky-hg
  2. tdog-husky-hg(前者的 fork

都是基于 husky v0.14.3 改造的,最后提交时间都在三年前(2019),然而 husky 现在已经迭代到 v7 版本了,这 3 年间经过无数迭代,使用方式和实现原理都发生翻天覆地的变化,于是我们决定基于 husky v7 自行改造。

但是在此之前,我们先了解一下 Git 如何配置 hooks:

  1. 在以前的 Git 版本中,如果要配置一个 hooks 则需要在 .git/hooks 目录新增一个 hook 同名的可执行文件,并且出于前面说的安全考虑, .git 目录是不会被加入版本控制的,因此也存在上面所说的问题
  2. 在 Git v2.9 以后,支持通过配置 core.hooksPath 自定义项目的 hooks 的存放路径,也即意味着可将 hooks 加入版本控制,项目成员只需要在第一次配置 core.hooksPath 即可,后续增删 hooks 都可直接使用。

因为 Git 支持 core.hooksPath ,所以 husky 直接采用了新的实现原理重构:

  1. 在 huksy v4 的时候,由于 Git hooks 目录无法被加入版本控制,它们是这样解决这个问题的:

    1. 在初始化的时候就在 .git/hooks 目录预先创建所有的 hooks 可执行文件,然后在 hooks 文件中执行定义在 package.json 中的 hooks 命令。
    2. 这样很显然可以解决 hooks 无法同步的问题,但是这个实现原理也被不少人诟病,见 #260
  2. 由于 Git v2.9 的升级,在 husky v7 中使用了新的实现方式:

    1. 将 hooks 可执行文件存放在一个可以被进行版本控制的目录(默认是 .husky),然后初始化的时候只需要配置 core.hooksPath 即可。

显然,v7 的实现方式更加方便快捷了,除此之外,它们的使用方式也有很大的不同:

  1. 在 v4 中,通过在 package.json 中配置 husky 字段来定义 hooks。
  2. 在 v7 中,它不再仅限于 Node.js 项目,可以直接通过 CLI 的方式进行配置,参见:Why husky has dropped conventional JS config

在深入了解背后的实现原理后,我们得出了结论:

  1. v4 版本的代码有较多历史包袱,不利于改造,故基于 v7 版本修改
  2. 但 v7 版本的实现方式对 Hg 并不完全适用,所以需要继续沿用 v4 的部分实现方式,所以这样设计:
    1. 将 hooks 脚本存放在可被版本控制的 .husky 目录
    2. 但不通过预注册所有的 hooks 的方式,而是采用按需配置,初始化时根据 .husky 的 hooks 可执行文件列表注入 hooks 配置。
      1. 比如在 Node.js 项目中可以通过 npm 的 prepare 钩子来自动初始化。
  3. 因此,使用方式与 husky 文档 中基本一致。
    1. husky installhusky add .husky/pre-commit

以上的心路历程、改造进展可以通过这个 PR 查看,感兴趣的同学可自行尝试:

  1. clone 项目,安装依赖,执行 npm link。
  2. 参考 husky 文档 进行使用。

背后的一些二三事

最后分享一些我们在实践 Hg hooks 时的小插曲。

一个隐藏字符引发的前端事故

有一天下午,在群里收到这么一个反馈:

2471647676117_.pic

点开大图一看,好家伙!赫然一个「口」字就这么明目张胆地贴在页面的左下角,看它「浓眉大眼」的。

到底是哪里出了问题呢?

仔细看清楚,才发现它其实不是一个「口」字,而是「□」,学名叫做 虚缺号,通俗地讲就是一个特殊字符。

于是打开对应的代码文件,果然一个红底白色 BS 字符引入眼帘:

这是 VSCode 的锅?

在网上有一番搜寻后,发现早就已经有不少人遇到过这个问题:

看下他们提供的复现过程:

hmmm

直接说下这个 Bug 的结论:

  1. VSCode 开启 webview 的情况下,使用中文输入法时按下退格键,就会导致出现退格符。
  2. VSCode 底层是 Electron,Electron 底层用的 chromium,这个 BUG 是 chromium 的。
  3. 该 BUG 已经在 VSCode v1.4.0 得到修复,参见这个 issue

但既然该问题在 2019 年已经修复,那为什么在 2022 年的今天还会出现这个退格符呢?

由于已经复现不了,根源追求也就只能不了了之,但影响又如此之大,所以我们应该怎么去规避它呢?

规避方案

利用 VSCode 扩展自动删除

有一个 VSCode 扩展 Remove backspace control character 专门用于解决此类问题,安装后我们只需要在 setting.json 添加如下配置:

1
2
3
4
"[wxml]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "satokaz.vscode-bs-ctrlchar-remover"
}

即可在保存代码的时候自动移除这些特殊的隐藏字符。

实现方式主要是通过正则去匹配这类隐藏字符:

1
/[\u0000]|[\u0001]|[\u0002]|[\u0003]|[\u0004]|[\u0005]|[\u0006]|[\u0007]|[\u0008]|[\u000b]|[\u000c]|[\u000d]|[\u000e]|[\u000f]|[\u0010]|[\u0011]|[\u0012]|[\u0013]|[\u0014]|[\u0015]|[\u0016]|[\u0017]|[\u0018]|[\u0019]|[\u001a]|[\u001b]|[\u001c]|[\u001d]|[\u001e]|[\u001f]|[\u001c]|[\u007f]/gm

在这里查看所有字符的介绍:Unicode,本文所出现的 BS 正是 [\u0008],也就是退格符。

提交代码前自动删除

更好的方式是:我们可以在 precommit 钩子自动做这件事:

1
2
3
4
find . -name "*.wxml" -exec perl -i -p -e "s/[\x08]//g" {} +

# 这行命令的 time total
0.10s user 0.80s system 93% cpu 0.953 total

让 vscode-hg 提交代码时显示 ESLint 报错的规则

起因是某位同学反映在 vscode-hg 提交代码的时候,无法显示 ESLint 校验不通过的规则提示:

image-20220320141520784

通常我们提交代码时,如果 yarn lint 不通过,会输出如下:

1
2
3
4
5
6
7
8
9
10
$ eslint '**/*.js' --cache --fix

/Users/4ark/project/helper/404.js
9:7 error 'a' is assigned a value but never used no-unused-vars

✖ 1 problem (1 error, 0 warnings)

error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
abort: pre-commit hook exited with status 1

而 vscode-hg 只输出如下:

1
2
error Command failed with exit code 1.
abort: pre-commit hook exited with status 1

不过经测试在 VSCode 中进行 Git 代码提交时并不存在该问题,所以猜测是 vscode-hg 这个扩展的原因。

于是抱着怀疑的态度看一下源码,发现果然如此:

1
2
3
4
// src/hg.ts#L620
if (options.logErrors !== false && result.stderr) {
this.log(`${result.stderr}\n`);
}

这里只输出了 stderr,但是 ESLint 的规则输出是 stdout。

于是我们为了更好地使用 Hg hooks,让它支持了输出 ESLint 规则,见 #185

结语

以上就是我们在实践 Hg hooks 过程的一些经历和心得,未必是最佳解决方案,正如文本开头所说,撰写本文的目的是希望能起到抛砖引玉的作用,与大家一起进一步的深入探讨。

对于本文的实践思路、代码实现有任何的意见和建议,都请不吝指教。

最后感谢大家的阅读。

Git tips

批量删除 git 分支

1
git branch -a | grep -e "fix/" | xargs git branch -D

批量添加匹配文件到暂存区

1
git status -s -uall | grep .vue | awk '{print $2}' | xargs git add

按最后提交日期排序所有远程分支

1
git branch -rv --sort=-committerdate

更多:How can I get a list of Git branches, ordered by most recent commit?

更好的 git log

1
git config --global alias.lg "log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"

列出另外一个分支没有的提交

1
git cherry -v branch-A branch-B

clone 特定分支

1
git clone -b develop git@github.com:user/myproject.git

递归移动文件夹

1
mv bar/{,.}* .

列出特定分支的记录

1
git log -p branch-name

查看特定文件的 git 记录

1
git log -p -- filename

修改 git 提交为任何人

1
git -c user.name="NEW NAME" -c user.email="new_email@gmail.com" commit --amend --date="Tue Nov 20 03:00 2018 +0100" --author="NEW NAME <new_email@gmail.com>"

查看当前所有子目录的 git 状态

1
find . -maxdepth 1 -mindepth 1 -type d -exec sh -c '(echo {} && cd {} && git status -s && echo)' \\;

创建 git 归档时忽略某些目录

1
tar cvfz app.tar.gz --exclude ".git/*" --exclude ".git" app/

列出未发布的 commit

1
git log @{u}..

在所有 commit 中寻找代码

1
git rev-list --all | xargs git grep

找到一个文件是何时被删掉的

1
git log --diff-filter=D -- path/to/file

删除远程分支

1
git push origin :branch

让 git 可以递归调用

1
git config --global alias.git '!git'

revert cherry-pick

1
git rebase -p --onto SHA^ SHA

永久删除文件

1
git filter-branch --tree-filter 'rm -rf my_folder/my_file' HEAD

永久链接

1
permalink = "!f() { echo "https://$(git config --get remote.origin.url | grep --color=never -o -E 'github.com[:/][^\\.]+' | sed s/\\:/\\\\//)/commit/$(git rev-parse @{u})"; }; open $(f)"

仅忽略本地副本的文件更改

1
2
3
git update-index --assume-unchanged <file>

git update-index --no-assume-unchanged <file>

忽略已跟踪的文件

1
git rm --cached <file>

获取当前分支名称

1
git branch --no-color 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/\1/'

查看某个版本的目录树

1
2
3
git show HEAD~5:hello.txt

git show awesome-feature:app/models
❌