在维护一个多包项目的时候,我经常会在发布版本时碰到这样的事:手头明明有一堆新的改动要发布,高高兴兴地敲下 npx lerna version,结果屏幕上一堆红色报错,其中最让人摸不着头脑的一种就是“Git 合并冲突”导致的自动版本号递增失败。明明改动都写完,也没人告诉我版本号会跟冲突扯上关系,怎么一到要发布就卡住了?今天咱们就把这个问题从头到尾拆开,看看它到底怎么回事,以及下一次再遇到时,怎么一步步把它收拾干净。

一、先说说这个坑是什么

Lerna 是前端工程化里非常常见的一个工具,它专门用来管理那些“一个仓库里塞了好几个项目”的代码,也就是 monorepo 场景。lerna version 这个命令,干的活是帮我们把所有子包的版本号自动往上加,然后打上 Git 标签,顺便提交一份版本变更记录。听起来很省事,对吧?但它的前提是:当前 Git 仓库必须处于一个干净、容易处理的状态。

可现实往往是,你的开发分支上改了 package.json 里的 version 字段,另一个同事的代码也改了同一个字段,然后你把两条分支合并到一起。Git 一对比,发现两边都有改动,自己搞不定,就在文件里留下了一堆 <<<<<<<>>>>>>> 的标记。这时候你要是直接运行 lerna version,Lerna 会去读 package.json 来获取当前版本号,可读到的却是满屏的冲突符号,它完全没法解析。于是它会中止操作,告诉你版本号递增失败。简单说,不是 Lerna 太脆弱,是它根本没法在“还没整理好的文件”上干活。

二、为什么会失败?冲突从哪来

要理解这个问题,得先知道 Lerna 读版本号的方式。你打开任意一个子包里的 package.json,都会看到类似这样的片段:


{
  "name": "@my-project/app",
  "version": "0.1.0",
  "scripts": {
    "test": "jest"
  }
}

这个 version 字段就是 Lerna 要读的关键信息。在固定模式(fixed mode)下,lerna.json 里也会有一个统一的版本号,它负责跟所有子包的版本号保持一致。

假设你拉了一个新分支,打算把版本号从 0.1.0 升级到 0.2.0,于是你手动改了 package.json。另外一位同事在主干分支上也做了热修复,把 version 改成了 0.1.1。当你们合并代码的时候,Git 看到两个分支都动了同一个地方,就开始纠结:该听谁的?它没法替你拍板,只能把决定权交还给你,于是文件就进入了“冲突状态”。

冲突状态下的 package.json 会变成这样:


# 查看冲突文件的内容,你会发现这样的标记
cat packages/app/package.json


{
  "name": "@my-project/app",
<<<<<<< HEAD
  "version": "0.1.1",
=======
  "version": "0.2.0",
>>>>>>> feature/version
  "scripts": {
    "test": "jest"
  }
}

看到没,这种内容已经不是一个合法的 JSON 了。Lerna 在运行的时候,第一步就要用 JSON.parse 去解析这个文件,读到 <<<<<<< 直接就会崩掉。就算它幸运地绕过了解析,它还会检查 Git 工作区状态,发现你当前还处在合并冲突中,有未解决的文件路径,它一样会拒绝继续执行。所以,这不仅仅是“版本号算错了”,而是整个工作流被堵死了。

三、完整应对流程

别慌,这个问题是完全可以解决的,而且流程其实非常固定。下面咱们一步一步来。

3.1 先冷静下来,看看冲突长什么样

lerna version 报错的时候,先别急着去翻日志。第一件事是执行 git status,看看仓库里到底有哪些文件是“没合并完”的。


# 查看当前仓库状态
git status

你会看到类似这样的输出:


# 冲突文件会显示为 unmerged
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  both modified:   packages/app/package.json
  both modified:   packages/lib/package.json

这里明确告诉我们,这两个文件在两条分支里都被改过。接下来要做的就是把它们打开,找到冲突标记,然后做出选择。

3.2 手动解决冲突的原则

打开冲突文件,看到满满的 <<<<<<<=======>>>>>>> 时,不要抓狂。你只需要理解每一段的意思。从上往下看,<<<<<<< HEAD 后面跟着的是当前分支(通常是主干)上的内容,======= 后面是另一个分支(合并进来的那个)的内容。

这时候你可能会想:“那我是不是无脑保留版本号更大的那个?”不一定。因为版本号不是越大越好,它要按照语义化版本的规则来。比如当前分支是 0.1.1,另一个分支是 0.2.0,如果这次改动确实引入了新功能,那保留 0.2.0 是合理的;如果只是一些小修复,可能 0.1.2 更合适。所以解决冲突时,先问自己:我这次要发布的改动,到了什么级别?然后选择一个合理的版本号,把其他冲突标记删掉。

