番外篇 6 - Jujutsu
Jujutsu(命令名 jj)是一个版本控制系统,用 Rust 写的,底层仍然用 Git 的对象格式,但把用户接触到的那层模型整个换掉了。它和 Git 的关系,大致相当于 Git 和 CVS 的关系:能互操作,但概念不是一套。
这篇番外介绍它的模型,以及为什么这套模型特别适合现在的编程方式——由人和 AI 一起改代码。
现状说明:截至写作时 jj 仍是 0.x(最新为 0.46),官方文档自称 "experimental VCS",命令行也可能变。日常开发用它没问题,团队协作前先看清这一点。
1. 工作副本就是一个提交
Git 里改文件、git add、git commit 是三步。暂存区(index)夹在中间,是很多人最初学 Git 时最绕不过去的一关。
jj 没有暂存区。工作副本本身就是一个提交,用 @ 表示。你改文件,等于改这个提交;你不需要"提交"这个动作,因为改动已经在一个提交里了。
每执行一条 jj 命令,它会先做这几件事:把工作目录里的文件读进来(快照)、记录一次操作、按需要创建新提交、再把更新后的 @ 写回工作目录。所以:
$ jj new # 在当前提交之上开一个新提交,并切过去
$ vim main.c # 随便改
$ jj status # 改动已经在 @ 里了
$ jj new # 再开一个,上一个就定住了2
3
4
"提交"这个概念还在,只是从"显式动作"变成了"切到下一个提交"的副作用。jj describe 可以随时改当前提交的说明,包括已经写过的。
这个模型有个直接后果:没有"忘了 add"这回事。新增的文件默认会被跟踪(受 snapshot.auto-track 和忽略规则约束),删掉文件就取消跟踪。要精细控制用 jj file track / jj file untrack。
2. change ID 与 commit ID
Git 的提交 ID 是内容的哈希,所以任何改写——改说明、变基、压缩——都会得到一个全新的哈希。这在脚本里是麻烦的:你上一秒记下的 a1b2c3d,下一秒就找不到了。
jj 给每个提交两个 ID:
| 是什么 | 改写后会变吗 | |
|---|---|---|
| commit ID | 某一次具体快照的哈希,与 Git 的 commit hash 一致 | 会变 |
| change ID | 一个逻辑改动的标识,12 个字符,字母落在 k–z 区间 | 不变 |
jj log 默认把两者并排显示。日常指代某个改动用 change ID,因为它跟着"这件事"走,而不是跟着"这一版内容"走。如果一个 change ID 对应多个版本(比如分叉后又合回来),用 change/0、change/1 这样的偏移区分,0 是最新的。
3. 改写会自动带着后代走
Git 里改写一个早期提交,后面所有提交都要手动 git rebase --onto 重新挂上去,中途还可能撞一堆冲突。
jj 在改写提交时,自动把它的所有后代重新挂到新版本上。你在历史中间改一句话、改一行代码、插一个提交,上面的整条链会跟着挪,不需要额外命令。
这是"工作副本即提交"之外第二个改变手感的设计。在 Git 里,改早期提交是一件要下决心的事;在 jj 里,它和改当前提交差不多。
4. 冲突存在提交里,不中断流程
Git 合并或变基撞上冲突时会停下来,进入一个半完成状态:工作目录里有冲突标记,仓库里有 MERGE_HEAD 或 rebase-merge/ 目录,你必须解决完、git add、再 --continue 或 --abort。这个状态很容易把人(和自动化脚本)卡住。
jj 把冲突记录在提交内部,存的是一份逻辑表示,不是冲突标记。只有把冲突具体化到工作目录或 diff 输出时,才会变成 <<<<<<< 那种文本。
后果是:合并、变基遇到冲突也能直接做完,提交照样建出来,只是带着冲突。你可以先放着,去处理别的事,之后再 jj edit 进去解决,解决完这个提交就自动更新。没有"未完成的变基"这种悬空状态。
5. 操作日志与撤销
jj 把每一次改动仓库的操作都记进操作日志(jj op log)。每次操作存一份当时的仓库视图——书签、标签、Git ref 指向、各工作区的 @——加上时间、用户、主机和描述。
于是撤销变成一等操作:
$ jj undo # 撤销上一步
$ jj op revert <op> # 撤销指定的某一步,不必是最近一步
$ jj op restore <op> # 把整个仓库恢复到那一步时的状态2
3
这比 Git 的 reflog 加 reset --hard 好找得多:jj op log 是一份可读的操作清单,不是一堆哈希。
6. 书签与分支
jj 用书签(bookmark)对应 Git 的分支。差别在几处:
- 没有"当前分支"的概念。书签只是指向某个提交的名字,不存在"检出了哪个分支"这回事。当前在哪由
@决定。 - 改写提交时书签自动跟着走。
jj rebase之后,指向被移动提交的书签会一起挪过去。Git 的reset --hard、commit --amend都会让分支指针留在原地。 - 一个本地书签可以同时跟踪多个远程上的同名书签,用
name@origin、name@upstream区分。 - 提交被
abandon时,指向它的书签会被删掉。
7. 用 revset 选提交
jj 的 -r 接受一种查询语言叫 revset,由符号、运算符和函数组成,语法借自 Mercurial。几个常用的:
| 写法 | 含义 |
|---|---|
@ | 当前工作副本提交 |
@- | 它的父提交(- 是取父,+ 是取子) |
::@ | @ 的全部祖先,含自身 |
x..y | y 的祖先里去掉 x 的祖先 |
all() / none() / root() | 全部 / 空 / 根提交 |
description(x)、author(x)、files(x) | 按说明、作者、改动文件筛 |
conflicts()、divergent() | 带冲突的、分叉的提交 |
$ jj log -r '@-' # 只看父提交
$ jj log -r '::@' -T 'description' # 所有祖先的说明
$ jj log -r 'remote_bookmarks()..' # 还没推到任何远程的提交
$ jj log -r 'author(*me*) & conflicts()'2
3
4
-T 指定输出模板,模板语言是单独一套,可以自定义到相当细的粒度。
8. 和 Git 放在一起用
jj git init 或 jj git clone 建出来的工作区默认是并置的(colocated):顶层同时有 .jj/ 和 .git/,两条命令都能用。每次执行 jj 命令,它都会自动从 Git 仓库导入、往 Git 仓库导出。
这个模式的实际状况:
能用的:只读的 Git 命令基本都能用;期待 Git 仓库的工具(IDE、部分 CI 脚本)能认出这个目录;用 Git 做的改动会以 "import git refs" 的名义出现在 jj op log 里,可以用 jj undo 撤掉。
要留神的:
- jj 通常让 Git 停在 detached HEAD 状态,因为它没有"当前分支"。在执行会改仓库的 Git 命令之前,可能先要
git switch。 - 混着用会产生令人困惑的分支冲突或分叉的 change ID。数据不会丢,但看着闹心。IDE 后台悄悄跑的
git fetch也可能触发这个问题。 - 引用很多的工作区会因为自动导入而变慢,
jj util gc可以缓解。 - Git 那边的工具看到的是冲突的内部表示,不是 jj 的冲突标记。
- jj 不认识 Git 的暂存区、合并冲突表示和未完成的
git rebase状态。 - 放在 NFS、Dropbox 这类共享文件系统上时,并置模式对并发问题更脆弱。
如果不要并置,--no-colocate 或 git.colocate = false,此时 Git 仓库藏在 .jj 底下,需要显式 jj git import / jj git export 才和外界同步。
9. 为什么它适合现在的编程方式
这一节是写这篇番外的原因。
现在的代码越来越多是"人定方向、AI 动手":AI 一次改几十个文件,改错了要退,改到一半发现方向不对要分叉,同一件事要在多个版本上并行试。这些动作在 Git 里都不算顺手,而在 jj 里恰好都是最基础的几件事。
一、AI 不需要学暂存区。 git add 是一个纯粹的人为步骤,对自动化来说没有任何信息量——它想表达的是"这些改动一起提交",可 AI 改完文件之后这个判断通常早就有了。jj 里工作副本就是提交,AI 改完即已记录,不需要额外动作,也不会因为漏了 git add -A 而把新文件落在提交之外。少一个概念,就少一类失败。
二、撤销不再需要考古。 AI 改坏了,最自然的反应是"退回去"。Git 的 reflog 是一串哈希,reset --hard 又会连工作目录一起抹掉,中间还容易选错起点。jj 的 jj undo 撤的是"上一次操作",jj op log 是一份人可读的操作清单,jj op restore 可以整体回到任意一步。对自动化的意义是:回退是一次普通命令,不需要专门的恢复逻辑。
三、改早期提交不再有代价。 AI 常常在任务末尾才发现前面某个提交写错了。Git 里这要 rebase -i 或 rebase --onto,中途撞冲突就进半完成状态。jj 里 jj edit 到那个提交、直接改,后面整条链自动跟着挪;改完用 jj next 逐层往后走,或者直接 jj new <顶端提交> 回到顶端继续。这让"回头修"和"往前走"的成本接近,AI 就不必为了规避 rebase 而把修正堆在最新的提交里。
四、没有未完成状态可以卡住。 自动化的天敌是"停在半路等人"。Git 的合并冲突、未完成变基、MERGE_HEAD 都是这种状态——脚本跑到一半失败,下次运行面对的是一棵状态不明的树。jj 把冲突放进提交本身,合并和变基总能做完,"解决冲突"变成一个可以随时排队、也可以完全跳过的普通任务。
五、并行试错是原生的。 jj workspace add 可以让同一个仓库有多个工作副本,各自检出不同的提交。一个 agent 在改代码、另一个跑长测试、第三个在验证某个假设,互不干扰。Git 的 worktree 也能做,但配合自动 rebase 和无暂存区,jj 的组合更省心。
六、jj absorb 把零散修改送回原处。 AI 批量改完之后,常常是一堆零散改动散落在工作副本里。jj absorb 会把这些改动拆开,每一块送到"最后修改过这些行的那个提交"里;认不出来的留在原地。配合 --interactive 可以人工挑拣。这在"让每个提交各自完整"这件事上,比手工 fixup + rebase --autosquash 省事得多。
七、change ID 让对话有稳定的锚点。 和 AI 讨论"刚才那个改动"时,commit hash 一变,指代就断了。change ID 跟着逻辑改动走,改写之后还是同一个名字,人和 AI 可以一直用它对齐。
当然,这不意味着该立刻把所有项目搬过去。jj 还在 0.x,命令行会变;绝大多数 IDE、CI、代码托管平台的集成只认 Git;团队里其他人可能没听说过它。并置模式能缓解互通问题,但会引入上面第 8 节那些坑。
比较务实的做法是:本地开发用 jj,远端和 CI 仍然是 Git。这个组合已经能吃到上面大部分好处,而对协作方完全透明——推上去的还是普通 Git 分支。
10. Git 与 jj 对照
| 动作 | Git | jj |
|---|---|---|
| 记录改动 | git add + git commit | 改完即已记录 |
| 开新提交 | git commit | jj new |
| 改提交说明 | git commit --amend | jj describe |
| 改早期提交 | git rebase -i | jj edit,改完 jj next 或 jj new <顶端> |
| 合并 | git merge,冲突时中断 | jj new a b,冲突可留在提交里 |
| 撤销 | git reflog + git reset | jj undo / jj op restore |
| 分支 | 有"当前分支" | 书签,无当前分支 |
| 引用提交 | commit hash | change ID 或 commit ID |
| 并行工作副本 | git worktree | jj workspace add |
| 零散改动归位 | fixup + --autosquash | jj absorb |
参考资料
- Jujutsu 官方文档(工作副本、书签、冲突、操作日志、revset、Git 兼容各章)
- Jujutsu GitHub 仓库(发布记录与版本状态)
- jj 教程