深入理解与解决 Jujutsu(jj)的 Divergent Changes:从成因到五种处理策略
【免费下载链接】jjA Git-compatible VCS that is both simple and powerful项目地址: https://gitcode.com/GitHub_Trending/jj/jj
导读:本文以 docs/guides/divergence.md 为核心主体,系统讲解 Jujutsu(jj)中"分歧变更"(divergent changes)的产生机制——当多个可见提交共享同一个 change ID 时会发生什么,并结合
jj converge、jj abandon、jj metaedit --update-change-id、jj squash五种实战策略给出完整解决方案。读完本文,你将能识别日志中的 divergence 标记、用 change offset 精确定位提交,并根据"保留哪个版本的内容"这一核心诉求选择最合适的收敛手段,同时理解其底层可见性模型与收敛算法。
什么是 Divergent Changes(分歧变更)?
Jujutsu 的数据模型与其他版本控制系统最大的不同之一,是引入了change ID(变更 ID)与commit ID(提交 ID)的分离。一个 change 代表"一次变更随时间的演化",而 change ID 是 change 的属性,在提交被重写(rewrite)时保持不变;commit ID 则每次重写都会变化。关于这对概念的精确定义,可参考 术语表 与 Rewrite 条目。
一个divergent change(分歧变更)发生在:多个 可见提交 拥有同一个 change ID时。此时这些提交是同一 change 的多个并存版本,在jj log输出中会以 change ID 后面的 change offset 和 "divergent" 标签来标识:
$ jj log @ mzvwutvl/0 test.user@example.com 2001-02-03 08:05:12 29d07a2d (divergent) │ a divergent change上面的输出包含三个关键信息:
mzvwutvl是 change ID(默认以 12 位k-z字母序列显示,详见 术语表 Change ID 条目);/0是change offset,用于在同 change ID 的多个提交之间消歧(最靠近当前的一个为/0,更早的依次为/1、/2…);(divergent)标签明确告诉你:这个 change 当前有不止一个可见提交。
从源码角度看,divergent()是一个内置 revset 函数,定义于 lib/src/revset.rs,其求值逻辑在 lib/src/default_index/revset_engine.rs 中检查某 change 的目标集合是否is_divergent()。也就是说,divergence 不是某种"坏状态"的报错,而是数据模型中的一种普通状态——类似于文件冲突,jujutsu 允许你决定何时、以何种方式去处理它。
Divergence 是如何产生的?
要理解 divergence 的成因,首先必须理解 jujutsu 的可见性模型:正常重写提交时,原版本("predecessor",前驱)会被隐藏,新提交("successor",后继)变为可见,因此同一 change ID 在同一时刻通常只有一个可见提交。可见性的具体定义是:可见提交是那些可以从 view(视图)中的匿名 head(anonymous head)到达的提交,可见提交的祖先隐式可见,见 术语表 Visible commits 条目。实现上,匿名 head 集合记录在 lib/src/view.rs 的View::heads()中。
情况一:隐藏提交重新变得可见
一个已隐藏的提交可能重新变为可见,文档明确列出了四种触发方式:
- 本地新增可见后代:例如
jj new REV会使REV即使此前已隐藏也重新可见; - 从远端抓取到可见后代:如果某个隐藏提交曾被推送到远端,其他人可能基于它创建新提交;当这些新提交被 fetch 回本地时,它们的可见性会使那个隐藏提交重新变为可见;
- 将其设为工作副本:
jj edit REV会使REV及其所有祖先(如果尚未可见)变为可见; - 其他使隐藏提交可见的操作:例如给隐藏提交添加 bookmark(书签),其隐含语义是"你要重新开始使用这个提交了"。
这些场景的共同点,都可以归结为 view 中的 head 集合被扩充——参见 lib/src/view.rs 的add_head/replace_heads等操作。
情况二:两个用户或进程同时改写同一个 change
即使提交从未隐藏,divergence 也可能因为同一 change 被"双写"而出现两个可见后继:
- 他人修改了你也修改过的分支:你在本地改写了某分支上的提交,远端其他人也改写了它;
- 在同一仓库的不同工作区中对同一 change 执行操作:每个工作区都有独立的工作副本提交,跨工作区操作很容易制造双写;
- 两个程序同时修改仓库:例如你运行
jj describe正在编辑提交描述,期间一个 IDE 集成执行了 fetch 并 rebase 了你正在处理的分支——两个操作基于同一旧提交各自生成了后继。
设计文档 docs/design/jj-converge-command.md 还补充了更多真实场景:在终端 A 打开jj describe编辑器后,到终端 B 改文件并触发快照,然后回到终端 A 保存描述,最终会得到两个可见提交;任何交互式命令(jj split -i、jj squash -i等)都可能以类似方式引入 divergence;在使用 Git 后端时,change ID 存储在提交头中,jj git fetch之后也可能出现第二个同 change ID 的提交。
值得注意的是,divergent 提交之间可以有不同的提交描述、不同的文件树、不同的父提交、不同的作者,甚至可能出现除时间戳外完全相同的两个提交——这正是收敛算法需要逐一处理多个属性的原因。
解决 Divergence 前的准备:如何引用分歧提交
在动手解决之前,有一个关键规则必须牢记:revset 中引用分歧提交时,要么使用它的 commit ID,要么使用带 change offset 的 change ID(如/0、/1,与 log 中显示一致),因为裸 change ID 本身是有歧义的。
change offset 的详细语义见 术语表 Change offset 条目:当 change ID 无法唯一确定一个提交(提交被隐藏或 change 分歧)时,在 change ID 后追加偏移量即可精确定位,/0表示最近的提交,/1表示更早的一个,以此类推。
处理策略的选择取决于你的核心诉求:保留哪个提交的内容、两个都要、还是合并它们。下面逐一展开。
策略一:使用jj converge自动收敛(实验性)
jj converge是 jujutsu 提供的实验性命令,目标是自动解决(或减少)divergence:它把某个 change 的两个或多个可见提交替换为单一提交,并尽可能避免引入新的 divergence。
基本用法与默认搜索范围
直接运行:
jj converge命令首先在一个"搜索空间"内按 change ID 分组查找分歧提交。默认搜索空间来自配置项revsets.converge,其默认值定义在 cli/src/config/revsets.toml:
converge = "mutable() & divergent()"即默认只处理mutable(可变历史)中且处于分歧状态的提交——这保证了不可变历史(如已推送或被保护的分支)不会被自动改写。你也可以用--revisions(别名--revisions,短选项-r)显式指定搜索范围:
jj converge --revisions 'mutable() & divergent() & @-::' jj converge -r 'author("alice")' -r '::main'对应参数定义在 cli/src/commands/converge.rs。
无交互模式
--no-interactive指示命令不向用户提问:如果无法自动求解,它将打印警告并退出而不做任何修改(divergence 仍然保留):
jj converge --no-interactive当搜索范围内有多个divergent change 且处于非交互模式时,命令无法自行决定收敛哪一个,会直接报错并提示:要么用交互模式运行,要么指定一个只解析出单个 change ID 的 revset——见 cli/src/commands/converge.rs。
命令的工作流程
从源码 cli/src/commands/converge.rs 可以还原出完整流程:
- 解析搜索空间 revset,并检查其中提交是否可重写(
check_rewritable_expr,第139-141行); - 调用库函数
find_divergent_changes()找出所有分歧 change(lib/src/converge.rs:求值 revset 后按 change ID 分组,只保留成员数大于 1 的分组); - 若无分歧,直接打印 "No divergent changes found." 并成功返回(对应测试 cli/tests/test_converge_command.rs);
- 若存在分歧,打印每个分歧 change 及其提交列表;只有一个时直接选定,有多个时交互式询问用户要收敛哪一个;
- 对选中的 change 运行收敛算法(见下文"底层原理"),必要时就作者(author)、提交描述(description)、父提交(parents)向用户提问;
- 创建解决方案提交,把 divergent 提交的后代 rebase 到解决方案上,并更新指向这些提交的本地 bookmark;
- 打印结果摘要:创建的解决方案提交 ID、被 rebase 的后代数量;若范围内还有其他分歧 change,会提示可再次运行命令。
jj converge一次只收敛一个 change。测试 cli/tests/test_converge_command.rs 展示了最简单的"单分歧 change、无需用户输入"的场景:日志中zsuskuln/0与zsuskuln/1两个 divergent 提交被收敛为单一提交zsuskuln,同时Rebased 1 descendants。
收敛算法如何工作(底层原理)
库层实现位于 lib/src/converge.rs。核心思路是以演化历史而非提交图为依据来求解:
- TruncatedEvolutionGraph(截断演化图):只包含该 change ID 的提交,并沿 predecessor 链回溯到所有分歧提交的最近共同支配点(closest common dominator),见 lib/src/converge.rs。它本质上是对
jj evolog演化图的截断版本。 - 作者、描述、父提交的求解:对每个属性,在演化图上用 "dominator value"(支配值,即演化分叉点处该属性的取值)作为合并基底,构造
base + (A - base) + (B - base)形式的三方合并;能平凡求解则自动解决,否则标记为Unsolved并转入交互式提问,见 lib/src/converge.rs 与create_value_merge(第591-628行)。 - 树的收敛:把每个分歧提交的树 rebase 到选定父提交之上(
rebase_tree_onto_solution_parents,第698-721行),再以支配值为基底做多路合并。因此解决方案中可能存在文件冲突,即使原本没有冲突——这是该命令文档明确声明的行为。 - 落地:
apply_solution()(第192-215行)创建新提交(保持原 change ID),将其标记为所有分歧提交的后继(set_predecessors),并set_rewritten_commit后rebase_descendants()完成后代迁移。
重要注意事项与安全网
该命令使用启发式算法寻找"好"的解决方案,但没有任何算法能永远做到用户想要的事,因为"用户想要什么"本身没有客观定义。因此请务必记住三个检查手段:
# 查看命令究竟做了什么(查看操作差异) jj op show -p # 用 evolog 观察 change 的演化历史 jj evolog # 对结果不满意时回退 jj undo这三条同样在 cli/src/commands/converge.rs 的注释中被强调,且jj op show -p、jj undo也是原文档推荐的检查与回退路径。
策略二:抛弃其中一个提交(jj abandon)
如果某个分歧提交已经明显过时或错误,直接抛弃它即可:
# 用 commit ID(或带 offset 的 change ID)抛弃不需要的提交 jj abandon <unwanted-commit-id> # 可以一次抛弃多个: # jj abandon abc def 123 # jj abandon abc::这是"你已经知道该保留哪个版本"时最简单的解决方案。注意jj abandon并不会删除提交对象,只是将其从可见 head 集合中移除、使其变为隐藏——这与可见性模型完全一致。
策略三:为其中一个提交生成新的 change ID(jj metaedit --update-change-id)
如果希望把两个版本都保留下来,作为拥有不同 change ID 的独立 change,可以给其中一个提交生成全新 change ID:
jj metaedit --update-change-id <commit-id>metaedit命令用于修改提交元数据而不改变其内容;--update-change-id标志的说明见 cli/src/commands/metaedit.rs,其实现调用commit_builder.generate_new_change_id()(第230-231行)。生成新 change ID 后,两个提交各自拥有唯一 change ID,divergence 即告解除,同时两份内容都被完整保留。
策略四:将提交合并在一起(jj squash)
当你想把两个分歧提交的内容合并到单个提交时,使用 squash:
# 把一个提交压进另一个 jj squash --from <source-commit-id> --into <target-commit-id>squash 会将源提交的变更合并进目标提交,形成单一提交;源提交随即被抛弃(变为隐藏)。这与"保留一个、抛弃另一个"(策略二)的区别在于:策略二只保留单一版本的内容,而 squash 是把两份内容都并入最终提交。
策略五:忽略 divergence
Divergence 不是错误。如果它没有立即引起问题,完全可以保持原样;尤其当两个提交都处于不可变历史(immutable history)中时,忽略可能是唯一可行的选项。
不过忽略也伴随着实际代价:你无法仅凭裸 change ID 无歧义地引用分歧提交——每次都必须附带 commit ID 或 change offset,这会在日常操作中持续带来不便。
如何选择策略:决策速查
| 你的目标 | 推荐命令 | 效果 |
|---|---|---|
| 自动解决,启发式+必要时询问 | jj converge | 单提交替换全部分歧提交,后代自动 rebase |
| 只保留其中一个版本 | jj abandon <id> | 抛弃其余提交,内容不合并 |
| 两个版本都保留为独立 change | jj metaedit --update-change-id <id> | 解除歧义,两份内容并存 |
| 合并两个版本的内容 | jj squash --from <src> --into <dst> | 内容合并,源提交被抛弃 |
| 不想处理 / 不可变历史 | 不操作 | divergence 保留,引用需带 offset |
需要留意的是,jj converge是实验性命令,其行为细节(例如启发式规则)可能随版本演进;使用前可查看 converge 命令的设计文档 了解完整设计动机与更细致的场景分析。
深入理解:可见性模型与演化历史是理解 divergence 的关键
最后,从仓库实现层面总结两个理解 divergence 的核心机制:
1. 可见性由 view 中的匿名 head 集合决定。lib/src/view.rs 中的heads()返回当前 view 的 head 集合;lib/src/view.rs 的normalize_heads()保证 head 集合保持"规范化"(移除有后代的 head)。可见提交是这些 head 的后代闭包。当新的 head 被加入(如jj new、jj edit、bookmark 添加、fetch),原本隐藏的提交就可能重新进入可见集合,从而与另一个可见后继"撞车"形成 divergence。
2. 演化历史(predecessor/successor)被记录在操作日志中。lib/src/evolution.rs 定义了CommitEvolutionEntry,通过遍历操作(operation)历史读取predecessors_for_commit来还原提交演化链;jj evolog展示的正是这条链。converge 的启发式算法也正是基于这条演化链上的"支配值"来寻找合并基底,因此它对"两个分歧提交从哪个共同祖先分叉而来"非常敏感。
理解这两点后,你会明白:divergence 本质上不是数据损坏,而是 jujutsu"变更(change)与提交(commit)解耦"模型下多人/多进程协作的正常副产品。选择jj converge自动收敛、jj abandon丢弃、jj metaedit分流、jj squash合并,或干脆忽略它——这五种策略覆盖了所有常见诉求,且都有jj op show -p、jj evolog、jj undo三重安全网兜底。
【免费下载链接】jjA Git-compatible VCS that is both simple and powerful项目地址: https://gitcode.com/GitHub_Trending/jj/jj
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考