以刚才那个文件为例,我们决定临时先用 0.2.0,于是手动把文件改成:


{
  "name": "@my-project/app",
  "version": "0.2.0",
  "scripts": {
    "test": "jest"
  }
}

注意,这里不要一上来就把所有冲突标记都删掉,只保留你最终认准的那个 version 值。同时要仔细看看 package.json 里还有没有其他冲突片段,比如依赖的版本,脚本命令等等,往往不止一处。

3.3 用工具辅助解决

如果你觉得手一个个改太慢,也可以用一些现成的命令来辅助,不过要注意它们的行为。比如 git checkout --ours 表示直接采用当前分支的版本,git checkout --theirs 表示采用合并进来的分支的版本。


# 直接使用当前分支(HEAD)的版本解决冲突
git checkout --ours packages/app/package.json


# 或直接使用另一个分支的版本来解决冲突
git checkout --theirs packages/app/package.json

这种办法比较生猛,适合你非常确定“不需要看细节,直接用某一方的版本”的时候。但如果文件里既有版本号冲突,又有其他改动,这样做可能会把别人的改动也丢掉,所以还是要慎用。更推荐的做法是结合 git mergetool,它会启动一个图形化的合并工具,让你看到两边改动,然后手动挑。不过对小冲突来说,直接用编辑器打开改反而更快。

3.4 重新运行 lerna version

等你手动解决了所有冲突文件,并在编辑器里保存后,第一步是把这些文件标记为“已解决”。


# 把已经处理好的文件加入暂存区
git add packages/app/package.json packages/lib/package.json lerna.json

这里要注意,如果你改了 lerna.json 里的 version,也要一起 git add。完成之后,还需要执行一次提交,把这次合并正式完成。很多人以为解决完冲突就能立刻跑 lerna version,其实不行,因为 Git 还处于“合并进行中”的状态,Lerna 会认为工作区不干净。


# 提交完成合并
git commit -m "merge: resolve version conflicts"

提交之后,再运行 npx lerna version,这一次它一般就能正常走了。如果你希望它别瞎猜版本号,可以直接在命令后面指定一个具体版本:


# 去掉 --yes 的话会交互式确认,这里用 --yes 直接按默认来
npx lerna version 0.2.0 --yes

你会看到 Lerna 开始更新所有子包的 package.json,更新 lerna.json,然后自动打上 Git 标签,流程就顺畅起来了。

3.5 抢救失误的版本号

万一你胆子比较大,没解决冲突就硬跑 lerna version,或者解决冲突时选了一个错误的版本号,导致已经把不对的版本推到分支上了,那也不是世界末日。首先,你可以用 git log 看看最近的提交记录,找到那个失误的提交,然后用 git revert 撤销它。


# 查看最近提交
git log --oneline


# 撤销最近的一次提交,但保留代码改动
git revert HEAD --no-commit
git commit -m "revert: undo wrong version bump"

如果只是版本号错了,还可以直接改回正确版本,再提交一次。但要注意,如果你已经给某个版本打了 Git 标签,并且这个标签已经推送到远程仓库,那就要谨慎处理了,因为标签通常指代不变的版本。这种情况下,更好的做法是赶快发布一个修订版本,把版本号往上升一档,而不是强行去改动历史。

四、一个完整的实际例子

为了让你看得更明白,咱们把上面这些步骤串起来,完整地演一遍。假设我们有一个 monorepo,里面有两个子包:@my-project/app@my-project/lib,它们都使用固定模式,统一版本号。当前版本是 0.1.0

技术栈:JavaScript(Node.js)环境下的 Lerna + Git 工作流。

首先,lerna.json 内容如下:


// 注意:这只是用 JS 对象来表示 lerna.json 的结构,方便写注释
const lernaConfig = {
  version: "0.1.0",            // 当前统一版本号
  packages: ["packages/*"],    // 子包都放在 packages 目录下
  command: {
    version: {
      conventionalCommits: true, // 使用 conventional commits 规范来自动生成版本
      message: "chore(release): publish %s"
    }
  }
};

接着,在 main 分支上,因为修复了一个小 bug,有人把 @my-project/lib 的版本改成了 0.1.1。同时,你在 feature/version 分支上准备发布 0.2.0,把所有子包的版本都改成了 0.2.0。现在合并 feature/versionmain,Git 发现 packages/lib/package.json 冲突了。

