首页
/ Jujutsu 复制追踪设计:在快照模型中用 CopyId DAG 实现 rename/copy 的检测、合并与追溯

Jujutsu 复制追踪设计:在快照模型中用 CopyId DAG 实现 rename/copy 的检测、合并与追溯

2026-09-09 13:54:09作者:姚月梅Lane

导读:本文基于 Jujutsu(jj)仓库中的 复制追踪设计文档 展开,系统讲解 jj 如何在基于快照(snapshot)的存储模型上记录与推导文件复制/重命名(copy/rename)信息的完整方案。文中将覆盖核心数据结构(CopyIdCopyHistory)、diff 与 merge 的复制感知算法、Git 后端与云后端的表示差异、以及当前仓库中 copies.rs 等源码对设计的落地印证,帮助你理解 jj diffjj statusjj 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 重命名为 barX 又把 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 Bjj rebase -r D -A C 逐步串行化,应能回到与原先相同的图结构,且 E 中不应出现冲突、看起来与之前一样是普通编辑。

2.7 合并提交内部的复制

系统应能解析命名冲突(例如两个分支把不同文件重命名为同一名字,合并时选择其中一个作为来源),jj file annotate baz 不应包含来自另一分支的改动;也应能撤销该解析、回到命名冲突状态;还应能重命名仅存在于一侧的文件。

2.8 跨合并提交的复制

例如两侧分别把 foo 重命名为 barbaz,另一提交删除 bazjj 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 之前曾叫过 barfoo

为了支持把两个文件合并为一个,历史名字列表实际上是一个 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 不相关。barbaz 在两侧的复制图互不相交,故把它们的 diff 拆成两个独立 diff。剩余 copy ID 中,复制图最短路径在源侧 foo 与目标侧 baz 之间,先处理这一对:源侧无 foo、目标侧无 baz(且有相关 copy ID),判定为重命名。剩余源侧 bar,其最近相关目标为 baz,但 baz 已被用作 foo 的重命名目标,故 bar 视为复制进 baz。最终得到:

  • baz 被删除(删除内容 L
  • bar 被创建(内容 M
  • foo 被重命名为 baz(展示 KM 的 diff)
  • bar 被合并进 baz(展示 LM 的 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 重命名为 baz
  • bar 复制到 qux

4.4 例子:复制到被删除的文件上

M copy foo->bar
||
L delete bar
||
K add foo, bar

Diff K -> Mbar 两侧 copy ID 不同且不相关,输出两条记录:bar 被删除、barfoo 复制而来。反向 diff M -> K:输出 bar 被创建、bar 被合并进 foo

4.5 源码中的实现印证

复制感知 diff 在 copies.rs 中有完整实现:

  • CopyRecordsL55-L119)用 sources/targets 两个 HashMap 从路径快速索引复制记录;同一(source, target)对在 diff 合并提交时每个父节点都会报告一次,被识别为重复并跳过;存在冲突的目标则被丢弃并按“无来源”处理(对应设计文档“TODO: handle conflicts”的已知简化)。
  • CopyOperation 枚举(L122-L128)区分 Copy(源路径未删除)与 Rename(源路径已删除)。
  • CopiesTreeDiffStreamL172-L255)包装普通 TreeDiffStream:当目标路径有复制记录时,通过检查源路径在目标树中是否存在来判断是 Rename 还是 Copy;当目标被删除且源路径存在复制记录时,跳过该“删除”条目(避免重命名场景出现重复删除)。
  • 更进一步的 CopyHistoryDiffStreamL386-L522)与 CopyHistoryTreeDiffEntry 则完全基于 copy history 做追溯:两侧 copy_id 相同则走普通路径;copy_id 不同或非文件变文件时,标记前者删除、对后者做 copy-tracing(注意代码注释 NOTE[deletion-diff-entry]:这可能会产生两条 diff 条目,作者计划近期改进)。其中 find_diff_sources_from_copiesL627-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 流程

合并时需在内容级合并之前增加一个处理复制的阶段:

  1. 先基于输入树构建完全未解析的合并树;
  2. 查看每个 diff 中变化的文件,取其完整复制图;
  3. 遍历复制图找出可能的目标路径,再到合并的另一侧查找:若该路径存在于树中且 copy ID 匹配,则两文件相关;
  4. 分析所有涉及的复制,找出冲突(如两侧把文件重命名为同一目标)。存在冲突时保持树不变,由用户通过 jj resolve(待实现)解决命名冲突。若命名冲突阶段较慢,可考虑给提交写入“存在未解决命名冲突”的标志位,避免后续调用重复该阶段。

设计上假设 base 与冲突第一项之间的差异可能非常大,因此不查看该 diff;只要提交后端能按 copy ID 返回完整复制图,就不依赖该 diff 也能保证正确性。

合并树时,先把每个 diff 重写到目标树中对应的不同名字上。例如树冲突为 A+(B-C)+(D-E),则把 (B-C)(D-E) 两个 diff 重写到 A 中的路径:先计算 CA 的重命名,再把这些重命名同时应用到 CB,这可能产生冲突。

关键行为:若文件的 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 修改 fooLfoo 复制为 foo2foo3,把 M rebase 到 N 后,对 foofoo2foo3 的修改全部应用到 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 的复制图应同时继承 foobar,在复制图中产生一个 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->bazB 仍在);再 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 方合并(不考虑复制信息)得到的树无冲突,但用户可能期望在 barbaz 名字间选择。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_conflictL411-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.rsread_copy/write_copy/get_related_copies 均直接返回 BackendError::UnsupportedL1147-L1160),Git 后端生成的 TreeValue::File 使用 CopyId::placeholder()L1194-L1204);get_copy_recordsL1478-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.rsBackend 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 类迭代算法提前短路。

配套类型:CopyRecordL223-L249)描述单次复制事件(targettarget_commitsourcesource_filesource_commit),其中 source_commit 供后端实现“集成(integration)”逻辑——类似分支但发生在文件级,且指向特定提交的复制源应避免在 rebase 时传播复制(适合 fork 风格复制);RelatedCopyL269-L276)是 CopyHistory 与其 ID 的组合。

测试后端 test_backend.rs 则完整实现了 read_copy/write_copy/get_related_copies(copy ID 由 CopyHistory 的哈希生成),并在注释中说明“返回全部复制历史以测试调用方正确忽略无关历史”——这与实现计划第一步“先在测试后端实现复制追踪”呼应。

八、实现计划

设计文档给出的大致实施路线:

  1. 在测试后端实现复制追踪支持;
  2. 实现 diff 算法并测试;
  3. 实现 merge 算法并测试;
  4. 实现 blame 算法并测试;
  5. 实现文件跟随(file-following)log 算法并测试;
  6. 把部分查询提取到提交后端 trait,使云后端(如 Google 后端)能提供基于数据库索引的实现;
  7. 在 Git 后端实现复制追踪(可能涉及惰性回填或提交后端 trait 的新抽象);
  8. 实现记录复制与解决复制冲突的 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->bazbar->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.rsstatus.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.rstree_merge.rsbackend.rs 与各后端实现已把该设计的大部分骨架落地为可运行代码,而 blame 追踪、Git 后端持久化复制图与复制冲突的正式 CLI 仍留待后续实现。理解这份设计,是深入掌握 jj 的 diff/merge/rebase 行为,乃至为自定义后端接入复制能力的基础。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
858
1.35 K
docsdocs
暂无描述
Markdown
899
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
923
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.83 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
532
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
524
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
393