编程 Jujutsu 0.44 深度拆解:当版本控制决定把「暂存区」「分支」「冲突」一起删掉——从双 ID 模型到无锁 Operation Log 的架构手术

2026-08-09 03:48:39 +0800 CST views 8

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 挑 hunk
  • git 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

注意 kntqzsrp8f2c1a4d 这两个 ID——后面会讲,它们是两个不同维度的东西。

关键在于:你从来不需要"保存"你的工作。工作区的内容天然就在版本控制里。这句话的推论是一连串的:

Git 操作jj 里发生了什么
git add不存在。文件默认被跟踪,snapshot 自动完成
git stash不存在。jj new 一下,旧的 @ 自动留在 DAG 里
git commit --amendjj describe 改描述;内容改动直接改文件就行
git checkout -- fjj 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 addgit 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 DAGoperation DAG
commit 对象operation 对象
tree 对象view 对象
commit → tree 指针operation → view 指针
commit → parent commitsoperation → 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 处理远端冲突那样。

具体机制:

  1. 命令启动,加载最新 operation 对应的 repo。因为 view 对象完整定义了仓库状态,这条命令在整个执行期间看到的是一个一致的快照,其他进程的改动完全不可见。
  2. 命令完成,以启动时的 operation 为 parent 写入新 operation。这一步不可能失败(除非磁盘挂了)。
  3. 有没有并发分叉,留给下一条命令去发现。

第 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 backendcommit / tree / file.jj/repo/store/type
operation backendoperation / view.jj/repo/op_store/type
op heads backendoperation log 的 head.jj/repo/op_heads/type
index backendcommit 索引.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-libjj-cli 的拆分是硬性的:库层不做任何终端 I/O,也不读用户 home 目录的配置或用户相关环境变量(因为它要能跑在服务端)。所有 I/O 归 CLI 层。这也是为什么社区能相对容易地做出各种 TUI(如 jjui)和编辑器集成。

6.3 Revset:一门真正的查询语言

Revset 语法抄自 Mercurial,但 jj 的实现有自己的四阶段求值:

  1. 解析RevsetExpression(接近 AST)
  2. 符号解析:把 tags()、bookmark 名之类解析成具体 commit。这一步之后表达式里不再有 CommitRef
  3. 可见性解析:解析 visible_heads()all(),产出 ResolvedExpression
  4. 求值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 versionjj log -n 5 -n 10 现在都能工作,最后一个赢

真正的价值在于:alias 或 wrapper 里写死的参数,现在可以在命令行上被显式覆盖。以前你定义了一个 alias 强制 -n 10,就没法临时改成 20,只能绕开 alias。现在直接 -n 20 覆盖掉。

注意例外:可以重复指定的参数--configjj 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 命令对照

意图Gitjj
看状态git statusjj st
看历史git log --graph --onelinejj log
开始新工作git checkout -b f && ...jj new main -m "..."
暂存git add -p不需要
提交git commit -mjj describe -m(内容已在 @
提交并开新的git commit && ...jj commit -m "..."
修改上一个提交git commit --amend直接改文件 / jj describe
修改任意历史提交git rebase -i + editjj edit <change-id>
把改动塞进某个历史提交git commit --fixup + rebase --autosquashjj squash --into <id>
自动分发改动git absorb (第三方)jj absorb [-i]
拆分提交git rebase -i + edit + reset + add -pjj split -i
暂存工作git stashjj new(旧的自动保留)
丢弃提交git reset --hard / git rebase --ontojj abandon
撤销上一步git reflog + git resetjj undo
撤销 fetch基本没法优雅撤销jj undo
变基git rebasejj rebase -s/-b/-d
变基后代手动多次 rebase自动
挑拣git cherry-pickjj duplicate --onto
反做git revertjj backout
worktreegit worktree addjj workspace add
查历史版本的某次修改无对应jj evolog(看一个 change 的演化史)
二分查找git bisectjj 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 秒往上。

缓解手段,按效果排序:

  1. watchman(必须)——把变更检测从"轮询"变成"订阅"
  2. sparse checkout——架构文档明确说了"所有工作副本都是稀疏的,只是大多数情况下跟踪整个仓库"。所以稀疏不是特殊路径,是默认路径的一个参数:
jj sparse set --clear --add src/payments --add src/common --add Cargo.toml
jj sparse list
jj sparse reset   # 恢复全量
  1. --ignore-working-copy——只读查询跳过 snapshot
  2. snapshot.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 clonegit 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 的路线往下推,有几件事值得盯:

  1. 服务端 index / 云端后端。 五后端架构和 Index::evaluate_revset() 这个下沉点,摆明了是为这个铺路的。一旦有生产可用的云后端,超大 monorepo 的故事就完全不一样了。
  2. 原生 forge 支持。 jj gerrit upload 已经在了(0.43 还给它加了 -o)。如果哪天有 forge 原生理解 change ID,堆叠式 PR 的体验会有质变——因为 change ID 天然就是 "PR 的稳定身份"。
  3. Git 后端的完全无锁化。 #2193 是目前最影响"能不能放在共享文件系统上"的问题。
  4. StackedTable 的 GC。 长期运行的必需品。

最后说句实在的:你不需要"迁移"。 jj git init --colocate 是可逆的,成本是一条命令。用一周,如果 jj undojj absorb 没打动你,删掉 .jj 就是了。但我猜你删不掉。


参考

  • Jujutsu 官方仓库与 CHANGELOG:github.com/jj-vcs/jj(0.44.0,2026-08-05)
  • 官方技术文档:docs/technical/architecture.mddocs/technical/conflicts.mddocs/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 路径)

推荐文章

使用 sync.Pool 优化 Go 程序性能
2024-11-19 05:56:51 +0800 CST
20个超实用的CSS动画库
2024-11-18 07:23:12 +0800 CST
基于Webman + Vue3中后台框架SaiAdmin
2024-11-19 09:47:53 +0800 CST
Go中使用依赖注入的实用技巧
2024-11-19 00:24:20 +0800 CST
程序员茄子在线接单