执行合并命令:


# 切换到 main 分支,并把 feature/version 分支合并进来
git checkout main
git merge feature/version

冲突提示出现后,我们打开 packages/lib/package.json,看到:


{
  "name": "@my-project/lib",
<<<<<<< HEAD
  "version": "0.1.1",
=======
  "version": "0.2.0",
>>>>>>> feature/version
  "main": "index.js"
}

我们决定采纳 0.2.0,因为这次发布要一起带上新功能,于是把文件手动改成:


{
  "name": "@my-project/lib",
  "version": "0.2.0",
  "main": "index.js"
}

然后检查其他文件,确认 packages/app/package.json 没有冲突(也许只有 feature 分支改了它)。再用 git status 看一下:


# 确认还有哪些未合并文件
git status

输出里已经没有 unmerged 项目了,说明所有冲突都解决了。接下来把文件标记为已解决,并提交合并:


# 把所有已经处理好的文件加入暂存区,并完成合并提交
git add packages/lib/package.json
git commit -m "merge: resolve version conflict"

最后运行 lerna version,这次直接指定版本号:


# 指定发布版本为 0.2.0
npx lerna version 0.2.0 --yes

你会看到类似这样的成功输出:


lerna info version 6.0.0
lerna info Executing command
lerna info Publish version 0.2.0
Successfully published @my-project/lib@0.2.0
Successfully published @my-project/app@0.2.0
lerna success All packages have been published

当然,实际开发里你可能用的是 --conventional-commits 让它自动从提交记录里推导版本号。如果提交信息不标准,它有可能会算出一个你意想不到的版本,所以更稳妥的方式是像上面这样手动给定版本。

五、注意事项

整个流程看起来不复杂,但有几个地方特别容易把人绊倒。

第一,不要忽略除了 package.json 之外的冲突文件。比如 package-lock.jsonyarn.lock,它们里面也可能记录了依赖的版本,跟子包的版本号有对应关系。如果只解决了 package.json,锁文件还处于冲突状态,lerna version 同样会拒绝执行。解决锁文件冲突可能比较头疼,通常的做法是运行一下包管理器自带的命令,比如 npm installyarn install,让它根据你手动改好的 package.json 重新生成一份干净的锁文件。

第二,注意 lerna.json 里的 version。在固定模式下,这个值会被同步更新。如果你在合并时没有冲突,它可能不会出现 <<<<<<< 标记,但 Lerna 在运行时会拿它跟各子包的版本做比较。如果子包版本已经被你手动改得不一致了,Lerna 会要求你先统一。所以解决冲突后,建议顺手打开 lerna.json 看一眼,确保它跟你最终选择的版本一致。

第三,独立模式(Independent Mode)下的冲突更麻烦。如果 lerna.json 配了 "version": "independent",每个包就拥有自己独立的版本号,冲突的范围会扩大。你可能同时面临好几个包的不同版本冲突。不过应对思路还是一样:先把每个包都改成你想要的版本,再提交,最后跑 lerna version。只是要小心,别把 A 包的版本号套到 B 包上。

第四,合并冲突后的提交信息不要太随意。因为 lerna version 会自动创建新的 commit,如果你前面的合并 commit 信息是乱七八糟的,后续追溯版本变更历史的时候很难看。建议用 merge: 或者 chore: 这样的前缀,至少让人看出来这是“解决冲突”的提交。

第五,尽量让 Lerna 自己管理版本号,而不是手动改。多人协作时,大家最常犯的错误就是喜欢在 PR 里手动把 version 字段改大,导致合并时冲突。正确的做法是:平时不要动 version,只在发布前通过 lerna version 统一修改。这样可以大大减少冲突的发生,也能让版本号管理更规范。

六、总结

遇到“Lerna version 和 Git 合并冲突”这个组合的时候,真的不用慌。本质上就是“脏工作区”挡住了自动发布工具的路,只要我们把冲突解决干净,再执行一次合并提交,Lerna 就能继续干活。整个流程的核心就三步:查冲突,解决冲突,提交合并,然后再跑 lerna version。每一步都不难,但顺序不能乱,特别是千万别在冲突还没解决完的时候强行发布。

下次再看到 lerna version 报错,先别急着删 node_modules,也别去重新拉代码。先看一眼 git status,把那些带有 <<<<<<< 的文件处理干净,然后老老实实完成一次合并提交,你会发现后面的路就又顺了。版本号这东西,说到底还是代码仓库里的一份普通数据,它也在参与协作,也会产生分歧,但最终只要我们有条理地处理,它永远不会成为发布路上的拦路虎。