Jujutsu(jj)架构深度解析:数据模型、双 crate 分层与存储无关 API 设计
【免费下载链接】jjA Git-compatible VCS that is both simple and powerful项目地址: https://gitcode.com/GitHub_Trending/jj/jj
导读
本文围绕 Jujutsu(jj)官方架构文档展开,系统梳理其核心设计:与 Git 相似但独立演化的提交数据模型、jj-lib与jj-cli双 crate 分层、可插拔的存储后端体系,以及 revset 四阶段求值流水线。读者读完可完整掌握 jj 仓库的内部结构、关键类型(Backend/Store/ReadonlyRepo/MutableRepo/Transaction等)的职责划分,并了解GitBackend、SimpleBackend、StackedTable等底层实现如何在源码中落地,为后续阅读源码或二次开发打下基础。
本文以 web/docs/src/content/docs/technical/architecture.md 为主干,结合 lib/src 与 cli/src 目录下的源码进行印证与扩充。
一、数据模型:与 Git 同源,但有自己的演进
Jujutsu 的提交数据模型与 Git 的对象模型(blob / tree / commit)结构相似但不相同。两者的核心差异在于“提交(Commit)如何被标识”:
- CommitId(提交 ID):由提交内容决定。提交被改写(rewrite)后,其
CommitId会随之变化。这与 Git 的 commit hash 思路一致。 - ChangeId(变更 ID):稳定标识符,随提交一起“进化”而不随内容变化。一次逻辑改动无论被 rebase、squash 多少次,其
ChangeId保持不变。这是 jj “演化(evolution)”语义的基石——用户可以从一条变更(change)的历史中看到同一逻辑改动的所有版本。
在 lib/src/backend.rs 中可以看到这两个 ID 类型的定义:
id_type!( /// Identifier for a [`Commit`] based on its content. When a commit is /// rewritten, its `CommitId` changes. pub CommitId { hex() } ); id_type!( /// Stable identifier for a [`Commit`]. Unlike the `CommitId`, the `ChangeId` /// follows the commit and is not updated when the commit is rewritten. pub ChangeId { reverse_hex() } );注意ChangeId使用reverse_hex()输出:它采用z-k的“反向十六进制”字符集而非0-9a-f,这样在日志输出中变更 ID 能使用 Git 不会出现的字符,避免混淆(见 lib/src/backend.rs)。
除提交外,数据模型还定义了TreeId、FileId、SymlinkId、CopyId等对象 ID(lib/src/backend.rs),以及Signature(作者/提交者与时间戳)等元数据结构。整个模型使用纯 Rust 数据结构表达,不绑定任何具体的磁盘格式——这正是下一节“存储无关 API”的基础。
二、库与 UI 分离:两个 Rust crate 的职责边界
jj可执行文件由两个 Rust crate组成(见 cli/Cargo.toml 与 lib/Cargo.toml):
| crate | 目录 | 职责 |
|---|---|---|
jj-lib | lib/src | 仓库状态、提交/树/文件读写、revset 求值、事务、并发控制等核心逻辑 |
jj-cli | cli/src | 命令行参数解析、用户交互、模板渲染、输出格式化 |
核心约束:jj-lib当前只被 CLI 使用,但设计上应当也能被 GUI / TUI / 多用户服务器复用。因此:
- 库 crate不得直接与终端交互,所有输入输出由 CLI crate 完成;
- 库 crate不能读取用户主目录的配置,也不能读取用户特定的环境变量(因为服务器场景没有这些);
- 少量例外存在,例如仓库格式自动升级时打印的提示消息。
在jj-lib的模块清单(lib/src/lib.rs)中可以看到这种边界:git、git_backend等底层存储模块位于库侧,而cli_util、ui、diff_util等所有与用户打交道的逻辑都在 CLI 侧。库侧代码还开启了#,保证核心逻辑全部为安全 Rust。
从源码结构看,库 crate 的 API 在“好用”上投入了大量设计,但像集合类型选用、导出符号范围这类“细节”尚未刻意打磨——这提示读者在引入jj-lib作为依赖时,API 仍可能小幅演进。
三、存储无关 API:为“换存储”而生
贯穿 jj 架构的一条总原则是:数据存在哪里,应当易于改变。默认落在本地磁盘,但也应能搬到云端(Jujutsu 诞生于 Google,天然面向这一目标)。为此,架构把仓库的每一类数据都抽象为独立的后端:
| 数据 | 由谁存储 | 磁盘位置(默认) |
|---|---|---|
| 提交、树、文件等 | 提交后端(commit backend) | .jj/repo/store/ |
| 操作(operation)与视图(view) | 操作后端(operation backend) | .jj/repo/op_store/ |
| 操作日志头部(op heads) | op heads 后端 | .jj/repo/op_heads/ |
| 提交索引 | 索引后端(index backend) | .jj/repo/index/ |
| 工作副本 | 工作副本后端(working copy backend) | .jj/下的工作副本状态 |
每个后端的选择在仓库创建时写入对应目录下的type文件:
.jj/repo/store/type.jj/repo/index/type.jj/repo/op_store/type.jj/repo/op_heads/type
这些接口都用普通 Rust 数据类型定义,不绑定特定格式,因此替换实现时无需改动上层逻辑。工作副本目前还没有独立的 trait,但其接口面很小,需要时可以很容易地抽象出来。
ReadonlyRepo::init()(lib/src/repo.rs)就是这套多后端装配过程的代码证据:初始化时依次创建store/、op_store/、op_heads/、index/、submodule_store/目录,并把各后端的名字分别写入各自的type文件,最后组装成RepoLoader。这也印证了“存储无关 API”并非文档中的理想,而是落地的实现事实。
四、库 crate 设计:核心类型全景
架构文档给出了一张类型关系图(见 web/docs/src/content/docs/technical/types.svg,由 Excalidraw 绘制,可在 Excalidraw 中右键 “Copy to Clipboard as SVG” 获取可编辑副本)。核心脉络是:
Workspace ──→ WorkingCopy ──→ TreeState │ └──→ RepoLoader ──→ ReadonlyRepo ──→ MutableRepo(经 Transaction 获取)下面逐一说明每个类型的职责与源码对应关系。
4.1 Backend:提交后端的统一接口
Backendtrait(lib/src/backend.rs)是每个提交后端都必须实现的接口,包括:
name():后端唯一名称,写入.jj/repo/store/type;commit_id_length()/change_id_length():ID 长度;root_commit_id()/root_change_id():根提交(唯一没有父提交的虚拟提交)的 ID;empty_tree_id():空树 ID;read_file/write_file、read_symlink/write_symlink、read_tree/write_tree、read_commit/write_commit:对象级读写;concurrency():后端能良好支撑的并发请求数估计(本地后端可设为 1,云后端可设为 100 量级),同时保证至少返回 1。
write_commit还接受可选的sign_with签名函数,供支持加密签名的后端将签名结果存回secure_sig字段。
由于还存在非提交类后端,文档指出Backend这个名字迟早应改名为CommitBackend——这是当前实现的一个已知命名欠账。
4.2 GitBackend:以 Git 仓库为存储的提交后端
GitBackend(lib/src/git_backend.rs)把提交、树、文件直接存进一个 Git 仓库,使用gitoxide读写提交与 refs(见 lib/src/git_backend.rs 的use gix::...导入)。
三个关键设计点:
- 防 GC 引用:为防止 Git GC 删除仍被操作日志可达的提交,
GitBackend会在refs/jj/keep/命名空间下为操作日志中的每个提交保存一个 ref。对应常量NO_GC_REF_NAMESPACE: &str = "refs/jj/keep/"定义在 lib/src/git_backend.rs。 - 非 Git 元数据外置:Jujutsu 模型有而 Git 模型没有的提交数据——变更 ID(change ID)与前驱(predecessors)列表——存放在
.jj/repo/store/extra/的一个StackedTable中。对没有该表数据的提交(即由git直接创建的提交),前驱取空列表,变更 ID 取“位反转的提交 ID”。相关常量(CHANGE_ID_COMMIT_HEADER: &str = "change-id"等)见 lib/src/git_backend.rs。 - ID 冲突即报错:因为直接使用 Git 对象 ID 作为提交 ID,两个仅变更 ID 不同的提交会得到相同提交 ID,写入第二个时会报错。
4.3 SimpleBackend:概念验证后端
SimpleBackend(lib/src/simple_backend.rs)只是一个概念验证实现:对象按哈希寻址,每个对象一个文件。文档明确说明其定位并非生产级,而是用来验证后端接口设计的完整性。
4.4 Store:对 Backend 的包装与缓存
Store(lib/src/store.rs)包装Backend,对外返回更方便使用的包装类型:
- 包装对象持有
Store自身的引用,因此可以写出commit.parents()这样无需显式传入 store 的调用; - 提供提交与树的缓存:
commit_cache(容量 100)与tree_cache(容量 1000,因为树对象多于提交且常被多个提交共享),见 lib/src/store.rs。
4.5 ReadonlyRepo:某个操作下的仓库快照
ReadonlyRepo(lib/src/repo.rs)表示仓库在某个特定操作(operation)时的状态,持有该操作关联的视图(view)对象。结构体字段包括loader(RepoLoader)、operation、只读索引index、变更 ID 索引change_id_index,以及视图view。
仓库本身不知道工作副本在磁盘的哪里;它只通过视图对象知道每个 workspace 当前应处于哪个工作副本提交。
4.6 MutableRepo:可修改的仓库
MutableRepo(lib/src/repo.rs)是ReadonlyRepo的可变版本:持有对基础ReadonlyRepo的引用,但拥有自己的视图对象副本,允许调用方修改。
4.7 Transaction:把变更发布到操作日志
Transaction(lib/src/transaction.rs)持有MutableRepo与即将写入操作日志的元数据:
mut_repo:事务范围内的内存变更;parent_ops:父操作列表;op_metadata:操作元数据;end_time:可选的结束时间。
提交事务时:MutableRepo变成操作日志中磁盘上的视图对象,Transaction对象本身变成操作对象;内存中Transaction::commit()返回一个新的ReadonlyRepo。源码注释(lib/src/transaction.rs)将其类比为:事务之于仓库,就像提交之于内容、树之于内容快照——三者同构地表达了“变更”与“变更后的状态”。
Transaction::commit()可能返回TransactionCommitError,涵盖索引、索引存储、op heads 存储、op 存储四类错误(lib/src/transaction.rs)。
4.8 RepoLoader:指向.jj/repo/的指针
RepoLoader(lib/src/repo.rs)表示一个操作未指定的仓库,可视为指向.jj/repo/目录的指针。给定操作 ID,它可以创建对应的ReadonlyRepo。
4.9 TreeState:工作副本的文件状态机
TreeState(lib/src/local_working_copy.rs)表示工作副本中文件的状态:
- 为每个被跟踪文件记录mtime 与 size;
- 知道工作副本当前对应的
TreeId; snapshot():利用记录的 mtime/size 检测工作副本变化,若有变化返回新的TreeId;checkout():把磁盘文件更新到请求的TreeId;- 支持稀疏检出(sparse checkout):事实上所有工作副本都是稀疏的,只是大多数情况下跟踪整个仓库。
源码中可以看到其内部还维护sparse_patterns(路径前缀列表)、own_mtime、watchman_clock(配置 Watchman 文件监控时的时钟值)等字段,默认稀疏模式为vec。
4.10 WorkingCopy 与 Workspace
WorkingCopytrait(lib/src/working_copy.rs)在TreeState之上,还知道自己的WorkspaceName与最近更新时的操作。Workspace(lib/src/workspace.rs)表示仓库 + 工作副本的组合,类似 Git 的 worktree 概念。
文档指出一个重要的“愿望与现实的偏差”:当前操作下的仓库视图决定每个 workspace应该处于哪个工作副本提交,WorkingCopy决定工作副本实际是什么。当工作副本提交被其他 workspace 改变(或更新进程崩溃)时,工作副本会变 stale(过期)。
4.11 git 模块:高于 GitBackend 的互操作层
git模块(lib/src/git.rs)提供与 Git 仓库互操作的功能,层次高于GitBackend:
GitBackend受Backendtrait 约束,只能做对象读写;git模块专门针对 Git 后端仓库,负责从 Git 仓库导入 refs、向 Git 仓库导出 refs,以及向远端推送 / 从远端拉取。
4.12 Revsets:四阶段求值流水线
用户提供的 revset 表达式字符串要经过四阶段求值(lib/src/revset.rs 与 lib/src/revset_parser.rs):
- 解析:把表达式解析成
RevsetExpression,接近 AST; - 解析符号:把
tags()这类符号/函数解析为具体提交;此阶段后仍是RevsetExpression,但不再含CommitRef变体; - 解析可见性:解析
visible_heads()与all(),产出ResolvedExpression; - 求值:把
ResolvedExpression求值为Revset。
关键点在于第 4 步由Index::evaluate_revset()执行,允许Revset实现利用特定索引实现(如 lib/src/default_index 目录下的默认索引)的特性;而前三步与索引实现无关,因此可以被任何索引后端复用。
4.13 StackedTable:自研的磁盘键值格式
StackedTable(实际是ReadonlyTable与MutableTable,见 lib/src/stacked_table.rs 与 lib/src/stacked_table.rs)是一种简单的磁盘键值格式:
- 键定长、值变长,按键排序;
- 采用自研格式的原因:需要无锁并发(详见 web/docs/src/content/docs/technical/concurrency.md),而现有键值库无法满足;
- 文件格式:一张查找表(按序排列的键列表,每个键后跟着对应值在拼接值区中的偏移)+ 拼接的值区;
- 父表链:查表时当前表找不到就查父表;表从不原地更新——若新条目数少于父表条目数的一半,则新建一张指向父表的新表;否则把父表条目与新条目复制进一张以祖父表为父的新表;递归执行,保证父表规模至少是子表的 2 倍,摊还后插入与查找均为O(log N);
- 尚无不可达表的垃圾回收;
- 表以哈希命名,另用目录保存指向当前叶表的指针(与操作日志的存储方式相同,见 web/docs/src/content/docs/technical/concurrency.md#storage)。
TableStore(lib/src/stacked_table.rs)还提供gc()(lib/src/stacked_table.rs)用于在保持叶表的前提下清理旧表。
五、CLI crate 设计要点
5.1 模板(Templates)
模板概念源自 Mercurial,但语法不同:
- 顶层表达式本身就是模板表达式,而非像 Mercurial 那样是一个字符串;
- 没有字符串插值(Mercurial 中可写
"Commit ID: {node}",jj 不支持这种写法)。
模板的解析与渲染实现在 CLI 侧,涉及 cli/src/template_parser.rs(基于 pest 语法,语法文件 cli/src/template.pest)与 cli/src/commit_templater.rs 等文件。
5.2 差异编辑(Diff-editing)
jj diffedit等命令的差异编辑机制很巧妙:
- 创建两个极其稀疏的工作副本,只包含希望用户编辑的文件;
- 让用户编辑差异的右侧;
- 直接对该工作副本做 snapshot,得到新树。
这样就把“编辑差异”问题转化为普通的文件编辑 + 快照问题,复用了TreeState::snapshot()的既有能力。实现位于 CLI 的 diff 编辑流程中,依赖库侧的TreeState快照机制。
六、从架构到实践:如何继续深入仓库
如果你希望验证或深入学习上述设计,建议按以下路径阅读源码:
- 对象模型:lib/src/backend.rs(
CommitId/ChangeId定义与Backendtrait); - 多后端装配:lib/src/repo.rs(
ReadonlyRepo::init()展示各后端type文件的写入); - Git 互操作:lib/src/git_backend.rs(防 GC refs、
extra/元数据表)与 lib/src/git.rs; - 事务与并发:lib/src/transaction.rs 与 web/docs/src/content/docs/technical/concurrency.md;
- 工作副本:lib/src/local_working_copy.rs(
TreeState的 snapshot/checkout/稀疏模式); - revset 求值:lib/src/revset.rs 与 lib/src/default_index/revset_engine.rs;
- 磁盘格式:lib/src/stacked_table.rs(
ReadonlyTable/MutableTable/TableStore)。
另外,docs/technical/architecture.md 是与本文同源的仓库根目录版本,cli/docs/technical/architecture.md 为旧版镜像,三者主题一致,可交叉对照。
结语
Jujutsu 的架构本质上是**“把 Git 的好用之处保留下来,把 Git 的模型扩展成可演化、可替换存储的形态”**:通过CommitId/ChangeId双 ID 模型支撑演化语义,通过Backend/OpStore/IndexStore等后端抽象实现存储无关,通过事务 + 操作日志实现并发安全,再以jj-lib/jj-cli分离保证核心逻辑可被 GUI/TUI/服务器复用。理解这张架构蓝图,是读懂 jj 源码、乃至贡献代码的第一把钥匙。
【免费下载链接】jjA Git-compatible VCS that is both simple and powerful项目地址: https://gitcode.com/GitHub_Trending/jj/jj
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考