Jujutsu 复制追踪设计:在快照模型中用 CopyId DAG 实现 rename/copy 的检测、合并与追溯
导读:本文基于 Jujutsu(jj)仓库中的 复制追踪设计文档 展开,系统讲解 jj 如何在基于快照(snapshot)的存储模型上记录与推导文件复制/重命名(copy/rename)信息的完整方案。文中将覆盖核心数据结构(CopyId、CopyHistory)、diff 与 merge 的复制感知算法、Git 后端与云后端的表示差异、以及当前仓库中 copies.rs 等源码对设计的落地印证,帮助你理解 jj diff、jj status、jj debug copy-detection 等命令背后复制信息的产生与消费链路。
一、设计目标:为哪些场景引入复制信息
Jujutsu 的设计目标是提供对复制信息(copy information)的支撑,以满足至少以下四类使用场景:
- Diff(差异比较):如果一个文件是被复制来的,diff 时应展示它相对源版本的差异,而不是显示为一个全新的新增文件。
- Merge(合并):当合并(或 rebase)的一侧重命名了文件、另一侧修改了该文件时,应能把修改传播到新路径上(此外还有很多需要处理的组合情况)。
- Log(历史追溯):应能执行类似
jj log -p <file>的命令,并在文件由复制创建时沿文件历史向前追溯。 - Annotate(标注/blame):与 Log 场景类似,在文件由复制创建时应能沿历史向前追溯。
同时,该方案必须满足性能上的双重要求:既要支持 Git 那种在任意两棵树之间即时合成(on the fly)复制信息的方式,也要支持自定义后端(custom backend)显式记录并在任意大的提交区间上重新提供复制信息的方式。API 设计还需要保证自定义后端在准备好之前可以完全忽略复制信息——这一点在源码中体现为后端 trait 的可选实现与 BackendError::Unsupported 返回值(详见下文“后端表示”一节)。
二、期望的用户体验(Desired UX)
设计文档通过一系列典型场景定义了理想行为,这些场景也是后续算法正确性的验收标准。
2.1 复制与重命名不区分:rename = copy + delete
文档明确说明:没有太多理由去区分 copy 与 rename,rename 就是“copy 加上一个 delete”。因此系统无法区分“把 foo 复制到 bar 再把 foo 重命名为 baz”与“把 foo 复制到 baz 再把 foo 重命名为 bar”这两种操作序列。
2.2 从提交恢复时应保留复制
例如 jj new X--; jj restore --from X 应把 X- 与 X 中产生的复制恢复进新的工作副本。传递性复制应被“展平”(flattened):若 X- 把 foo 重命名为 bar,X 又把 bar 重命名为 baz,则恢复后的提交应表现为 foo 直接重命名为 baz。这同样适用于一般的 reparenting(如“逐字 rebase”)。
2.3 恢复后的 diff 应为空
jj restore --from X; jj diff --from X 在文件内容层面应为空(允许提示重命名文件具有不同历史)。
2.4 Rebase 的无损往返
除 same-change 规则(即 A+(A-B)=A)外,当前 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.5 撤销父提交应为空操作
补丁应可逆,即做一次修改再撤销后,两个提交合并来看应为空 diff:
$ jj log
B rename foo->bar
||
A add foo
$ jj revert -r B -o B
$ jj diff --from B- --to B+ # 应为空
2.6 Parallelize/serialize 往返
这是无损 rebase 的特例:jj parallelize B::D 后再通过 jj rebase -r C -A B、jj rebase -r D -A C 逐步串行化,应能回到与原先相同的图结构,且 E 中不应出现冲突、看起来与之前一样是普通编辑。
2.7 合并提交内部的复制
系统应能解析命名冲突(例如两个分支把不同文件重命名为同一名字,合并时选择其中一个作为来源),jj file annotate baz 不应包含来自另一分支的改动;也应能撤销该解析、回到命名冲突状态;还应能重命名仅存在于一侧的文件。
2.8 跨合并提交的复制
例如两侧分别把 foo 重命名为 bar 与 baz,另一提交删除 baz:jj diff --from C --to D 应显示 baz -> bar 重命名(与 jj diff --from C --to B 一致),而 jj diff --from B --to D 不应显示重命名——尽管 C 中存在重命名。
三、高层设计:把复制历史放进快照模型
3.1 快照模型下的必然选择
Jujutsu 采用与 Git 类似的**基于快照(snapshot)**的模型,其一级冲突代数同样建立在快照与“状态间差异即补丁”的基础上。因此复制信息也必须被嵌入快照模型——设计文档作者注记中提到,这一认知花费了数月才真正理解(原注记)。
核心提案:让树对象(tree object)同时携带文件的历史名字信息。例如文件 foo 在一个提交中被重命名为 bar、在另一提交中被重命名为 baz,则记录 baz 之前曾叫过 bar 和 foo。
为了支持把两个文件合并为一个,历史名字列表实际上是一个 DAG:合并可以发生在合并提交中(两侧把不同源文件复制/重命名为同一目标文件),借助模型层的支持,普通非合并提交中也能支持多文件合并为一个文件。
3.2 数据结构:CopyId 与 CopyHistory
为避免在树对象条目中存储全部历史路径,复制历史被写作独立对象,树对象通过 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>
}
该设计已在仓库源码中落地。在 backend.rs 中定义了 CopyId 类型;CopyHistory 结构体包含:
current_path: RepoPathBuf——文件当前路径;parents: Vec<CopyId>——成为当前文件“化身”的来源文件 ID 列表(新创建文件无父节点;常规复制/重命名有一个父节点;多文件合并有多个父节点);salt: Vec<u8>——可选盐值,用于给 Copy 对象一个不同 ID;可随机生成。这允许一个提交声明某文件被“新化身”取代,即路径上换了一个逻辑上完全不同的文件(对应设计中“从头重写文件”的场景)。
而 TreeValue::File 变体在 backend.rs 中已包含 copy_id: CopyId 字段,并提供 copy_id() 辅助方法(L413-L421)。文档还讨论了两个扩展问题:
- 符号链接(symlink):其历史对 annotate 用途不大,但了解历史至少有助于检测目录重命名(若某目录下所有文件与符号链接都被重命名)。
- 目录复制:大概率不支持,因为看起来较复杂(文档作者自述未深入思考,也可能并不复杂)。
关于确定性 ID 与盐:若仅用文件名作为 ID 输入,可获得确定性树 ID;若给复制图节点加盐,则可表达“文件被从头重写”。例如 foo 上一提交的 copy ID 为 123,本提交被重写后得到 copy ID 456(尽管并不涉及从既有文件的复制)。这在逻辑上成立,但文档作者不确定其实际价值——而当前源码中的 salt 字段正是该讨论的落点。
四、复制感知的 Diff 算法
4.1 算法骨架
对两棵树做 diff 时,先在不考虑复制信息的情况下 diff 树;对 diff 中发生变化的所有 copy ID,遍历其复制图,判断相互之间的关联,并决定哪个源文件对应哪个目标文件。具体细节留给实现,文档通过多个例子说明其可行性。
4.2 例子:分叉复制与重命名(Divergent copy and rename)
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
对应的复制图关系:
graph LR
subgraph L["Commit L"]
2["2:bar"]
3["3:baz"]
subgraph K["Commit K"]
1["1:foo"]
end
end
subgraph M["Commit M"]
4["4:bar"]
5["5:baz"]
end
2 --> 1
5 --> 1
Diff K -> M:仅看树,diff 发现 copy ID 1、4、5 受影响。遍历图后得知 1 与 5 相关、4 不相关。由于源侧不存在 foo、目标侧不存在 baz(且 copy ID 相关),判定为重命名。
Diff L -> M:等价于 jj new L; jj bookmark create M2; jj restore --from M --to M2 得到树相同的 M2 的 diff。diff 发现 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。最终得到:
baz被删除(删除内容L)bar被创建(内容M)foo被重命名为baz(展示K到M的 diff)bar被合并进baz(展示L到M的 diff)
4.3 例子:分叉复制与重命名(最佳重命名目标)
N copy baz->qux
||
M rename foo->baz
||
|| L rename foo->bar
||/
K add foo
Diff L -> N 时所有文件都相关。源侧 bar 在目标侧不存在,需寻找重命名目标:选择 baz,因为它在图中比 qux 更近。于是 diff 为:
bar重命名为bazbar复制到qux
4.4 例子:复制到被删除的文件上
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。
4.5 源码中的实现印证
复制感知 diff 在 copies.rs 中有完整实现:
CopyRecords(L55-L119)用sources/targets两个 HashMap 从路径快速索引复制记录;同一(source, target)对在 diff 合并提交时每个父节点都会报告一次,被识别为重复并跳过;存在冲突的目标则被丢弃并按“无来源”处理(对应设计文档“TODO: handle conflicts”的已知简化)。CopyOperation枚举(L122-L128)区分Copy(源路径未删除)与Rename(源路径已删除)。CopiesTreeDiffStream(L172-L255)包装普通TreeDiffStream:当目标路径有复制记录时,通过检查源路径在目标树中是否存在来判断是Rename还是Copy;当目标被删除且源路径存在复制记录时,跳过该“删除”条目(避免重命名场景出现重复删除)。- 更进一步的
CopyHistoryDiffStream(L386-L522)与CopyHistoryTreeDiffEntry则完全基于 copy history 做追溯:两侧 copy_id 相同则走普通路径;copy_id 不同或非文件变文件时,标记前者删除、对后者做 copy-tracing(注意代码注释NOTE[deletion-diff-entry]:这可能会产生两条 diff 条目,作者计划近期改进)。其中find_diff_sources_from_copies(L627-L714)实现了“沿复制图找源”的核心逻辑:按父节点遍历祖先、后代,取树中存在的最近相关文件,与设计文档中“遍历复制图找最短路径”的描述一致。 MergedTree::diff_stream_with_copies()(merged_tree.rs)与MergedTree::copy_value()(L236-L243)把复制记录接入 diff 流,其中copy_value读取 CopyHistory 定位current_path并校验该路径处的 copy_id 与查询一致。
五、复制感知的 Merge(合并)算法
5.1 流程
合并时需在内容级合并之前增加一个处理复制的阶段:
- 先基于输入树构建完全未解析的合并树;
- 查看每个 diff 中变化的文件,取其完整复制图;
- 遍历复制图找出可能的目标路径,再到合并的另一侧查找:若该路径存在于树中且 copy ID 匹配,则两文件相关;
- 分析所有涉及的复制,找出冲突(如两侧把文件重命名为同一目标)。存在冲突时保持树不变,由用户通过
jj resolve(待实现)解决命名冲突。若命名冲突阶段较慢,可考虑给提交写入“存在未解决命名冲突”的标志位,避免后续调用重复该阶段。
设计上假设 base 与冲突第一项之间的差异可能非常大,因此不查看该 diff;只要提交后端能按 copy ID 返回完整复制图,就不依赖该 diff 也能保证正确性。
合并树时,先把每个 diff 重写到目标树中对应的不同名字上。例如树冲突为 A+(B-C)+(D-E),则把 (B-C) 与 (D-E) 两个 diff 重写到 A 中的路径:先计算 C 到 A 的重命名,再把这些重命名同时应用到 C 和 B,这可能产生冲突。
关键行为:若文件的 copy ID 存在冲突,物化时表现为“不存在”,因此在用户解决冲突前不会出现在工作副本中。
5.2 合并示例一:重命名叠加修改
M set foo="bye"
||
|| L rename foo->bar
||/
K add foo="hello"
把 M rebase 到 L 上时,把 foo->bar 重命名应用到 M 及其父提交的树上。
5.3 合并示例二:无关文件的创建不被重命名干扰
N rename foo->bar
||
|| M create foo="M"
|| |
|| L delete foo
||/
K add foo="K"
把 M rebase 到 N 上时,N 中存在 foo->bar 重命名,但它与 M 中新建的 foo 无关(假设新建的 foo 使用了不同的盐值),因此不执行任何重命名,rebase 后新建的 foo 与 rebase 前一样。
5.4 是否跨复制传播修改?
这是文档讨论的一个开放决策点:
- 传播(Mercurial 的做法,Git 不做):修改
foo后 rebase 到“把foo复制为bar”的提交上时,把修改同时应用到bar。这在“一个文件被拆分为两个文件”的场景特别有用(如foo被拆为foo1/foo2),每个修改都能在某一个文件中成功应用,另一个文件中产生相对容易解决的 modify/delete 冲突;若不传播,属于某个文件的修改只会表现为第一个文件中的 modify/delete 冲突,需要手动拷贝。 - 代价:传播后 rebase 再 rebase 回去不再是无操作(即使忽略 same-change 规则),同一修改会应用两次到
foo;不过得益于 same-change 规则不会被视为冲突,实际体验可能还行。 - 第三种选择:不自动传播,而是在冲突提交中保留相关路径不变,每次检查该提交时重做复制追踪;由
jj resolve对每个复制目标问用户一个简单的 yes/no 问题。
最终决策:询问用户是否传播(yes/no)是最佳方案——避免意外,并让冲突代数在更多情况下成立。
5.5 更多合并示例
传播后 rebase 回去:M foo="M" rebase 到 L copy foo->bar 上时,因决定不自动传播,保留 M+(L-K) 树未解析;若用户不解决冲突直接 rebase 回 K,按常规冲突简化自动解决。
多重复制:M 修改 foo,L 把 foo 复制为 foo2、foo3,把 M rebase 到 N 后,对 foo、foo2、foo3 的修改全部应用到 foo,产生 4 方冲突(4-sided conflict)。
汇聚重命名(Convergent renames):
$ jj log
C rename bar->baz
||
|| B rename foo->baz
||/
A add foo, add bar
$ jj new B C
baz 的复制图应同时继承 foo 与 bar,在复制图中产生一个 merge。各树如下(为简化两侧内容相同;若不同则内容有冲突,但 copy ID 依然明确):
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}
Rebasing:链式重命名 A add foo -> B rename foo->bar -> C rename bar->baz,执行 jj rebase -r C -o A 后得到 C rename foo->baz(B 仍在);再 jj rebase -r C -o B 回到原状。
重命名新增文件(Rename added file):这是 Mercurial 的著名棘手问题:
$ jj log
C rename foo->bar
||
|| B modify foo
||/
A add foo
$ jj squash --from C --into A
Mercurial 的问题在于 squash 后新 A 有 bar 但无“它曾叫 foo”的记录。本文方案在 squash 后保留 bar 的 copy ID,从而能检测到 B 中对 foo 的修改应传播到 bar。
分叉重命名(Divergent renames):
$ jj log
C rename foo->baz
||
|| B rename foo->bar
||/
A add foo
$ jj new B C
普通 3 方合并(不考虑复制信息)得到的树无冲突,但用户可能期望在 bar 与 baz 名字间选择。Git 会报 CONFLICT (rename/rename);Mercurial 只打印 note: possible conflict - foo was renamed multiple times to: bar baz 后继续。而本文的模型与算法在传播重命名后会在两条路径上都产生 copy ID 冲突,从而可显式呈现给用户解决。
5.6 源码中的合并印证
树合并逻辑在 tree_merge.rs 中已把 copy_id 纳入冲突处理:try_resolve_file_conflict(L411-L469)分别提取文件内容 ID、可执行位、copy ID 的冲突合并,仅当 copy_id 冲突可按 SameChange::Accept 平凡解析时才输出合并后的文件值,否则保留冲突——对应设计文档“copy ID 冲突时文件物化为不存在、等待用户解决”的行为。代码中留有 TODO:是否按 options.same_change 合并可执行位与 copy_id。
六、Log 与 Annotate
- Log:复制图包含文件的所有历史路径与 copy ID,因此执行
jj log <filename>时,可把它翻译为类似files()的 revset,但匹配的是特定(path, copy ID)对而非特定路径。 - Annotate:原文档标注为 TBD(待定)。当前 annotate.rs 中的
FileAnnotator注释也写明:“如果加入 copy-tracing 支持,file_path可能由 state 跟踪”,说明该功能仍未实现。
七、后端表示:Git、云仓库与自定义后端
7.1 在 Git 后端中的表示
文档提出若干问题:是否要在 Git 后端记录重命名?若要,则应像 change id 一样存储在 Git 对象之外。对于没有复制图的树用什么?若直接按当前路径新建复制图,调用方永远发现不了复制。是否需要在 jj git init 时做全库重命名索引?大型仓库可能非常昂贵——参考数据:git log --summary --find-copies-harder 在 git.git 仓库约需 165 秒,在 Nixpkgs 仓库约需 13 小时。替代方案是在克隆后后台做复制索引,但复制信息会延迟出现,且实现工作量更大。
关于“两棵树内容相同但 file id 不同”的处理:是否把附加数据链到提交对象?这行不通——因为树可能被非提交对象引用(例如工作副本状态就指向树)。
另一种思路:Git 中不存任何复制信息,把上述模型作为后端的实现细节(供原生后端与 Google 后端使用),Git 后端继续用即时复制检测。但若要向用户展示冲突 copy ID 的细节以便其决策,仍需抽象地表示这些冲突。
当前仓库的落地情况:git_backend.rs 中 read_copy/write_copy/get_related_copies 均直接返回 BackendError::Unsupported(L1147-L1160),Git 后端生成的 TreeValue::File 使用 CopyId::placeholder()(L1194-L1204);get_copy_records(L1478-L1555)则借助 gix 的树 diff 与 track_rewrites 做即时复制检测(重写百分比阈值 0.5、limit: 1000、从修改文件集中找复制源),并把检测到的 Rewrite 转换为 CopyRecord 流式返回——这正是文档所描述的“Git 即时合成复制信息”模式。
7.2 在云仓库(如 Google)中的表示
假设要把本地修改的提交同步(rebase)到更新的主干上:若某些修改过的文件在主干上已不存在,需要判断它们是否被重命名,从而把修改传播到新位置。可通过“自上次同步以来 copy ID 发生变化的文件”来定位。但当主干新增上千万提交、全树散布数万个相关文件时,计算代价极高,因此需要自定义后端实现帮助执行该查询。
由于只关心与“rebase 提交中修改过的文件”相关的复制图,后端只需提供“按给定 copy ID(或列表)获取完整复制图”的方法:先找出 rebase 提交 diff 涉及的所有 copy ID,再查询后端获取完整复制图,最后遍历复制图检查是否有节点存在于目标树中。该方案的一个弱点是:相关文件极多时搜索变昂贵,但实践中问题不大。服务器可能只对公开/不可变提交填充索引,否则用户可通过大量复制(有意或无意)污染索引,使未来所有相关查询变慢。
7.3 Backend trait 中的 API
上述设计已固化为 backend.rs 中 Backend trait 的方法:
read_copy(&self, id: &CopyId) -> BackendResult<CopyHistory>(L803-L807);write_copy(&self, copy: &CopyHistory) -> BackendResult<CopyId>(L809-L813);get_related_copies(&self, copy_id: &CopyId) -> BackendResult<Vec<RelatedCopy>>(L815-L825)——返回与指定复制图相关的所有历史(祖先 + 祖先的所有后代),子节点必须先于父节点返回且顺序确定;允许(但浪费地)包含无关历史;不支持的实现返回BackendError::Unsupported;get_copy_records(&self, paths, root, head) -> ...(L856-L873)——返回root..head提交区间内的复制记录,可按路径过滤,顺序保证为逆拓扑序(祖先的 record 流式输出在后),设计为流式以支持超大单文件历史,并允许 blame/annotate 类迭代算法提前短路。
配套类型:CopyRecord(L223-L249)描述单次复制事件(target、target_commit、source、source_file、source_commit),其中 source_commit 供后端实现“集成(integration)”逻辑——类似分支但发生在文件级,且指向特定提交的复制源应避免在 rebase 时传播复制(适合 fork 风格复制);RelatedCopy(L269-L276)是 CopyHistory 与其 ID 的组合。
测试后端 test_backend.rs 则完整实现了 read_copy/write_copy/get_related_copies(copy ID 由 CopyHistory 的哈希生成),并在注释中说明“返回全部复制历史以测试调用方正确忽略无关历史”——这与实现计划第一步“先在测试后端实现复制追踪”呼应。
八、实现计划
设计文档给出的大致实施路线:
- 在测试后端实现复制追踪支持;
- 实现 diff 算法并测试;
- 实现 merge 算法并测试;
- 实现 blame 算法并测试;
- 实现文件跟随(file-following)log 算法并测试;
- 把部分查询提取到提交后端 trait,使云后端(如 Google 后端)能提供基于数据库索引的实现;
- 在 Git 后端实现复制追踪(可能涉及惰性回填或提交后端 trait 的新抽象);
- 实现记录复制与解决复制冲突的 CLI。
从当前仓库看,第 1~2 步的成果已存在(test_backend.rs 的完整复制支持、copies.rs 的复制感知 diff 流、copy_detection.rs 提供的 jj debug copy-detection 调试命令),而 blame 追踪、Git 后端的持久化复制图以及解决复制冲突的正式 CLI 仍属未完成工作。
九、备选方案对比(Alternatives considered)
设计文档系统对比了四种备选模型,并解释了为何选择当前方案:
9.1 像 Git 一样即时检测复制
Git 不记录复制信息,而是在比较两棵树时推断。问题在于难以扩展到超大型仓库:例如把本地提交 rebase 到领先 100 万提交的新上游时,需要判断本地提交中的文件是否在上游被复制过,通过比较新旧 base 树来做极其昂贵。而本文方案把查询 API 定义为接收提交(而非树),允许后端利用历史计算复制,并可基于本地提交中的输入文件建索引,无需比较整棵树。
9.2 在树中记录逻辑文件标识(BitKeeper 式模型)
BitKeeper 为每个路径记录一个文件 ID(标识逻辑文件,区别于 FileId),比较任意两棵树时只需对比增删文件的文件 ID 即可判断重命名。该模型的缺陷:难以扩展支持复制(只支持重命名);跨百万提交 rebase 时不想 diff 整棵树(可能有数百万修改文件),或许可以靠二分查找“删除过本地提交中某个文件的提交”来发现重命名;Git 后端合成文件 ID 也成问题(或需从根提交遍历并持久化索引)。
9.3 把复制信息并入 FileId(Mercurial 式模型)
Mercurial 在文件内容的元数据段存储复制信息,复制历史变化会得到新的文件(内容)ID,这与本方案相近。区别在于:Mercurial 只存最近一次复制的信息,文件一旦被修改就获得新文件 ID,因此必须遍历文件历史找旧名字(通常不是大问题,因为 Mercurial 除提交级 revision DAG 外还按文件维护 revision DAG)。
9.4 快照/补丁混合模型:复制信息存在提交里
曾在提交对象中存储复制/重命名信息,但对数据模型有显著冲击:
- 无复制信息时,线性提交链 A..D 的总 diff 就是 D-A(因 (B-A)+(C-B)+(D-C) 可化简);有复制信息后总 diff 会涉及复制信息,若与单个提交关联就需要某种聚合;
- 从另一棵树恢复不再只是拷贝那棵树,还需计算新旧树之间的复制;
- 冲突状态表示为一系列“添加/移除”状态,这与补丁式复制信息不兼容——作者投入大量时间仍未调和快照式冲突模型与补丁式复制信息,最终放弃追踪冲突态复制信息(如
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"
}
该表示能算出结果树,但无法执行 jj 现有的冲突代数,导致“并行化再串行化”会丢失复制信息。
十、现状验证:如何亲手观察复制检测
在 Git 后端仓库中,复制检测是即时的。可用以下命令观察:
# 在改动提交上运行复制检测调试命令,输出 "source -> target" 记录
$ jj debug copy-detection [REVSET] # 默认 "@",对比其父提交
该命令在 copy_detection.rs 中实现:对提交的每个父提交调用 store.get_copy_records(None, parent_id, commit.id()),把结果打印为 source -> target。对应的集成测试位于 test_copy_detection.rs:构造“删除 original、新增内容相同文件 modified”的提交后,jj debug copy-detection 输出 original -> modified,验证了 Git 后端的即时重命名识别。
在用户可见层面,diff.rs 与 status.rs 已经通过 get_copy_records() 收集记录并构建 CopyRecords,随后交给 diff_stream_with_copies() 渲染,让 diff/status 输出能呈现“Rename/Copy”语义,而非纯粹的增删。
结语
Copy tracking 是 Jujutsu 在快照模型之上实现“既能与 Git 兼容、又能服务自定义后端”的关键设计。核心思路是以 CopyId 引用独立的 CopyHistory DAG 节点,把“文件曾用名”嵌入树对象;diff 时先做普通树 diff 再遍历复制图匹配来源与目标,merge 时在内容合并前增加复制处理阶段,冲突则交由用户通过 jj resolve 决策。当前仓库中 copies.rs、tree_merge.rs、backend.rs 与各后端实现已把该设计的大部分骨架落地为可运行代码,而 blame 追踪、Git 后端持久化复制图与复制冲突的正式 CLI 仍留待后续实现。理解这份设计,是深入掌握 jj 的 diff/merge/rebase 行为,乃至为自定义后端接入复制能力的基础。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00