jj 复制/重命名追踪设计:CopyHistory DAG、快照模型与 diff/merge 实现解析
【免费下载链接】jjA Git-compatible VCS that is both simple and powerful项目地址: https://gitcode.com/GitHub_Trending/jj/jj
本文基于 jj(Jujutsu)仓库中的设计文档 copy-tracking.md 展开,系统讲解 jj 如何在快照模型中记录文件的“复制/重命名”信息:从CopyId/CopyHistory的 DAG 数据结构,到 diff 算法、merge 阶段的命名冲突处理,再到 Git 后端与云端后端的表示方案。读完后,你将理解 jj 与 Git/Mercurial 在复制检测上的本质差异,并能对照当前仓库中 lib/src/copies.rs、lib/src/backend.rs 的实际实现,验证设计文档中各例子的落地情况。
一、设计目标:为什么快照模型需要复制信息
jj 的设计文档首先明确了复制信息至少要支撑的四个使用场景:
- Diff:如果文件被复制过,diff 应该与“源版本”比较,而不是显示为整个文件的“新增”;
- Merge:当合并(或 rebase)的一侧重命名了文件、另一侧修改了该文件时,修改要传播到重命名后的路径(还有其他大量边界情况需要处理);
- Log:应该能执行类似
jj log -p <file>的操作,在文件是被复制出来的时候沿复制链向前追溯; - Annotate(blame):与 log 类似,遇到复制创建的文件时也要向前追溯历史。
同时文档对性能提出了两条约束:
- 方案必须同时适配Git(在任意两棵树之间即时合成复制信息)与自定义后端(可能显式记录复制信息,并在任意大的提交范围内回取);
- API 的定义要让自定义后端在“还没准备好实现复制追踪”时可以完全忽略复制信息。
一个关键的设计取舍是:文档认为没有必要区分 copy 和 rename——重命名就是“复制 + 删除”。代价是无法区分“把foo复制为bar并把foo重命名为baz”与“把foo复制为baz并把foo重命名为bar”。这一取舍在后文中与当前仓库代码的对应关系会再次出现:lib/src/copies.rs 中正是用一个CopyOperation { Copy, Rename }枚举来表达“源路径是否被删除”。
二、期望的用户体验(Desired UX):一组可验证的行为不变式
设计文档用大量具体场景定义了“理想行为”,这些场景本质上是行为不变式,也是后续实现与测试的验收标准。
2.1 restore 必须保留复制关系,且传递性复制要“压平”
例如jj new X--; jj restore --from X应该把X-和X中做的复制恢复到新的工作副本。传递性的复制要压平:如果X-把foo重命名为bar,X又把bar重命名为baz,那么恢复出来的提交应该表现为foo直接重命名为baz。这一行为同样适用于一般的 reparenting(如社区讨论过的 “verbatim rebase” 场景)。
2.2 restore 之后再 diff 应当为空
jj restore --from X; jj diff --from X至少就文件内容而言应当是空的,即使它可能提示重命名过的文件有不同历史。
2.3 rebase 应支持无损往返
除了A+(A-B)=A这条冲突代数中的“同变规则”(对应 lib/src/merge.rs 中的实现),rebase 目前从不损失信息:把一个提交 rebase 走再 rebase 回来应当得到相同内容。文档给出的例子:
$ jj log C rename bar->baz | B rename foo->bar | A add foo $ jj rebase -r C -o A $ jj rebase -r C -o B # 回到上述状态2.4 revert 父提交应当是 no-op
补丁必须可逆:做完一个修改再 revert,两个提交之间的 diff 都应为空:
$ jj log B rename foo->bar | A add foo $ jj revert -r B -o B $ jj diff --from B- --to B+ # 应为空2.5 parallelize / serialize 是“无损 rebase”的特例
$ jj log E edit qux | D rename baz->qux | C rename bar->baz | B rename foo->bar | A add foo $ jj parallelize B::D # E 不应出现冲突,看起来仍像一次普通编辑 $ jj rebase -r C -A B $ jj rebase -r D -A C # 现在回到与之前相同的图2.6 合并提交中的复制:命名冲突要可解决、可回退
两侧把不同源文件重命名到同一目标时应能解决命名冲突,且解决方案可以被撤销、回到命名冲突状态:
$ jj log D resolve naming conflict by choosing `foo` as the source |\ C | rename bar->baz || | B rename foo->baz ||/ A add foo and bar $ jj file annotate baz # 不应包含来自 C 的修改还要能重命名“只在一侧存在”的文件:
$ jj log D rename foo2->foo3 and bar2->bar3 |\ C | rename bar->bar2 || | B rename foo->foo2 ||/ A add foo and bar2.7 跨越合并提交的复制:diff 结果必须与图拓扑一致
$ jj log D delete baz |\ C | rename foo->baz || | B rename foo->bar ||/ A add foo此时jj diff --from C --to D应当显示baz->bar的重命名(就像jj diff --from C --to B那样);而jj diff --from B --to D不应显示任何重命名——尽管 C 里确实发生了一次重命名。
三、高层设计:把“过去的路径名”写进树对象
文档指出:jj 使用与 Git 类似的快照模型,其一级冲突的代数也建立在快照之上(补丁即两个状态之间的差)。因此复制信息也必须塞进快照模型——这一点文档作者自嘲花了数月才真正想通。
3.1 数据结构:CopyHistory DAG 与 CopyId
提案是更新树对象,使其包含文件“过去路径”的信息:如果文件foo在一个提交里被重命名为bar,又在另一个提交里被重命名为baz,那么就记录baz曾经叫过bar和foo。为了支持“两个文件合并为一个”,过去名字列表实际上是一个DAG——合并可以在合并提交中发生(两侧把不同源文件复制到同一目标),而模型层面支持它之后,普通提交中“多合一”也顺带被支持了。
为了避免在树条目中存储全部历史路径,设计把复制历史写成独立对象,树只通过 ID 引用,每个 ID 指向复制历史 DAG 中的一个节点——正如提交 ID 指向提交 DAG 的节点。文档给出的原始数据结构是:
// 当前 TreeValue::File 变体: File { id: FileId, executable: bool }, // 新的 TreeValue::File 变体: File { id: FileId, executable: bool, copy_id: CopyId }, // CopyId 是以下结构的哈希: struct CopyHistory { path: RepoPath, parents: Vec<CopyId> }这里还有一个值得注意的细节:ID 的输入只含文件名时,树 ID 是确定性的;而如果给复制图节点加入一个“盐”(salt),则可以表达“文件被从零重写”——例如foo在前一个提交的 copy ID 是123,当前提交中文件被整体重写后即使没有任何复制发生也拿到新的 copy ID456。文档作者当时对盐的实用性持保留态度,但这个设计已经落地:当前仓库 lib/src/backend.rs 中CopyHistory结构体确实包含salt: Vec<u8>字段,注释说明它“可以让一个提交声明某文件被其新化身取代”;结构体还有current_path与parents(新建文件无父,普通复制/重命名有一个父,多文件合并有多个父),与文档的 DAG 描述一一对应。
文档还留下两个未决问题:符号链接是否参与复制追踪(其历史对 blame 意义不大,但有助于检测整目录重命名);以及是否追踪目录级别的复制(文档认为可能很复杂,但坦承没有深入思考)。
3.2 与当前实现的对应:trait 的三个复制接口
设计文档要求“自定义后端在准备好之前可以忽略复制信息”。在 lib/src/backend.rs 中,这一要求落实为ReadCopy/WriteCopy相关的三个 trait 方法:
read_copy(&self, id: &CopyId) -> BackendResult<CopyHistory>write_copy(&self, copy: &CopyHistory) -> BackendResult<CopyId>get_related_copies(&self, copy_id: &CopyId) -> BackendResult<Vec<RelatedCopy>>
文档注释明确写道:“不支持复制追踪的后端可以返回BackendError::Unsupported”,并且get_related_copies的语义是“返回给定复制历史的所有祖先,以及这些祖先的全部后代;子节点必须先于父节点返回,顺序应确定”。这正是第 5 章云端方案中“按 copy ID 取整图”查询的 trait 化实现。
与此同时,当前仓库中的 Git 后端与 simple 后端都尚未支持:lib/src/git_backend.rs 中这三个方法均返回"The Git backend doesn't support tracked copies yet",lib/src/simple_backend.rs 同样返回Unsupported。也就是说,文档“实现计划”中 Git 后端复制追踪一项(第 8 步)在当下仍走“即时启发式检测”路线——jj diff/jj status通过 cli/src/diff_util.rs 中的get_copy_records从后端按 DAG 区间取得启发式CopyRecord(每个CopyRecord记录 target/target_commit/source/source_file/source_commit,见 lib/src/backend.rs),仓库还提供了 cli/src/commands/debug/copy_detection.rs 调试命令用于观察检测结果。
四、Diff 算法:先比树,再沿复制图追溯
文档描述的 diff 流程是:
- 先不考虑复制信息对两棵树做常规 diff;
- 对 diff 中所有发生变化的 copy ID,遍历其复制图,弄清它们之间的关系,确定哪个源文件对应哪个目标文件;
- 细节留给实现,文档用三个例子证明可行性。
4.1 例一:分叉的复制与重命名
场景(假设新文件在两提交中内容不同):
M rename foo->baz, create bar | | L copy foo->bar, create baz |/ K add foo各提交树中记录如下(id是内容哈希即FileId;2:bar->1:foo表示 copy ID 2 的文件bar复制自 copy ID 1 的foo):
Commit K: name: foo, id: K, copy_id: 1:foo Commit L: name: bar, id: L, copy_id: 2:bar->1:foo name: baz, id: L, copy_id: 3:baz name: foo, id: K, copy_id: 1:foo Commit M: name: bar, id: M, copy_id: 4:bar name: baz, id: M, copy_id: 5:baz->1:foo对应地,copy ID 与提交的包含关系是一幅图:节点2:bar与5:baz都指向1:foo,而4:bar孤立。
- diff K→M:树 diff 发现 copy ID 1、4、5 受影响。遍历复制图发现 1 与 5 相关、4 不相关。对于涉及 1 和 5 的复制图,由于
foo在目标端不存在而baz在源端不存在,判定为重命名。 - diff L→M(等价于
jj new L; jj bookmark create M2; jj restore --from M --to M2后对M2做 diff):发现 copy ID 1、2、3、4、5 全部变化。遍历复制图后,1、2、5 相关,3、4 不相关。bar与baz在两侧的复制图互不相交,因此把它们的 diff 拆成两个独立文件级 diff。剩下的 copy ID 中,复制图上最短路径连接的是源端foo与目标端baz,且foo不在目标端、baz不在源端(以相关 copy ID 计),判定为重命名。源端剩下的bar其最近亲是baz,但baz已作为foo的重命名目标被占用,于是把bar视为复制进baz。
最终 diff 结果:
baz被删除(删除内容L)bar被创建(内容M)foo重命名为baz(显示 K 到 M 的 diff)bar合并进baz(显示 L 到 M 的 diff)
这段推理与当前实现惊人一致:lib/src/copies.rs 中CopyHistoryDiffStream的poll_next正是“copy ID 相同则走普通条目,不同则调用find_diff_sources_from_copies沿复制图查找源”;后者会先按拓扑序构建祖先/后代映射(collect_descendants),再依次尝试“父节点/祖先在树中 → 父节点的后代在树中 → 祖先的后代在树中”三级查找,最后用classify_source把每个源分类为Normal/Copy(path)/Rename(path)。is_ancestor函数则负责复制图的祖先判断。文档中“先找最短路径、已占用目标不再复用”的策略在代码中体现为按 parents 顺序每父至多取一个源(代码中的 TODO 注释也保留了“当父节点自身有多个父时如何取最接近亲缘”的开放问题)。
4.2 例二:最佳重命名目标的选取
N copy baz->qux | M rename foo->baz | | L rename foo->bar |/ K add foodiff L→N 时所有文件都相关。由于bar在目标端不存在,需要为它找重命名目标:baz在图上比qux更近,所以选baz。结果:
bar重命名为bazbar复制为qux
4.3 例三:向已删除文件的同一路径复制
M copy foo->bar | L delete bar | K add foo, bar- diff K→M:
bar在两侧是不同的、无关的 copy ID。呈现两条记录:bar被删除;bar由foo复制而来。 - diff M→K:反过来呈现:
bar被创建;bar合并进foo。
这一“同一目标路径拆成删除 + 复制两条记录”的行为,在 lib/src/copies.rs 中有对应注释:当 before/after copy ID 不同时会先发出一个“删除”条目再做复制追溯(NOTE[deletion-diff-entry]),文档承认这可能比旧 diff 流多出条目,并标注“目前计划改进”。
五、Merge:先做“命名冲突阶段”,再做内容合并
文档规定合并时要在内容级合并之前增加一个复制处理阶段:
- 先根据输入树构建完全未解析的合并树;
- 查看各 diff 中变化的文件,逐个查询其完整复制图;
- 沿复制图找出候选目标路径,在合并的另一侧树中查找该路径——若路径存在且 copy ID 匹配,则两文件相关;
- 由于 base 与冲突首项之间的差异可能非常大,不查看那个 diff;只要后端能按 copy ID 查询完整复制图,就不需要该 diff 也能保证正确性;
- 找到所有参与合并的复制后进行分析,找出冲突(例如两侧把文件重命名到同一目标);有冲突时树保持不变,由用户通过
jj resolve解决命名冲突;文档还预留了“在提交上写标志位表示存在未解决命名冲突,以便后续跳过该阶段”的优化; - 合并树时,先把每个 diff 改写成目标树中的路径名。例如树冲突为
A+(B-C)+(D-E)时,把(B-C)与(D-E)两个 diff 改写为A中的路径:先从C到A计算重命名,再把这组重命名同时应用到C和B,这可能产生冲突。
如果某文件的 copy ID 本身处于冲突状态,那么物化时它表现为“不存在”,因此在用户解决冲突前不会出现在工作副本中。
文档给了两个 rebase 的具体例子说明“相关才传播、不相关不动”:
例 1:K add foo="hello"→L rename foo->bar;另一侧M set foo="bye"。把Mrebase 到L上时,把foo->bar重命名应用到M及其父的树上。
例 2:K add foo="K"→L delete foo;另一侧M create foo="M",N rename foo->bar。把Mrebase 到N上时,虽然N里有foo->bar重命名,但它与M中新建的foo(假设使用了不同的 salt)不相关,因此不做任何重命名,新foo文件在 rebase 后的M中直接创建,与 rebase 前一样。
5.1 决策点:是否把修改传播到复制目标?
文档讨论了三条路线:
- 自动传播(Mercurial 的行为,Git 不做):修改了
foo再 rebase 到一个把foo复制为bar的提交时,修改也应用到bar。在“文件被一分为二”的场景下特别有用——各修改在其中一个文件里成功应用,在另一个文件里产生 modify/delete 冲突,可以较容易地向删除侧解决。不传播则修改只会以 modify/delete 冲突形式出现在原文件里,需要手工拷到复制文件。副作用是“rebase 走再 rebase 回来”不再恒等(同一修改会应用到foo两次),但同变规则下不构成冲突; - 完全不传播;
- 询问用户:在冲突提交中保持输入树的相关路径不变,每次检查该提交时重做复制追踪,由
jj resolve用简单的 yes/no 逐个复制目标询问是否传播。
文档的最终决策是询问用户:“这避免了意外,并让冲突代数在更多场景下成立”。
对应的例子:
M foo="M" | | L copy foo->bar |/ K add foo="K"把Mrebase 到L时,由于不自动传播,M+(L-K)树保持未解析。若用户不解决冲突而是把Lrebase 回K,冲突会按常规冲突简化规则自动消失。
5.2 多个复制目标:四元冲突
N foo="N" | | M foo="M, foo2="M2", foo3="M3" | | | L copy foo->foo2, copy foo->foo3 |/ K add foo="K"把Mrebase 到N时,foo、foo2、foo3的修改全部应用到foo上,得到一个四元冲突。
5.3 收敛性重命名:复制图上的 merge
$ jj log C rename bar->baz | | B rename foo->baz |/ A add foo, add bar $ jj new B Cbaz的复制图应同时继承foo与bar,在复制图中产生一个 merge。各提交树:
Commit A: name: foo, id: aaa111, copy_id: 1:foo name: bar, id: aaa111, copy_id: 2:bar Commit B: name: bar, id: aaa111, copy_id: 2:bar name: baz, id: aaa111, copy_id: 3:baz->1:foo Commit C: name: foo, id: aaa111, copy_id: 1:foo name: baz, id: aaa111, copy_id: 4:baz->2:bar Merge commit: name: baz, id: aaa111, copy_id: 5:baz->{3:baz->1:foo,4:baz->2:bar}(示例中foo与bar用了相同内容以便简化;若内容不同,内容会冲突,但 copy ID 依然清晰。)这正是第 3 章把 past names 做成 DAG(parents: Vec<CopyId>允许多父)的直接原因。
5.4 rebase 示例:两次 rebase 回到原图
$ jj log C rename bar->baz | B rename foo->bar | A add foo $ jj rebase -r C -o Arebase 后C变为rename foo->baz、与B分叉;再执行jj rebase -r C -o B即回到原线性图——与第 2.3 节的无损往返不变式呼应。
5.5 Mercurial 的经典难题:重命名“新增文件”
$ jj log C rename foo->bar | | B modify foo |/ A add foo $ jj squash --from C --into AMercurial 的问题在于:squash C 进 A 之后,新的 A 有文件bar但没有记录它曾叫foo。本文档的设计之所以能处理它,是因为 squash 后保留了bar的 copy ID,从而能检测到 B 对foo的修改应传播到bar。
5.6 发散性重命名:与 Git / Mercurial 的对比
$ jj log C rename foo->baz | | B rename foo->bar |/ A add foo $ jj new B C普通三方树合并(不考虑复制信息)会得到一个无冲突的树,但用户可能合理期待需要在bar与baz之间做出选择。文档引用了 Git 的输出:
$ git merge main CONFLICT (rename/rename): foo renamed to baz in HEAD and to bar in main. Automatic merge failed; fix conflicts and then commit the result. $ git st HEAD detached from ab0b8e3 You have unmerged paths. (fix conflicts and run "git commit") (use "git merge --abort" to abort the merge) Unmerged paths: (use "git add/rm <file>..." as appropriate to mark resolution) added by them: bar added by us: baz both deleted: foo值得注意的是 Git 似乎通过索引中“正常冲突不会产生的状态”来代表这一情形。而 Mercurial 的输出:
$ hg merge main note: possible conflict - foo was renamed multiple times to: bar baz 1 files updated, 0 files merged, 0 files removed, 0 files unresolved (branch merge, don't forget to commit)Mercurial 没有地方记录这个状态,只打印提示了事。本文档描述的模型与算法则会通过传播重命名,在两个路径上产生 copy ID 冲突——冲突状态被显式记录进快照,这正是 jj 与 Mercurial 的差别所在。
5.7 一个未完成的测试用例
文档保留了 @jonathantanmy 的测试用例(标注 TODO 待补充):
$ jj log E baz="baz" (resolves conflict) | D <conflict> |\ C | rename bar->baz || | B rename foo->baz ||/ A add foo="foo" and bar="bar" $ jj rebase -r E -o C $ jj new D E -m F约束是:若 F 为空(自动合并),它应处于与 rebase 前的 E 相同的状态。
六、Log 与 Annotate 的支持
- Log:复制图包含文件的所有历史路径与 copy ID,因此
jj log <filename>可以翻译成一个类似files()的 revset,只不过匹配的是特定的(path, copy ID) 对而非特定路径。 - Annotate:设计文档中这一节标记为 TBD——即当时 blame 沿复制链追溯的具体算法尚未设计。仓库中已存在独立的 lib/src/annotate.rs 实现,但从设计文档的口径看,annotate 的复制感知能力仍属于待完善范围;读者引用时应以当前实际行为为准。
七、后端表示:Git 后端与云端后端的落地难题
7.1 Git 后端:要不要、如何回填
文档提出一系列开放问题与数据点:
- 是否要在 Git 后端里记录重命名?若记录,大概要像存储 change id 一样存到 Git 对象之外;
- 对于没有复制图记录的树,用什么填充?若直接按当前路径新建复制图,调用方将永远发现不了任何复制;
- 是否在
jj git init时做一遍全库重命名检测索引?代价很高——文档给出了作者机器上的实测参照:git log --summary --find-copies-harder在 git.git 仓库约165 秒,在 Nixpkgs 仓库约13 小时; - 备选:克隆后在后台做复制索引。代价是复制信息要过一段时间才可见,且实现更复杂;
- 同一内容但不同 FileId 的两棵树如何处理?把附加数据挂在提交对象上?——但工作副本状态指向的树并不来自提交,此路不通;
- 还有一种思路:Git 里完全不存复制信息,把前述模型作为后端实现细节(原生后端与 Google 后端使用,Git 后端继续即时检测)。但若想向用户展示“冲突的 copy ID”细节以便其决定如何解决,就必须在抽象层面表达它。
对照当前仓库:如前所述,lib/src/git_backend.rs 中read_copy/write_copy/get_related_copies目前全部返回Unsupported("The Git backend doesn't support tracked copies yet")——即文档所讨论的“Git 后端是否回填复制图”在当下仍未定案,日常 diff 中的复制/重命名来自按区间计算CopyRecord的启发式路径(get_copy_records走后端root..head区间查询,见 lib/src/backend.rs 的接口注释与 cli/src/diff_util.rs 的实现)。
7.2 云端仓库(如 Google 后端):按 copy ID 查整图
场景:你有一个包含若干修改文件的提交,现在要把它同步(rebase)到更新后的主干。如果你修改的某些文件在主干上已不存在,要判断它们是否被重命名、从而把你的修改传播到新位置。方法是找出“自上次与主干同步以来 copy ID 发生变化的文件”——但如果主干上有 1000 万个新提交,可能有数以万计这样的文件散布在全树,计算非常昂贵,因此必须让自定义后端帮上忙。
由于只关心“rebase 的提交中变化过的文件”涉及的复制图,后端只需提供一个方法:给定一个(或一组)copy ID,取回其完整复制图。流程是:先找出 rebase 提交 diff 涉及的所有 copy ID,再向后端查询完整复制图,然后遍历复制图看是否有节点存在于目标树中。该方案的弱点是相关文件非常多时查询变贵——“实践中问题不大”。文档还提醒:服务器可能只希望为 public/immutable 提交建索引,否则用户可以创建大量复制(故意或误操作)来“污染”索引,使之后对这些文件的所有查询都变贵。
这段设计直接对应到了 lib/src/backend.rs 中get_related_copies的契约(“返回指定 copy 历史的祖先及其全部后代;允许多返回兄弟甚至无关的复制历史,但属于浪费”),以及 lib/src/copies.rs 中diffs_from_copies的真实调用链:拿到 after 文件的copy_id→backend().get_related_copies(copy_id)→ 构建CopyGraph→ 祖先/后代分析 → 分类源。
八、实现计划
设计文档给出的粗略实现顺序(原文编号即如此):
- 在测试后端中实现复制追踪支持;
- 实现 diff 算法并测试;
- 实现 merge 算法并测试;
- 实现 blame 算法并测试;
- 实现“文件跟随”的 log 算法并测试;
- 把部分查询提取到 commit backend trait,让基于数据库索引的云端后端(如 Google 后端)可以提供自己的版本;
- 在 Git 后端中实现复制追踪,可能涉及(惰性的)回填,也可能需要在 commit backend trait 中引入新的抽象;
- 实现用于记录复制、以及解决复制冲突的 CLI。
对照仓库现状:第 3 步的 diff 算法已落地(CopyHistoryDiffStream及配套的源查找/分类逻辑),第 7 步的 trait 接口也已定义(含BackendError::Unsupported逃生舱),而第 8 步(Git 后端)与第 9 步(CLI 侧的复制记录/冲突解决)在当前代码中尚未完成,这与 docs/changelog.md 等文档中“复制检测仍为启发式”的现状一致。
九、被否决的替代方案
9.1 即时检测(Git 模型)
Git 不记录复制信息,而是在比较两棵树时推断。难点在于超大仓库的可扩展性:例如 rebase 本地提交到一个领先 100 万提交的上游时,要找本地提交的文件是否在上游被复制过,对比新旧基树极其昂贵。而本文档定义的查询 API以提交(而非树)为输入,允许后端利用历史做索引:后端可以基于本地提交中的文件建立索引,判断其是否被复制,而无需对比整树。
9.2 树中记录逻辑文件标识(BitKeeper 模型)
BitKeeper 为每个路径记录一个文件 ID(标识逻辑文件)。比较任意两棵树时,找出新增与删除的文件,比较它们的 ID 即可判断哪些是重命名。问题是:该模型似乎无法扩展到复制(只能表达重命名);跨百万提交 rebase 时不想 diff 整棵树(可能有百万个修改文件),或许可以二分查找“删除了被 rebase 提交所改文件”的提交;另一个难题是 Git 后端如何合成文件 ID——可以从根提交开始遍历并持久化索引。
9.3 把复制信息并入 FileId(Mercurial 模型)
Mercurial 把复制信息存在文件内容自身的元数据段里,复制历史一变文件就拿到新的内容 ID——与本设计相当接近。差别在于 Mercurial 只记录最近一次复制的信息,文件一旦修改就获得新文件 ID,要找前一个名字必须遍历文件历史(由于 Mercurial 在提交级修订 DAG 之外还有每文件的修订 DAG,这通常不是大问题)。文档脚注还指出:Mercurial 从某个版本起也支持把复制信息存进提交,而那恰好是下一种“快照/补丁混合模型”,被论证为行不通。
9.4 混合快照/补丁模型:把复制信息存进提交
团队认真考虑过把复制/重命名信息存在提交对象里,但它对数据模型有显著影响:
- 没有复制信息时,线性链 A..D 的总 diff 只需 diff D-A(因为 (B-A)+(C-B)+(D-C) 可化简为 D-A);而复制信息使总 diff 涉及复制信息,若挂在单个提交上就必须做某种聚合;
- 从另一棵树 restore 不再只是拷贝那棵树,还需要弄清新旧树之间的复制关系;
- 冲突状态由“要加要减的一组状态”表示,与补丁式复制信息不兼容。作者团队花了很多时间尝试找到可行解,结论是快照式冲突模型与补丁式复制信息模型无法调和,因此不追踪冲突的复制信息(例如
foo→baz与bar→baz两个重命名之间的冲突); - 由于复制记录是相对于自动合并后父提交的,记录会依赖合并算法,未来合并算法的改动可能使某些复制记录失效,因此实现上不能假设复制源一定存在。
他们还尝试过用下面的表示来表达冲突提交中的状态:
struct MergedTree { snapshot: Tree, diffs: Diff } struct Diff { before: Tree, after: Tree, /// Copies from `before` to `after` copies: Vec<CopyInfo>, /// Copies from `before` to `snapshot` copies_to_snapshot: Vec<CopyInfo>, } struct CopyInfo { source: RepoPathBuf, target: RepoPathBuf, // Maybe more fields here for e.g. "do not propagate" }它能算出结果树,但做不了现有的冲突代数——这意味着“把提交并行化再串行化”之类的操作会丢失复制信息,最终被放弃。
十、小结:设计要点与仓库中的验证路径
这篇设计文档的核心贡献可以归纳为三点:
- 复制信息快照化:把“过去的路径名”表示为
CopyHistoryDAG 对象,树条目只持CopyId,从而与 jj 的一级冲突代数(基于快照差)兼容;salt字段(现已见诸 lib/src/backend.rs)还额外支持“同路径文件被逻辑重写”的表达; - 行为不变式驱动:restore 压平传递复制、diff 在 restore 后为空、rebase/parallelize 无损往返、revert 为 no-op、命名冲突可解可回退——每一条都是可写测试的断言;
- 后端无关的查询契约:以提交为输入、按 copy ID 取整图的
get_related_copies接口,让 Git(即时检测)与云端数据库后端(索引查询)都能接入,且未就绪的后端可用BackendError::Unsupported平滑共存(当前 Git 与 simple 后端正是如此,见 lib/src/git_backend.rs、lib/src/simple_backend.rs)。
延伸阅读建议:diff 算法细节读 lib/src/copies.rs,trait 契约读 lib/src/backend.rs,启发式检测的 CLI 侧入口读 cli/src/diff_util.rs 与 cli/src/commands/debug/copy_detection.rs,冲突代数读 lib/src/merge.rs。
【免费下载链接】jjA Git-compatible VCS that is both simple and powerful项目地址: https://gitcode.com/GitHub_Trending/jj/jj
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考