Jujutsu 0.44 深度拆解:当版本控制决定把「暂存区」「分支」「冲突」一起删掉——从双 ID 模型到无锁 Operation Log 的架构手术
2026 年 8 月 5 日,Jujutsu 发布 0.44.0。这个版本把 tag 的 fetch/push 追踪机制正式稳定化,同时给
jj run补上了--passthrough和确定性执行顺序,给jj absorb加上了交互式选择。听起来都是小修小补,但如果你顺着这些改动往下挖,会发现它们背后站着一套和 Git 完全不同的数据模型——一套敢把暂存区、分支、冲突解决这三样"天经地义"的东西一起删掉的模型。这篇文章不打算写成"jj 十分钟入门"。市面上这种文章已经够多了。我想拆的是:它凭什么敢这么设计,代价是什么,以及哪些场景你现在就该迁,哪些场景你千万别碰。
一、背景:Git 的三个包袱,其实是三个"实现细节泄漏"
先说清楚一件事:本文不是来黑 Git 的。Git 赢下了这场战争,而且赢得理直气壮。但一个用了二十年的工具,它的心智负担来自哪里,我们应该能说得比"Git 太难了"更精确一点。
我的判断是:Git 的复杂度,大部分来自三个实现细节泄漏成了用户概念。
1.1 包袱一:index(暂存区)是性能优化,却成了用户必修课
.git/index 最初的动机非常朴素——1990 年代末的机械硬盘上,遍历整个工作树做 stat() 太慢,所以缓存一份 (path, mtime, size, mode, blob_id) 的排序表。它是缓存。
但因为 git add 直接暴露了这层缓存,index 顺理成章变成了"暂存区"这个用户概念。于是你必须理解三态:工作区 / 暂存区 / HEAD。于是有了:
git add -p挑 hunkgit reset HEAD <file>退回暂存git checkout -- <file>丢弃工作区(后来才拆成git restore)git stash又搞出第四个隐藏状态
git stash 尤其能说明问题:它本质上是"把当前状态偷偷做成两个 commit,塞到 refs/stash 这个 reflog 里"。既然最终还是 commit,为什么不一开始就是 commit?
1.2 包袱二:branch 是一个可变指针,而不是一个对象
Git 的 branch 就是 .git/refs/heads/<name> 里的一行 40 字节 SHA。它没有身份,没有历史(reflog 是本地的、会过期的、非分布式的旁路)。
这带来一个后果:"这个 commit 属于哪个分支"这个问题在 Git 里没有答案。 你只能反过来问"哪些分支能到达这个 commit"(git branch --contains)。rebase 之后旧 commit 立刻变成孤儿,只能靠 reflog 捞。
1.3 包袱三:冲突是一种"进程状态",不是一种"数据状态"
这是最大的那个包袱。
在 Git 里,冲突不存在于对象数据库中。冲突存在于 index 的 stage 1/2/3 槽位 加上 .git/MERGE_HEAD、.git/rebase-merge/ 这些临时目录里。换句话说,冲突是一个中断的进程状态。
后果非常具体:
# 你在 rebase 一个 12 个 commit 的栈,第 4 个冲突了
git rebase main
# CONFLICT (content): Merge conflict in src/router.rs
# 现在你处于 "detached HEAD, rebase in progress" 状态
# 你不能:
# - 切到别的分支去看一眼参考实现
# - 跑一次完整测试(工作区里有 <<<<<<< 标记)
# - 把这次 rebase 的中间结果推给同事看
# - 明天再继续(除非你不动这个 worktree)
# 你只能:继续、跳过、或者 --abort 全部回滚
git rerere 的存在本身就是这个设计的补丁:因为冲突解决结果没地方存,所以只好单独搞一个"冲突解决方案缓存数据库"。
Jujutsu 的答案很暴力:这三个包袱,一个都不留。
二、核心概念一:工作副本就是一个 commit
jj 最激进也最容易被低估的设计:working copy 本身是一个真实的 commit,通常显示为 @。
这不是比喻。它是 commit store 里一个真实存在、有 ID、有父节点、可以被 jj log 看到、可以被 push 的 commit。
2.1 snapshot 机制
每次运行任何 jj 命令,第一步都是 snapshot:扫描工作目录,把变更写进 @ 这个 commit,生成新的 tree ID。
$ jj status
Working copy changes:
M src/router.rs
A src/middleware/auth.rs
Working copy (@) : kntqzsrp 8f2c1a4d (no description set)
Parent commit (@-): mzvwutvl 3b7e9f01 main | feat: add rate limiter
注意 kntqzsrp 和 8f2c1a4d 这两个 ID——后面会讲,它们是两个不同维度的东西。
关键在于:你从来不需要"保存"你的工作。工作区的内容天然就在版本控制里。这句话的推论是一连串的:
| Git 操作 | jj 里发生了什么 |
|---|---|
git add | 不存在。文件默认被跟踪,snapshot 自动完成 |
git stash | 不存在。jj new 一下,旧的 @ 自动留在 DAG 里 |
git commit --amend | jj describe 改描述;内容改动直接改文件就行 |
git checkout -- f | jj restore f (从父 commit 恢复) |
| "未提交的改动会被覆盖" | 不可能发生,改动已经是 commit 了 |
2.2 实际工作流的变化
Git 的心智模型是"攒够了再提交"。jj 的心智模型是"先占位,边做边整理"。
# 开始一个新工作,直接创建空 commit 并切进去
jj new main -m "feat: 用户会话迁移到 Redis"
# 直接改文件。不需要 add,不需要 commit。
vim src/session.rs
# 想起来要看一眼另一个分支的实现?直接切,改动不会丢
jj new other-work
# ... 看完了,回去
jj edit @- # 或者 jj edit <change-id>
# 觉得这个 commit 太大了,拆开
jj split -i # 交互式挑 hunk,拆成两个 commit
jj split 的实现细节很有意思。按官方架构文档的说法,diff-editing 的做法是:创建两个极度稀疏(sparse)的工作副本,只包含要编辑的文件,让你编辑 diff 的右半边,然后 snapshot 这个工作副本得到新 tree。 也就是说,交互式编辑复用的还是"工作副本即 commit"这一套机制,没有额外的特殊路径。整个系统的正交性非常高。
2.3 代价:snapshot 不是免费的
诚实地说:每条命令都要扫工作树,这在大仓库上是有成本的。
jj 的应对是 TreeState——记录每个文件的 mtime 和 size,跟 Git index 干的是一样的事。但如果你的仓库有 20 万个文件,纯 stat() 遍历也要几百毫秒。
生产环境必须开 watchman:
# ~/.config/jj/config.toml
[core]
fsmonitor = "watchman"
[core.watchman]
# 让 watchman 主动维护快照触发器,进一步降低冷启动成本
register-snapshot-trigger = true
另外强烈建议设置文件大小上限,避免手滑把 3GB 的模型权重 snapshot 进去:
[snapshot]
max-new-file-size = "10MiB" # 超过则报错并提示,而不是默默吞进去
auto-track = 'none()' # 极端场景:新文件默认不跟踪,需要 jj file track
auto-track = 'none()' 这一招在 monorepo + 大量构建产物的场景下很救命,代价是要手动 jj file track。多数团队用默认值 + 一份好的 .gitignore 就够了。
三、核心概念二:双 ID 模型——change ID 是 jj 的第一性原理
如果只让我保留 jj 的一个设计,我选这个。
3.1 两个 ID 分别是什么
每个 commit 有两个标识符:
- commit ID:内容哈希,等价于 Git 的 SHA。改一个字节就变。
- change ID:一个 128 位的随机标识符,在 rewrite(amend / rebase / describe / squash)时保持不变。
渲染上做了刻意区分:change ID 用一套只含字母的 reverse-hex 字母表(k–z)显示,commit ID 用常规的 0-9a-f。所以 kntqzsrp 一眼就知道是 change ID,8f2c1a4d 一眼就知道是 commit ID。你永远不会搞混。
3.2 这解决了什么问题
用 Git 描述一下痛点:
# Git:你有一个 5 commit 的 PR,reviewer 让你改第 2 个 commit
git rebase -i HEAD~5
# 标记第 2 个为 edit,改完 --continue
# 现在 commit 2/3/4/5 的 SHA 全变了
# 你之前记在便签上的 "commit abc123 是加索引那个" 全部作废
# CI 上的构建产物、你贴给同事的 permalink,全部失效
jj 里,这 5 个 change 的 change ID 从头到尾没变过。
# jj:直接编辑栈中间的那个 change
jj edit rlvkpnrz # change ID,稳定不变
vim src/index.sql
# 完事。后代自动 rebase,无需任何额外命令。
后代自动 rebase 这件事需要重点讲。在 Git 里,改了栈中间的 commit,你必须手动 rebase 后续所有 commit。在 jj 里,任何 rewrite 操作都会自动把所有后代 rebase 上去,这是 MutableRepo 层的内建行为,不是一个可选命令。
这带来一个非常爽的工作流——堆叠式开发(stacked development):
jj new main -m "refactor: 抽出 SessionStore trait"
# ... 写代码
jj new -m "feat: Redis 实现"
# ... 写代码
jj new -m "feat: 切换默认实现 + 灰度开关"
# ... 写代码
jj log
# @ wqnwkozp feat: 切换默认实现 + 灰度开关
# ○ puvunltp feat: Redis 实现
# ○ rlvkpnrz refactor: 抽出 SessionStore trait
# ◆ mzvwutvl main | feat: add rate limiter
# reviewer 说第一个 commit 的 trait 签名要改
jj edit rlvkpnrz
vim src/store.rs
jj edit @+ # 或者直接 jj new,回到栈顶
# puvunltp 和 wqnwkozp 已经自动 rebase 完毕
对比 Git 需要的:git rebase -i → 标记 edit → 改 → git add → git rebase --continue → 可能中途冲突 → 处理 → continue……
3.3 change ID 是怎么存的(Git 后端)
这是个工程上很聪明的妥协。jj 用 Git 做后端时,commit ID 直接复用 Git Object ID,但 change ID 在 Git 对象模型里没有位置。
架构文档给的方案是:存在 .jj/repo/store/extra/ 下的一个 StackedTable 里,同时存的还有 predecessors 列表。
对于那些由 git 命令直接创建、不在这张表里的 commit,jj 用一个优雅的 fallback:把 commit ID 按位反转(bit-reversed)当作 change ID。这样 colocated 仓库里 git 和 jj 混着用也不会崩,而且是确定性的——同一个 Git commit 在任何机器上算出的 change ID 都一样。
StackedTable 本身也值得一提。它是 jj 自己实现的一个磁盘 KV 格式:定长 key、变长 value、按 key 排序,文件由"排序 key 表 + 拼接的 value 区"组成。它支持父表链式查找——查不到就往父表找。写入时从不原地修改:新条目少于父表一半就新建一个带父指针的小表,否则把父表内容 copy 进来、指向祖父表。递归下去保证父表至少是子表的 2 倍大,从而得到 O(log N) 的摊还插入和查找。
为什么不用 RocksDB / LMDB?因为 jj 要的是无锁并发(下一节讲),现成的 KV 存储都做不到这一点。这个取舍很能说明 jj 的设计优先级。
顺便,架构文档里也老实记了一笔:这些表目前没有 GC。长期使用会累积垃圾。这不是致命问题,但你该知道。
3.4 一个必须注意的约束
因为 jj 用 Git Object ID 当 commit ID,所以两个只有 change ID 不同、内容完全相同的 commit 会算出同一个 commit ID。jj 在写第二个的时候会直接报错。
实践中你会在什么时候撞上?jj duplicate 一个空 commit,或者写脚本批量生成内容相同的 commit。遇到了不要慌,改一下 description 或加个字节即可。
四、核心概念三:一等公民冲突——把冲突变成代数
这是 jj 最有理论美感的部分。
4.1 数据模型:commit 可以指向奇数个 tree
正常 commit 指向一个 tree。冲突 commit 指向一个有序的 tree 列表,个数永远是奇数。
规则是:第一个 tree 是起点,后续每一对 tree 表示要施加到起点上的 diff。
- trees
[A, B, C]→ 内容是A + (C - B),也就是以 B 为 base、A 和 C 的三方合并 - trees
[A, B, C, D, E]→ 内容是A + (C - B) + (E - D)
结果 tree 是按需计算的。而且计算是分层短路的:checkout 时只需要合并跟上一个 tree 有差异的部分;列出冲突路径时,如果某个子树只有一侧改动,靠 tree id 就能平凡解决,根本不用递归进去。文件级别同理,先比 blob id,比不出来才递归到 hunk。
4.2 冲突化简:为什么 jj 不会产生"递归冲突"
这才是精髓。既然三方合并能写成 A+(C-B),那么当某一项本身就是冲突时,直接把它的表达式代入,然后消去互相抵消的项。对应实现是 merge.rs 里的 Merge::flatten() 和 Merge::simplify()。
官方文档给的例子非常好,我把它展开讲:
场景一:把冲突的 commit 继续 rebase
commit B 基于 A,rebase 到 C,产生冲突:C + (B - A)
用户懒得解决,继续 rebase 到 D:
D + ((C + (B - A)) - C)
展开后 C 和 -C 抵消:
D + (B - A)
这就是一个干干净净的三方合并,D 和 B 以 A 为 base,C 消失得无影无踪。
这意味着什么?意味着你可以把一堆带冲突的旧 commit 反复 rebase 到最新的主干上,永远不会积累出那种越滚越恶心的递归冲突。在 Git 里,你 rebase 一个曾经解决过冲突的 commit 栈,那种"为什么同一个冲突又来了"的绝望感,在 jj 里从数学上就不成立。
场景二:revert 一个冲突的 commit
E = C + (B - A),是 C 之上的冲突状态
revert 意味着应用反向 diff -(E - C):
E + (C - E) = (C + (B - A)) + (C - (C + (B - A)))
化简 → C
结果是干净的 C,没有冲突。符合直觉,而且是代数推出来的,不是特判出来的。
4.3 same-change rule 与它的代价
当冲突的各方做了完全相同的修改时,jj 默认自动认为已解决,取那个值。这叫 same-change rule,Git 和 Mercurial 也这么干(Darcs 不这么干,它认为这是冲突)。
文档里对这个决策的自我批评相当坦率:这个自动解决在冲突代数意义上是有损的。具体后果是:如果你把一个 commit rebase 到一个包含了相同改动(或其子集)的 commit 上,然后再 rebase 回来,改动会丢失(对应 issue #6369)。
jj 还是选择了这么做,理由是"绝大多数情况下更符合用户直觉"。这是一个典型的正确性 vs 人体工学的权衡,值得记住——因为这意味着 jj 的冲突代数并不是完全封闭的群运算,存在一个刻意打开的口子。
4.4 实战:冲突不再阻塞你
jj rebase -s feature-stack -d main
# Rebased 6 commits
# New conflicts appeared in these commits:
# puvunltp 2f8a01bc (conflict) feat: Redis 实现
# 注意:命令已经成功返回了。你现在不在任何"rebase in progress"状态。
jj log
# @ wqnwkozp feat: 切换默认实现 + 灰度开关
# ○ puvunltp conflict feat: Redis 实现
# ○ rlvkpnrz refactor: 抽出 SessionStore trait
# ◆ qpvuntsm main chore: bump deps
# 你可以:现在不管它,先去干别的
jj new main -m "hotfix: 修一个线上问题"
# ... 修完,推上去
jj git push -c @
# 三天后回来解决
jj edit puvunltp
jj resolve # 调用配置的 merge tool
# 或者直接编辑文件里的冲突标记,然后什么都不用做——snapshot 自动完成
# 解决后,后代(wqnwkozp)自动更新,冲突自动消失
0.44 还给 jj git push 加了 --allow-conflicts——你甚至可以把带冲突的 commit 推到远端。这在"我卡住了,帮我看看"的协作场景下极其有用。当然,别推到 main 上。
配套的 revset 函数是 conflicts():
jj log -r 'conflicts()' # 所有带冲突的 commit
jj log -r 'conflicts() & mine() & ~::trunk()' # 我的、还没进主干的冲突
五、核心概念四:Operation Log——把"仓库状态"也纳入版本控制
到这里为止讲的都是 commit 层。Operation Log 是 jj 在 commit 层之上又叠的一层版本控制。
5.1 数据模型:一个和 commit DAG 同构的第二 DAG
架构文档把这个类比说得很直白:
| commit DAG | operation DAG |
|---|---|
| commit 对象 | operation 对象 |
| tree 对象 | view 对象 |
| commit → tree 指针 | operation → view 指针 |
| commit → parent commits | operation → parent operations |
view 对象包含了整个仓库的可见状态:可见 head commits 的集合、所有 bookmark、所有 tag、以及每个 workspace 各自的工作副本 commit。
operation 对象指向一个 view,指向父 operation,外加元数据(谁、什么时候、执行了什么命令)。
正常情况下 operation log 是线性的。只有在并发操作时才会分叉。
5.2 无锁并发:为什么不用 lock file
绝大多数 DVCS 用 lock file 处理本地并发。jj 明确拒绝了,理由有两条,都很实在:
理由一:lock file 在分布式文件系统上不工作。 NFS、Dropbox、rsync 同步的目录里,文件锁语义要么不可靠要么根本没有。Mercurial 仓库在这类环境下频繁损坏是众所周知的。
理由二:锁的粒度是个复杂度陷阱。 最简单的做法是命令一开始就拿粗粒度锁,代价是完全不冲突的操作也要互相等。想优化就得细粒度锁或晚加锁,然后你就得处理"加锁前的假设在加锁后是否还成立"——这是并发编程里最容易出 bug 的地方。
jj 的做法是:接受并发一定会发生,然后把冲突暴露给用户——就像其他 DVCS 处理远端冲突那样。
具体机制:
- 命令启动,加载最新 operation 对应的 repo。因为 view 对象完整定义了仓库状态,这条命令在整个执行期间看到的是一个一致的快照,其他进程的改动完全不可见。
- 命令完成,以启动时的 operation 为 parent 写入新 operation。这一步不可能失败(除非磁盘挂了)。
- 有没有并发分叉,留给下一条命令去发现。
第 3 点是关键的架构简化。反正并发操作可能通过分布式文件系统"从天而降",你无论如何都得有处理多 head 的能力,那不如统一走这条路径。
5.3 存储实现:用文件名做原子写
这个技巧很漂亮:
- operation 对象和 view 对象都是内容寻址存储,和 Git 对象一样,天然可以无锁写。
- 那怎么记录"当前 head 是哪个 operation"?用一个目录,里面每个文件的文件名就是 operation ID,文件内容为空。
- 提交一个 operation:先创建指向新 op 的文件,再删除指向旧 op 的文件。写新文件那一刻,操作就可见了。
- 如果旧文件没删干净?后续的读取者会顺手清理。
这套方案保证了事务原子性,而且完全不需要锁。用文件系统的目录项创建作为原子发布点——这是 jj 整个设计里我最喜欢的一个细节。
5.4 分叉合并:view 的三方合并
如果加载时发现 operation log 有多个 head,jj 对 view 对象做三方合并(基于共同祖先;超过两个 head 就做多次)。
合并冲突记录在结果 view 里。比如 bookmark main 在一个操作里从 A 移到 B,在并发操作里移到 C,那么它被记录成"从 A 移到 B 或 C"(op_store.proto 里的 RefTarget)。
在 jj log 里,冲突的 bookmark 会在名字后面显示 ??,jj status 也会提示。你可以继续正常使用仓库,只有真正需要解析这个 bookmark 的命令才会报错(比如 jj new main 会告诉你 main 解析到了多个 revision)。
这个设计的实际收益:你可以把 jj 仓库放在 Dropbox / iCloud / NFS 上,在多台机器上同时改,让同步工具去合并。 提交、bookmark 移动都不会丢,冲突会以冲突的形式呈现出来。
不过文档也标注了当前的已知缺陷,我原样转述,别踩:
- Git 后端并非完全无锁,存在仓库损坏的可能(issue #2193)。恢复方式是
jj debug reindex。 - 这种用法测试覆盖不充分,尤其是 colocated 仓库场景。commit 内容应该是安全的,但跨机器并发修改可能丢失某些 bookmark 指针。
- 关键区别:jj 里丢 bookmark 指针不等于丢 commit(Git 里丢 branch 就基本等于丢 commit 了)。
5.5 Operation Log 的日常用法:真正的全局 undo
jj op log
# @ b8f2c1a4 me@host 3 minutes ago, lasted 82ms
# │ rebase commit 2f8a01bc and descendants
# ○ 7d3e9012 me@host 5 minutes ago, lasted 1.2s
# │ git fetch
# ○ 1a2b3c4d me@host 12 minutes ago, lasted 45ms
# │ squash commits into 4f8e2a91
# 手滑了,撤销最近一次操作
jj undo
# 撤销任意一次历史操作(不一定是最近的)
jj undo 7d3e9012
# 把整个仓库状态恢复到某个操作时的样子
jj op restore 1a2b3c4d
# 在某个历史操作的视角下运行命令(会产生 op log 分叉,可用于模拟并发)
jj --at-operation 7d3e9012 log
请注意这里的语义强度:jj undo 能撤销的是"操作",不是"提交"。 jj git fetch 可以撤销。jj abandon 可以撤销。jj rebase 整个可以一键撤销。误删的 commit 可以找回。
Git 里对应的能力散落在 git reflog + git reset + ORIG_HEAD + 手动 git update-ref 里,而且 reflog 只覆盖 ref 移动,不覆盖 index、不覆盖 stash、有过期时间、不参与分发。
这是我认为 jj 在工程可靠性上相对 Git 最大的一步。它把"我干了什么"这件事本身变成了一等公民数据。
六、架构分析:五个可插拔后端与 revset 的四阶段求值
6.1 一切皆后端
jj 的一个明确设计目标是"换存储位置应该很容易"——本地磁盘是默认,但要能把存储搬到云上(Google 内部的需求)。
于是拆出了五个后端:
| 后端 | 存什么 | 类型声明文件 |
|---|---|---|
| commit backend | commit / tree / file | .jj/repo/store/type |
| operation backend | operation / view | .jj/repo/op_store/type |
| op heads backend | operation log 的 head | .jj/repo/op_heads/type |
| index backend | commit 索引 | .jj/repo/index/type |
| working copy backend | 工作副本 | (尚无独立 trait,接口很小) |
接口全部用纯 Rust 数据类型定义,不绑死任何格式。
树内的 commit backend 有两个:
- GitBackend:commit 存在 Git 仓库里,用 gitoxide 读写 commit 和 ref。为了防止 Git GC 干掉 operation log 里仍可达的 commit,它在
refs/jj/keep/命名空间下给每个 op log 里的 commit 存一个 ref。 - SimpleBackend:概念验证,一个对象一个文件,按哈希寻址。
refs/jj/keep/ 这个细节值得记住——如果你在 colocated 仓库里跑 git gc 发现对象没被清掉,或者 git count-objects 数字大得离谱,原因就在这里。
6.2 类型层次:Transaction 的语义
Workspace ──┬─→ WorkingCopy ─→ TreeState
└─→ RepoLoader ─→ ReadonlyRepo ─→ (view @ operation)
│
Transaction ─→ MutableRepo
│
commit() → 新的 ReadonlyRepo
+ 磁盘上的 operation & view
几个要点:
ReadonlyRepo代表某一个特定 operation 下的仓库状态。它持有那个 operation 的 view。- 仓库不知道任何工作副本在磁盘上的位置。它只通过 view 知道"每个 workspace 的工作副本 commit 应该是哪个"。
Transaction提交时,MutableRepo变成磁盘上的 view 对象,Transaction本身变成 operation 对象。Workspace= repo + working copy,等价于 Git 的 worktree。工作副本可能变陈旧(stale)——比如另一个 workspace 改了工作副本 commit,或者更新工作副本的进程崩了。这时jj workspace update-stale修复。
jj-lib 和 jj-cli 的拆分是硬性的:库层不做任何终端 I/O,也不读用户 home 目录的配置或用户相关环境变量(因为它要能跑在服务端)。所有 I/O 归 CLI 层。这也是为什么社区能相对容易地做出各种 TUI(如 jjui)和编辑器集成。
6.3 Revset:一门真正的查询语言
Revset 语法抄自 Mercurial,但 jj 的实现有自己的四阶段求值:
- 解析成
RevsetExpression(接近 AST) - 符号解析:把
tags()、bookmark 名之类解析成具体 commit。这一步之后表达式里不再有CommitRef - 可见性解析:解析
visible_heads()和all(),产出ResolvedExpression - 求值成
Revset
第 4 步由 Index::evaluate_revset() 完成——这是故意的:把求值下沉到 index 实现,自定义 index 后端就能用自己的数据结构加速。前三步与 index 实现无关。这个分层对"把索引搬到服务端"的目标是必要的。
日常用法:
# 基础操作符
jj log -r '@' # 工作副本
jj log -r '@-' # 父
jj log -r '@+' # 子
jj log -r '::@' # 祖先(含自己)
jj log -r 'main..@' # main 之后到 @ 的所有 commit
jj log -r 'main::@' # main 到 @ 之间的 DAG 范围
# 函数
jj log -r 'mine() & ~::trunk()' # 我的、还没进主干的
jj log -r 'description(glob:"fix*")' # 描述匹配
jj log -r 'author(exact:"me@corp.com")' # 作者精确匹配
jj log -r 'empty() & mine()' # 我的空 commit(该清理了)
jj log -r 'files("src/router.rs")' # 碰过某文件的
jj log -r 'fork_point(a | b)' # 分叉点
jj log -r 'merge_point(a | b)' # 0.44 新增:汇合点
jj log -r 'forks()' # 0.43 新增:所有子节点数 > 1 的 commit
jj log -r 'heads(::@ & mine())'
jj log -r 'latest(mine(), 10)'
merge_point() 是 0.44 新增的,和 fork_point() 对称。这两个函数配合起来,能非常精确地描述"某两条线从哪儿分开、在哪儿合上",写 CI 脚本时很省事。
自定义别名放配置里,团队共享:
[revset-aliases]
# 定义什么叫"不可变"——防止误改已推送的历史
"immutable_heads()" = "builtin_immutable_heads() | remote_bookmarks() | tags()"
# 我的、活跃的、未合并的工作
"wip" = "mine() & ~::trunk() & ~empty()"
"stack" = "trunk()..@"
"review(x)" = "trunk()..x"
[revsets]
# 0.44 新增 builtin_log():可以复用内建默认 revset,而不用整段抄过来
log = "builtin_log() | wip"
builtin_log() 这个别名是 0.44 的一个小而实用的改进。以前你想在默认 log 视图上"加一点东西",得把内建那一长串表达式整个复制过来,之后 jj 升级了你的配置也不会跟着更新。现在可以直接组合。
immutable_heads() 强烈建议配。配好之后,任何试图 rewrite 已推送 commit 的操作都会被直接拒绝——这是 jj 版的"防止 force push 事故"。
七、0.44.0 逐条拆解:哪些是真改进,哪些会咬你
7.1 Release Highlight:tag 追踪机制稳定化
这是 0.44 的主线改动,也是唯一一个会实际打断现有工作流的。
变了什么:
jj git fetch现在用和 bookmark 完全一样的方式处理 tag。tag 被 fetch 成<name>@<remote>形式,同名本地 tag 自动追踪它。- tag 可以像 bookmark 一样 track / untrack,新增
jj tag track/jj tag untrack。 - 被追踪的 tag 默认会被 push。
- 在已有仓库里第一次跑
jj git fetch,会重新 fetch 所有 tag 以初始化追踪状态。
Breaking:
- Git 的
tagOpt配置不再被尊重。 要关掉 tag fetch,改用 jj 自己的配置:
[remotes.origin]
fetch-tags = '~*' # 不 fetch 任何 tag
# 或者只 fetch 特定模式
# fetch-tags = 'glob:"v*"'
jj git clone --fetch-tags=all|none|included被移除,改用--tag=PATTERN。jj git push --all现在除了 bookmark 还会推所有 tag。
我的判断: 前两条是纯改进——tag 和 bookmark 走同一套追踪模型,概念上更正交。第三条要小心:如果你有 CI 脚本用 jj git push --all,升级前务必检查一遍,否则可能意外把一堆本地 tag 推上去。这是 0.44 最容易出事故的地方。
另外,如果你的仓库有几万个 tag(某些老仓库真的有),升级后第一次 jj git fetch 会明显变慢,因为要重新初始化全部追踪状态。跑一次就好,不必惊慌。
7.2 jj run:0.43 引入,0.44 补齐
jj run 是 0.43 的招牌功能,思路很直接:对一组 change,每个都用独立的私有工作副本跑一遍命令;命令可以修改工作副本,改动和冲突会被正确传播回去。
# 检查栈里每个 commit 是否都能编译(防止"中间 commit 编译不过")
jj run -r 'trunk()..@' -- cargo check --all-features
# 让 cargo fix 修复栈里每一个 commit
jj run -r 'trunk()..@' -- cargo fix --allow-dirty
# 批量格式化
jj run -r 'mine() & ~::trunk()' -- cargo fmt
这解决了一个 Git 里非常烦的问题:git rebase -x 'cargo test' 能跑测试,但如果要修改 commit,你得自己写 --exec 'cmd && git commit --amend --no-edit',而且中途失败状态很难恢复。
0.44 给它补了四个关键能力:
| 改进 | 意义 |
|---|---|
默认从旧到新处理,且启动顺序有保证——即使 --jobs > 1,每个 revision 也只在前一个已经开始执行之后才启动 | 让并行执行的行为可预测。构建缓存命中率会好很多,日志也不会乱序到没法看 |
--passthrough | 子进程 stdout/stderr 直连终端,不再被捕获。跑 cargo build 想看实时进度条时必开 |
--ignore-changes | 命令即使改了工作副本也不修改任何 revision。纯检查场景用这个,语义更安全 |
--ignore-errors | 某个 revision 挂了继续跑剩下的。批量修复时不至于卡在第一个错误上 |
同时修了一个 panic:revset 里包含冲突 commit 时 jj run 会崩(#9747)。现在冲突会被保留在重写后的 commit 里。
实战建议——把它做成 CI 的本地前置检查:
#!/usr/bin/env bash
# scripts/precheck.sh —— 提 PR 前跑一遍,确保栈里每个 commit 都是绿的
set -euo pipefail
STACK='trunk()..@'
echo "==> 检查每个 commit 是否可编译"
jj run -r "$STACK" --ignore-changes --passthrough -- cargo check --all-targets
echo "==> 检查每个 commit 是否有描述"
if jj log -r "$STACK & description(exact:'')" --no-graph -T 'change_id.short() ++ "\n"' | grep -q .; then
echo "存在没有描述的 commit:" >&2
jj log -r "$STACK & description(exact:'')" --no-graph -T 'change_id.short() ++ " " ++ commit_id.short() ++ "\n"' >&2
exit 1
fi
echo "==> 检查是否有残留冲突"
if jj log -r "$STACK & conflicts()" --no-graph -T 'change_id.short() ++ "\n"' | grep -q .; then
echo "存在未解决的冲突" >&2
exit 1
fi
echo "全部通过"
注意 --ignore-changes 和 --passthrough 一起用:前者保证 cargo check 意外产生的文件(比如某些 build script 的产物)不会污染你的 commit,后者让你能看到实时输出。
7.3 jj absorb --interactive:这个我等很久了
先说 jj absorb 本身。它的作用是:把当前 commit 里的每个 hunk,自动"吸收"到最后修改过对应代码行的那个祖先 commit 里。
对应 Git 的 git absorb(一个第三方工具)或者手动 git commit --fixup + git rebase --autosquash。
典型场景:你有一个 5 commit 的 PR,reviewer 提了 8 条散落在各处的意见。你在工作副本里一口气全改了,然后:
jj absorb
# 8 个 hunk 自动分发到 5 个 commit 里,后代自动 rebase
0.44 加了 --interactive / -i / --tool:可以交互式挑选哪些 hunk 参与吸收。
这个改进的价值在于:以前如果你只想吸收一部分改动,必须先 jj split 把 commit 拆开,多一步操作。现在直接:
jj absorb -i
# 勾选要吸收的 hunk
# 没勾选的、以及无法吸收的(比如新增文件、找不到明确归属的行),留在源 commit 里
"无法吸收的留在源 commit"这个语义很重要——它意味着 jj absorb -i 是安全的,不会因为部分失败就整体回滚或者丢东西。
7.4 其余值得注意的改动
jj file search 输出格式变了(Breaking)
以前只打印包含匹配的文件路径,现在打印每一行匹配内容,带文件路径前缀(#9399)。要旧行为用 --name-only。同时新增 -n / --line-number 打印行号。
行为上现在更像 grep,符合直觉。但如果你有脚本 pipe 它的输出,一定会挂。检查一遍。
重复参数不再报错(Breaking,但是好事)
jj --no-pager --no-pager version、jj log -n 5 -n 10 现在都能工作,最后一个赢。
真正的价值在于:alias 或 wrapper 里写死的参数,现在可以在命令行上被显式覆盖。以前你定义了一个 alias 强制 -n 10,就没法临时改成 20,只能绕开 alias。现在直接 -n 20 覆盖掉。
注意例外:可以重复指定的参数(--config、jj log -r)行为不变,仍然收集所有值。
alias 递归检测更精确
现在 jj 能展开"单纯重复"的 alias。文档给的例子很妙:定义 jj = [] 这个 alias 后,jj jj jj 会解析成 jj。还有 i = ["--ignore-working-copy"],jj i 解析成 jj --ignore-working-copy——alias 可以回退到默认命令了。
顺带说,--ignore-working-copy 这个 flag 值得单独配个 alias。它跳过 snapshot 步骤,在超大仓库里跑只读查询能省掉几百毫秒:
[aliases]
i = ["--ignore-working-copy"]
l = ["--ignore-working-copy", "log"]
jj git import / export 在 colocated 仓库里默认禁用
理由:这两个命令通常什么也不做,却存在竞态条件。这是个纯 bug 修复。如果你的脚本显式调用了它们,去掉即可。
Git 临时文件清理更可靠
处理信号(Ctrl-C)时能正确清理 Git 临时文件,降低 "Could not acquire lock for index file" 的出现率(#7530)。这个错误在 colocated 仓库里以前挺烦人的。
非 UTF-8 路径不再导致 snapshot 失败
以前工作副本里有个文件名不是合法 UTF-8,整个 snapshot 就炸了(#9774)。现在跳过并警告。在处理某些历史遗留仓库或者跨语言环境时,这个修复很实在。
其他
- 新增
try(expr, fallback...)模板函数,压制运行时错误。写复杂 template 时不用再层层判空。 jj workspace list默认显示 workspace 根路径,可用templates.workspace_list或-T定制。diff.stat.max-bar-width配置,限制--stat那个++--条的宽度。终端窄的时候有用。jj bisect现在会在因为 skip 导致无法明确定位第一个坏 revision 时给出提示。
八、代码实战:一次完整的迁移
8.1 colocated 模式:零风险试水
强烈建议第一步用 colocated 仓库——.git 和 .jj 共存,Git 命令和 jj 命令可以混着用。你的 IDE、CI、Git hooks 全部照常工作。
# 在已有 Git 仓库里初始化
cd ~/code/my-service
jj git init --colocate
# 现在两套工具都能用
jj log
git log --oneline # 照常工作
git status # 照常工作
# 你的 IDE 的 Git 集成、GitLens、CI 脚本,全都不受影响
不喜欢?删掉 .jj 目录就行,Git 仓库完全没被动过。这个可回退性是 jj 相比其他 VCS 实验品最大的优势。
8.2 必备配置
# ~/.config/jj/config.toml
[user]
name = "Your Name"
email = "you@example.com"
[ui]
default-command = "log" # 裸 jj 等于 jj log
diff-editor = ":builtin" # 内建 TUI diff 编辑器,够用
merge-editor = ":builtin" # 或 "meld" / "vscode" / "nvimdiff"
pager = "less -FRX"
conflict-marker-style = "diff" # 冲突标记显示为 diff 形式,比 <<<< 好读很多
[core]
fsmonitor = "watchman"
[core.watchman]
register-snapshot-trigger = true
[git]
auto-local-bookmark = false # fetch 时不自动创建本地 bookmark(推荐)
# 大仓库上开着会创建几百个本地 bookmark
[snapshot]
max-new-file-size = "10MiB"
[revset-aliases]
"immutable_heads()" = "builtin_immutable_heads() | remote_bookmarks() | tags()"
"wip" = "mine() & ~::trunk() & ~empty()"
"stack" = "trunk()..@"
[revsets]
log = "builtin_log() | wip"
[aliases]
i = ["--ignore-working-copy"]
l = ["--ignore-working-copy", "log"]
s = ["status"]
d = ["diff"]
tug = ["bookmark", "move", "--from", "heads(::@- & bookmarks())", "--to", "@-"]
[templates]
draft_commit_description = '''
concat(
description,
surround("\nJJ: This commit contains the following changes:\n", "",
indent("JJ: ", diff.summary())),
)
'''
那个 tug alias 是社区里流传很广的一个技巧:把离 @ 最近的那个 bookmark 往前拖到 @-。因为 jj 里 bookmark 不会自动跟着走(这是刻意设计),所以需要一个顺手的命令把它拽过来。
8.3 一个真实的功能开发流程
# ── 1. 同步主干
jj git fetch
jj new main@origin -m "feat: 订单履约状态机重构"
# ── 2. 开发。不需要 add,不需要 commit
vim src/fulfillment/state_machine.rs
vim src/fulfillment/mod.rs
# 随时看状态
jj st
# Working copy changes:
# M src/fulfillment/mod.rs
# A src/fulfillment/state_machine.rs
# Working copy (@) : nkmrtpxv 9c8b7a6d feat: 订单履约状态机重构
# Parent commit (@-): xtsmqnol 1f2e3d4c main@origin | chore: bump tokio
# ── 3. 觉得可以切一刀了,开下一个 commit
jj new -m "test: 状态机单元测试 + 属性测试"
vim tests/state_machine_test.rs
# ── 4. 发现第一个 commit 里漏了个字段。不用回退,直接 squash 进去
vim src/fulfillment/state_machine.rs
jj squash --into nkmrtpxv # 或者 jj squash -i 挑 hunk
# 后代自动 rebase
# ── 5. reviewer 提了一堆散落的意见,一口气改完
vim src/fulfillment/state_machine.rs
vim tests/state_machine_test.rs
jj absorb -i # 0.44:交互挑 hunk,自动分发到对应 commit
# ── 6. 提交前自检:每个 commit 都能编译
jj run -r 'stack' --ignore-changes --passthrough -- cargo check --all-targets
# ── 7. 主干动了,rebase 整个栈
jj git fetch
jj rebase -d main@origin
# 冲突了?不阻塞,先看看情况
jj log -r 'stack & conflicts()'
# ── 8. 解决冲突
jj edit <conflicted-change-id>
jj resolve
# 后代自动更新
# ── 9. 推送。-c 会自动为该 change 生成 bookmark 名
jj git push -c 'stack'
# ── 10. 手滑了?
jj undo
jj op log # 看看到底发生了什么
8.4 Git ↔ jj 命令对照
| 意图 | Git | jj |
|---|---|---|
| 看状态 | git status | jj st |
| 看历史 | git log --graph --oneline | jj log |
| 开始新工作 | git checkout -b f && ... | jj new main -m "..." |
| 暂存 | git add -p | 不需要 |
| 提交 | git commit -m | jj describe -m(内容已在 @) |
| 提交并开新的 | git commit && ... | jj commit -m "..." |
| 修改上一个提交 | git commit --amend | 直接改文件 / jj describe |
| 修改任意历史提交 | git rebase -i + edit | jj edit <change-id> |
| 把改动塞进某个历史提交 | git commit --fixup + rebase --autosquash | jj squash --into <id> |
| 自动分发改动 | git absorb (第三方) | jj absorb [-i] |
| 拆分提交 | git rebase -i + edit + reset + add -p | jj split -i |
| 暂存工作 | git stash | jj new(旧的自动保留) |
| 丢弃提交 | git reset --hard / git rebase --onto | jj abandon |
| 撤销上一步 | git reflog + git reset | jj undo |
| 撤销 fetch | 基本没法优雅撤销 | jj undo |
| 变基 | git rebase | jj rebase -s/-b/-d |
| 变基后代 | 手动多次 rebase | 自动 |
| 挑拣 | git cherry-pick | jj duplicate --onto |
| 反做 | git revert | jj backout |
| worktree | git worktree add | jj workspace add |
| 查历史版本的某次修改 | 无对应 | jj evolog(看一个 change 的演化史) |
| 二分查找 | git bisect | jj bisect |
jj evolog 值得特别提一句。因为 change ID 稳定,jj 能记录同一个 change 的每一次 rewrite。所以你可以看到"这个 change 从最初到现在被改了 7 次,第 3 次是什么样"。Git 里这个信息只存在于 reflog 的碎片中,而且 rebase 之后基本捞不回来。
九、性能与工程权衡:什么时候别用 jj
我不想写成安利文,所以这一节讲代价。
9.1 snapshot 成本在超大仓库上是真实的
每条命令都要 snapshot。即使有 TreeState 缓存 mtime/size,在 50 万文件级别的 monorepo 上,冷启动的 stat() 遍历也要 1 秒往上。
缓解手段,按效果排序:
- watchman(必须)——把变更检测从"轮询"变成"订阅"
- sparse checkout——架构文档明确说了"所有工作副本都是稀疏的,只是大多数情况下跟踪整个仓库"。所以稀疏不是特殊路径,是默认路径的一个参数:
jj sparse set --clear --add src/payments --add src/common --add Cargo.toml
jj sparse list
jj sparse reset # 恢复全量
--ignore-working-copy——只读查询跳过 snapshotsnapshot.auto-track = 'none()'——新文件默认不跟踪
9.2 生态是最大的短板
这是硬伤,必须承认:
- GitHub / GitLab 原生不认 jj。 你只能推 Git ref,PR 流程还是 Git 的。
- IDE 集成弱。 JetBrains 和 VS Code 的原生 VCS 面板是为 Git 写的。colocated 模式下能用,但显示的是 Git 视角(看不到 change ID,看不到 op log)。社区有
jjui(TUI)和一些 VS Code 插件,但成熟度和 GitLens 差一个量级。 - CI/CD 全是 Git。 你的 pipeline 里全是
git clone、git rev-parse HEAD。 git-lfs支持不完整。 如果你重度依赖 LFS,先别迁。- submodule 支持有限。 同上。
- 团队协作有摩擦。 你可以自己用 jj、别人用 Git(colocated 保证了这一点),但你没法把 jj 的好处传染给团队——change ID、op log 这些都是本地概念,推到远端就退化成普通 Git commit。
9.3 分布式文件系统场景:能用,但看清楚警告
前面 5.4 已经讲了。总结一下现状:
- 理论上支持(无锁设计的直接推论)
- Git 后端并非完全无锁,有仓库损坏风险(#2193),恢复靠
jj debug reindex - 测试覆盖不足,尤其 colocated 场景
- 可能丢 bookmark 指针,但不会丢 commit
我的建议:单人多机同步(笔记本 + 台式机,Dropbox/Syncthing)可以试,但一定要有远端备份。团队共享的 NFS 目录上跑 jj,暂时别。
9.4 什么场景现在就该迁
- 你个人的项目 / 你主导的小团队仓库——收益最大,阻力最小
- 你经常做堆叠式 PR——jj 在这里的优势是碾压级的
- 你经常被 rebase 冲突折磨——冲突化简能救命
- 你误操作频繁 / 需要频繁回滚仓库状态——op log 就是为你准备的
- 你在 Google(笑)——jj 本来就是从那儿出来的
9.5 什么场景现在别碰
- 重度依赖 LFS / submodule 的仓库
- 团队里 Git 深度定制了一堆 hook 和工作流
- 你的日常操作 90% 是
git pull+git commit -am+git push——那 jj 给不了你什么 - 你需要图形化界面来理解仓库状态——工具链还没跟上
十、十条踩坑清单
1. jj git push --all 在 0.44 会推所有 tag。 升级前检查所有 CI 脚本。这是本次升级最容易出事故的点。
2. git tagOpt 不再生效。 想关掉 tag fetch 必须改成 remotes.<name>.fetch-tags = '~*'。之前靠 Git 配置控制 tag 行为的,升级后会突然 fetch 一堆 tag。
3. jj file search 输出格式变了。 任何 pipe 它的脚本都会挂,加 --name-only 恢复旧行为。
4. 一定要配 immutable_heads()。 默认配置下你能 rewrite 已推送的 commit。配上 remote_bookmarks() | tags() 之后,jj 会直接拒绝这类操作。这是防止团队事故的第一道防线。
5. bookmark 不会自动跟着 @ 走。 这是刻意设计,但从 Git 过来的人一定会懵。用 jj bookmark move 或者上面那个 tug alias。
6. jj git fetch 之后旧的本地 bookmark 不会自动删。 定期 jj bookmark list --all 清理。开 git.auto-local-bookmark = false 能少制造很多垃圾。
7. colocated 仓库里,git gc 清不掉对象。 因为 refs/jj/keep/ 还引用着 op log 里的所有 commit。这是设计使然,不是 bug。真要清理,先 jj op abandon 掉老的 operation。
8. jj undo 撤销的是操作,不是提交。 jj undo 一次 jj squash,会把整个 squash 连同它触发的自动 rebase 一起撤销。但要注意:jj undo <id> 撤销的只是那一个 operation,在它之后发生的 operation 依然保留——如果后续操作依赖了被撤销的那一步,你可能得到一个没预期的中间状态。不确定时先 jj op log 看清楚,用 jj op restore <id> 整体恢复到某个确定的状态点更安全。
9. same-change rule 会丢改动。 前面 4.3 讲过:rebase 到一个有相同改动的 commit 上再 rebase 回来,改动会丢(#6369)。这是已知的、刻意的有损行为。在做复杂的 rebase 编排时留个心眼。
10. 大文件会被默默 snapshot。 没配 snapshot.max-new-file-size 的话,你 cargo build 产生的 target 目录如果没被 ignore,会直接进 commit。配上限,配好 .gitignore。
额外一条: StackedTable 目前没有 GC。长期高频使用的仓库,.jj/repo/store/extra/ 会慢慢变大。目前没有官方清理命令,重新 clone 是最干净的办法。这个坑现在还不痛,但值得知道。
十一、总结:一次"把隐式状态显式化"的系统性重构
写到这里,我想收敛出一个判断。
jj 的所有设计——工作副本即 commit、change ID、一等公民冲突、operation log——本质上是同一件事的四个面:把 Git 里那些藏在 .git/ 临时目录、index 槽位、reflog 里的隐式状态,全部提升为一等公民的、内容寻址的、可查询的数据。
- index 的隐式状态 → 工作副本 commit
- "这个 commit 的前世今生" → change ID + evolog
- rebase/merge 的中断进程状态 → 冲突 commit + 冲突代数
- reflog 的本地碎片 → operation log + view 对象
一旦你把状态显式化了,很多能力就是白送的:undo 变成遍历 DAG,并发变成三方合并,冲突不再阻塞流程,自动 rebase 变成 rewrite 的自然推论。
这种"把隐式变显式"的重构模式,其实在系统软件里反复出现。Kubernetes 把运维操作显式化成声明式 API;Terraform 把基础设施显式化成状态文件;React 把 DOM 操作显式化成虚拟 DOM diff。它们的共同点是:前期你要为显式化付出建模成本和性能成本,回报是后期获得组合能力。
jj 的建模成本是 change ID 和 operation 这两个新概念;性能成本是每条命令都要 snapshot。回报是上面那一整套东西。
这笔账划不划算? 我的答案是:对个人和小团队,非常划算,而且 colocated 模式让试错成本接近于零。对大团队,现在还早——不是因为 jj 不好,是因为生态还没到。
接下来看什么
顺着 0.44 的路线往下推,有几件事值得盯:
- 服务端 index / 云端后端。 五后端架构和
Index::evaluate_revset()这个下沉点,摆明了是为这个铺路的。一旦有生产可用的云后端,超大 monorepo 的故事就完全不一样了。 - 原生 forge 支持。
jj gerrit upload已经在了(0.43 还给它加了-o)。如果哪天有 forge 原生理解 change ID,堆叠式 PR 的体验会有质变——因为 change ID 天然就是 "PR 的稳定身份"。 - Git 后端的完全无锁化。 #2193 是目前最影响"能不能放在共享文件系统上"的问题。
StackedTable的 GC。 长期运行的必需品。
最后说句实在的:你不需要"迁移"。 jj git init --colocate 是可逆的,成本是一条命令。用一周,如果 jj undo 和 jj absorb 没打动你,删掉 .jj 就是了。但我猜你删不掉。
参考
- Jujutsu 官方仓库与 CHANGELOG:
github.com/jj-vcs/jj(0.44.0,2026-08-05) - 官方技术文档:
docs/technical/architecture.md、docs/technical/conflicts.md、docs/technical/concurrency.md - 冲突代数实现:
lib/src/merge.rs中的Merge::flatten()/Merge::simplify() - 相关 issue:#2193(Git 后端锁)、#6369(same-change rule 有损)、#9399(file search 输出)、#9747(run + 冲突 panic)、#9774(非 UTF-8 路径)