我的 JJ 使用体验

本文初记于 2026 年 8 月记事,后因篇幅过长单独提成这篇博文。

补充:后来记事也单独提成一篇回顾博文《大三下学期回顾》了。

临近这学期开学时写过我投靠 JJ 了,这学期还看了 Steve's Jujutsu Tutorial,同时基本上全面采用(甚至一些旧库也转了)并应用了很多新功能。大半年过去后,我只能说太好用,让我来写写看,能否表达出来。

当然,我也只是一个 Git 新手,也没怎么用过 Git 相关的命令,很多操作我之前其实都是用 VS Code 的 Git 插件,或者用 Vim 的 Fugitive 完成的,而不是靠命令行。反倒是改用 JJ 后使用命令行直接操作的频率极大地提升了,我想不仅仅是因为相关支持比较欠缺,其实更有它命令行本身就非常符合直觉、强大的原因在。

因此下面的一些所谓的 JJ 的优点,在 Git 上也是一定程度上可以实现的,只是我不了解罢了。但没有关系,既然 JJ 被许多人推崇,说明它的的确确是更加直观的。

首先把 JJ 有一个 change 的概念,类似 commit。我们知道 commit id 由一些元信息(例如说 commit message, author, committer 与相应时间)、parent、tree 等通过 SHA1 哈希得来,因此只要动了一点点,甚至什么也不动只是改个时间,commit id 都大变样了。而 change 对于一次逻辑上的变更,id 保持不变。

这说着有点抽象。可以这么想,jj new 后就会进入到一个 empty change,然后你在里面随便工作,怎样变更都行,这个 change 的 id 保持不变,而 commit id 则会随之变化。这样就很舒服了,一次固定的工作有一个不变的唯一标识符。

为什么这个非常好呢?因为 JJ 中 rebase 是非常频繁、常见的事情。我在 Git 中都没怎么 rebase 过,记得很久以前的时候确实有尝试过那个交互式 rebase 命令,但因为当时我也不懂 rebase 命令的原理,心怀恐惧。可能最多的场景还是在 GitHub 合并 PR 时点的 Squash and Merge 吧[1]

而由于 Git commit 的特性,rebase 后 commit id 就发生了变化,这可不太符合预期,因为明明是同一份工作。而有了 change id,rebase 后就有了一个可以依靠的标识符,不管怎么变化、移动,都稳定指向同一份工作。

下一个讲一下快照。Git 的东西,得加入暂存区,然后 commit 才能被 Git 追踪保存下来,假如说在没有追踪的情况下进行了变更,那就没法通过 Git 进行恢复。这其实非常合理,但 JJ 则更加人性化。

下面是我自己的理解,没具体查过。上面说过 jj new 开启一项新工作,从这开始其实就跟 Git 不太一样了。按照我的理解,在一个 change 中,执行 JJ 命令的时候就会进行一次 snapshot,相当于就直接把此时此刻的情况快照保存了,因此后面是有机会可以恢复中间形态的。

JJ 中命令可以撤销,使用 jj undo(重做就是 jj redo),具体的命令日志可以在 jj op log 中查看。检查的话就可以发现里面有 snapshot working copy 的命令记录,因此就可以相应地进行恢复了。Git 其实也有类似的东西,即 git reflog,我也曾经使用这个进行过恢复,但相比之下就没有这么强大。

当然直接这样快照也不是没有缺点的。我就遇到过好些情况,还没来得及写 .gitignore 就用 jj status 看看情况,结果会有命令警告有大文件。它默认是有一个上限的,超过了就不会快照,可能是几 M?实在需要可以通过设置调整。

还有一个分支,Git 中是有一个分支的概念的。在 JJ 中对应的名词是书签。说实话书签这个名词就非常地合适,仓库的内容本质上是一个 DAG,分支看起来像是限制住了你只能这样发展,像是变更的前提,而书签则随便贴在哪里都没事,是变更最后才要做的事情。当然其实也有一定限制的,我也遇到过书签贴在上一个时会有相应的警告,必须额外附带选项。

Git 中有一个概念叫做暂存区。我在用 JJ 之前,review AI 的代码的时候常常会用到。我将 AI 写的代码存到暂存区里面,然后再在本地进行检查修改。这样的话可以在 Git 插件那一块看我修改的变更,同时不会影响一开始的 AI 代码。

有时候我可能打算分多次,或者已经检查一部分感觉没什么问题了,打算进行下一次。有时候可能就是直接将完成的文件也加入暂存区。但我可能还因为各种各样的原因还不想合并破坏,或者说需要中间多个检查点。这时候就比较难办了,我一般可能就是 commit 记录下来,然后继续利用暂存区 review,并在最后逐个 commit 撤回并合并变更。这实际上是在手动模拟 squash,我查了一下 Git 原来使用 rebase 来实现,看起来挺麻烦的。

JJ 就不必这样复杂,压根就没有暂存区的概念。我完全可以将一开始的变更 describe 记录一下这个是初始的 AI 代码(或者懒的时候连 description 都不写了),然后直接 new,review 改点,打算建立检查点了,继续 new,继续改。完成后我可以直接用一个 squash 命令,全部合并成一个变更,甚至弄错了还可以简单地撤回。可以说非常直观的工作流,心智负担降低了很多。

而且很爽的是可以不管 description,或者说 commit message。虽然可以靠 git commit 来模拟,但你要先 add(虽然可以直接全部 add),然后再 commit,还得弄个 message(虽然可以空 message,但一般来说都是用一个应付的消息),最后再 rebase 交互改。而 JJ 就是 new, new, squash。

这其实还只是一个开始。上面说 rebase 在 JJ 中非常频繁,这是因为 JJ 中变更的改变是非常简单、轻松的。

比如说我在 Git 中刚提交完,发现 commit message 写错了,怎么办?说实话我之前是直接撤销上一次 commit,然后再修改提交信息(甚至有过丢掉提交信息得重新写的经历)。当然我用了 JJ 才知道可以用 --amend。但即便如此其实也不如 JJ 方便,因为上一个你可以这样改,那再上一个呢,甚至八竿子打不着的其他地方(例如其他分支上呢)?本质上正在工作的变更与其他变更没什么两样,在 JJ 中都可以传递一个参数 -r <revision>[2] 来,因此 jj describe 是正常的弄 description 的方式,改上一个就是 jj describe -r @-[3],改其他的就换成对应的 id 就完事了。

类似的,发现上一个提交忘记带一个东西了,Git 还是要用 --amend(当然,我之前还是撤回),JJ 直接 squash 这个东西就行了。同样的,如果不是上一个提交 Git 还会更加麻烦,JJ 则只是换一个 revision 的事,因为操作是完全一致的。

不过这时候可能会觉得奇怪,改了这些东西,本质上是动了 commit 本身,既然如此应该会有连锁反应吧,因为 commit 本身变了,id 也就变化了,以它为 parent 也会变化,以此类推……没错,JJ 能实现这么强大的功能的基础就是,它会自动 rebase 后面的变更,因此在改了前面的后其实会看到 JJ 的提示 Rebased x descendant commits。

当然如果后代太多,也可能比较慢。我在做课程项目的时候就有一个长长的变更链,因为迟迟没法推进,每次主分支变更我就要重新 rebase 到最新,然后解决冲突,解决的时候改一下就要重新 rebase 几十个 change,时间确实可能会长一点。但说实话这是可以预见的,也完全可以接受。

写到这里还要补充一点有关远程的事情。既然 rebase 这么频繁、方便,那岂不是很容易就修改掉远程变更,如果再推送上去,那不是乱套了?

这倒不用担心,clone 下来的仓库,远程的变更是不可变的(当然,有关不可变其实还有更详细的设置,这里就不提及了),如果你要修改的话就需要使用 --ignore-immutable 选项(类似上面如果书签要逆向的话就要 --allow-backwords),完成后 jj git push,不需要什么 --force 就的的确确是强制推送了。哦不过我记得 JJ 应该是默认类似 Git 的 --force-with-lease 的吧,之前好像遇到过?

当然,Git 这边其实也已经有所改进了,例如说 Git 在比较新的版本加入了 history 子命令,看了后其实就会感觉很眼熟了,其实都是 JJ 的杀手锏,因此我也没有太仔细看了。

瞥了一眼里面还有一个 split,这个 JJ 中也有,差点忘记说了。

有很多原因需要拆分一个变更。例如说 AI 生成的代码太大了想要进行拆分,或者说本地长期「暂存」一些文件,不想排除跟踪但也不想提交等。JJ 都可以 split 就是了。

不过确实要承认的一点是,如果 split 的粒度很小,例如说只想要提取某一行时,JJ 有点麻烦。它那个交互式 split 编辑器说实话我用着不太顺手。我之前单行变更的选取用的是 Fugitive,体验不错,比 VS Code 的 Git 插件都好用。

我有的时候就是会留一些上面说的,本地长期暂存的文件。但这时候我想要搞别的,怎么办呢?当然可以继续在这个脏变更中改完,然后再 split。但其实我会 new 一个新的变更,在里面工作。完成后的情况就是这样的:@ 是我的目标变更,@- 是暂存的脏变更,@-- 是远程最新情况,目标其实就是将 @@- 交换。

这时候可以用 arrange 子命令,直接将 @@- 两个重新排序。因为脏变更中的内容一般不会冲突,所以重排序可以完美完成。

arrange 也可以进行更加大范围地重排。例如说前阵子对 Neovim 配置进行了一些工作,收尾的时候我需要对很多变更按照合适的顺序进行排列,这时候就派上了用场。当然,从原理上讲这个命令是可能遇到冲突的,但实际上我要重排的变更一般是没什么冲突的,所以这还好。

JJ 的冲突模型听说也非常优秀,但其实我没学习了解过,这里就不涉及了。

不过 arrange 现在的交互窗口有一点问题,没法滚动,如果有很大范围的移动的话确实有点麻烦。我之前遇到过,最后是自己数了一下要移动几次,即便看不到也可以操作。有一个 PR 还没合并:

2026.6.5

上面说的情况(长期本地脏变更)会影响到变更的元数据,下我是我观察测试到的现象:

2026.6.15

  • 咦,JJ empty change 变化了 author 时间也会变化(变后第一次 snpashot 的时间同时作为 author 和 committer),这很好耶,我还以为是 new 了就会有了。不过其实很多情形我都挂着脏提交就是了。
    • 再变,commit 变 author 不变,变回来,committer 也依旧会更新,不会因为两次 snapshot 一致而回退。
    • 刚 new 的 empty change 的 author 和 committer 时间也是固定在 new 的时间。
    • 再测试了一下,把 empty change 弄脏,还是一样,author 与 committer 时间更新,撤销更改变回 empty,author 不变但 committer 更新,再弄脏,author 又会更新了。果然是在 empty change 变脏的时候会更新 author 时间。

然后如果因为脏变更放太久了,author 时间太老了,也可以用 jj metaedit --update-author-timestamp 更新进行更新。

还有很多很多,你想要的基本上都已经有了优雅的解决方案。例如说你想在之前的某个变更之后再进行一些操作,可以直接 jj new --insert-beofre <revision>,直接在那之后插入了一个新的空变更等。

其实还有更为强大的工作流,例如说 Megamerges,我在这篇文章中了解的:Jujutsu megamerges for fun and profit - Isaac Corbrey。大概就是说假如有多个 PR,不用每个进行测试,而是将它们合并在一起看共同工作的情况,然后可以对应的修改,同时 absorb 回相对应的 PR 变更链。因为我还没有这样的多 PR 工作经验,就不细谈了。

虽然没多 PR 工作流,但这学期我倒是尝试过多 Agent 在同一个项目进行开发。如果同时在一个 Git 仓库进行开发,那就会混入彼此不相干的内容,如果记录 commit,那也是互相交错、盘根错节。因此 Git 有一个 worktree 子命令,而 JJ 也有一个类似的 workspace 子命令。

当然 JJ 虽然非常非常好用,但也并不是完美无缺的。我在使用过程中依旧遇到过一些问题。

Git 中如果我要忽略一个文件,但不想在 .gitignore 中记录(因为写在 .gitignore 中本身就暴露了含有这个文件的信息,而我是要什么信息都不透露,仿佛就是不存在一样),可以在 .git/info/exclude 文件中写入,这样就神不知鬼不觉了。

而 JJ 暂时没有原生的命令,但其实也可以写 .git/info/exclude 就是了,因此其实只是一个小问题。相关问题在 FR: jj file untrack --local, jj file untrack --ignore · Issue #3493 · jj-vcs/jj 有所讨论。

然后是 JJ 不支持的一些特性了,我之前看到一篇文章,写从 JJ 切换回 Git,我本来好奇是什么理由,结果一看,非常能理解。

主要应该就是几件,JJ 不支持 .gitattributes、不支持 submodules、不支持 LFS。其中对我来说第一个尤为蛋疼,后两个因为我自己都不怎么用,就不提及了。

第一个问题相关 issue 是 Add support for .gitattributes · Issue #53 · jj-vcs/jj。有关 EOL 的问题也折磨过我两回:

2026.3.28

  • 起来后昨天软工三又出事了,上面的 PDF 有问题,本地却没问题。对照看了眼估摸着是 CRLF 的问题。把 EOL 转换关了就好了。查了一下 PDF 内部可能有 CRLF 或 LF,我 JJ 开了 EOL 转换,本地可以是 CRLF,但传上去的会是 LF,然后就转了 PDF 出事了。而因为 PDF 内容格式比较简单,没啥复杂的二进制文件,没 \x00,于是被当成文本文件转换了。后面加了 .gitattributes

2026.5.9

  • 累了,PDF 的 EOL 转换又遇到了,这次甚至没法 .gitattributes 解决,虽然后面 Codex 修好了,但我也懒得看调查结果了,最后关掉了 jj config --repoeol-conversion,改成了 none,太蛋疼了。

实际上应该是从 Git 方面出手解决了,而 JJ 本身是不支持的,我后面了解到了,最终改了 EOL 相关设置才再没遇到。

然后再稍微讲讲我目前的配置,前阵子其实还让 Codex 给我补充了很多东西,但用得不多的就不列了。

首先是差异的显示,嘛这个我还琢磨了好一阵子:

1
2
3
4
5
6
7
8
9
10
[[--scope]]
--when.commands = ["diff", "show"]

[--scope.ui]
pager = "delta"
diff-formatter = ":git"

[merge-tools.delta]
diff-expected-exit-codes = [0, 1]
diff-args = ["--width=$width", "$left", "$right"]

2026.5.21

  • delta 真神人了,JJ 关掉 whitespace change 还搁那显示。好了,是我配置错了,不知为何直接将 delta 设置为 diff-formatter。得 JJ diff 格式似乎不是 git-diff,于是要用 git-diff,然后再用 delta 显示。
    • 算了,没明白我用 delta 意义何在。直接注释了用默认的。
    • ohno 不行,delta 的 diff 更漂亮一些。代码有高亮。
    • 唔,直接恢复也很不爽,因为这样后即便短内容也用 pager。
    • 最终装了一个 difftastic 作为 diff-formatter,结果就是默认忽略掉了空白……行吧行吧,空白修改本来就挺恶心的。
    • 不行啊,还是没法显示源代码……
    • 好啊,好啊,折腾了一下午,终于完美实现我的需求了。现在记录一下怎么做到的。
    • 首先是,delta 只支持 git 格式 diff,因此 diff-formatter 得是 :git,否则直接用 delta 虽然可以在绝大多数场景使用,但无法用 -b-w 忽略空白变更,这点之前就遇到过了但没在意,这次彻底解决了。
    • 官方给出的建议是再设置 pager 为 delta。但这样也不行,因为 status, log 等也会用 pager 了,而不会输出在终端上,这点甚至更痛了。
    • 解决方案是在 Delta for diffs · jj-vcs/jj · Discussion #4690 找到的,JJ 可以用 [[--scope]],设置只在某些场景使用一些设置。这太牛逼了,于是只为 diff, show 设置了这些。
    • 还有输出错误码,之前也一直遇到抱怨 delta 输出错误码不为 0,这次也一并解决了,用 diff-expected-exit-codes,允许 1。
    • 但是还有问题,side-by-side 默认宽度很窄,现在不知道啥原因了,折腾过程中遇到。然后去搞什么 $width 变量。但最终问题其实是这样的,git-diff 交给 delta,delta 再用 PAGER,也就是 moor 显示,而 moor 默认我是设置了行号的,因此宽度就更宽了,超过去了。解决方法就是设置 DELTA_PAGER,给 moor 传一个 -no-linenumbers。然后不需要 --width=$width 也可以了,不知为何就可以识别宽度了。
    • Windows 也加入了支持,设置了 PAGER(之前一直没设置)和 DELTA_PAGER,顺带将两处的 ~/.gitconfig 大概排序与同步了一下。现在应该完美了,不过 Windows delta 稍微有点卡,500 多行都要等几秒。
    • 对了,把终端不透明度改回 100% 了,感觉后面 Neovim 看代码会比较多,穿透显示已经没什么意义了。
    • 难绷,现在好不容易来看代码,又遇到了问题……所以还是要加 diff-args

不过后面 DELTA_PAGER 没有继续用 moor,还是改成了 less(但 Windows 上还是继续用 moor,因为也没别的 pager 了),因为 delta 有一个 navigation 功能,只支持 less。我权衡了一下感觉 navigation 更重要一些。

然后是签名相关配置:

1
2
3
4
5
6
7
8
[signing]
behavior = "own"
backend = "ssh"
key = "~/.ssh/***.pub"
backends.ssh.allowed-signers = "~/.ssh/allowed_signers"

[git]
sign-on-push = true

改用 SSH 签名了。

有关书签的配置:

1
2
3
4
5
6
7
8
[revset]
bookmark-advance-to = "closest_pushable(@)"

[revset-aliases]
'closest_pushable(to)' = {
    definition = 'heads(::to & ~description(exact:"") & (~empty() | merges()))',
    doc = "Returns the closest pushable commit to `to`."
}

前面提过,分支像是变更前的约束,书签像是变更后的收尾。在 Git 中,完成了只需要直接 push 就行了;而在 JJ 中,需要将书签移动到最终位置再 push。我之前一般是 jj b m main -t @/@-(因为有时候已经在最终工作后新开了一个空变更,此时就是 @-)。

对于前者现在可以直接用 jj b a 了,全称是 jj bookmark advance。后者也可以 jj b a -t @-,反正不用输入书签名了,舒服一点。

然后是一些 alias:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# config.toml
[ui]
default-command = "log"

[aliases]
n = {
    definition = ["new"],
    doc = "Create a new change."
}
s = {
    definition = ["status"],
    doc = "Show working-copy status."
}

sync = {
    definition = ["git", "fetch", "--all-remotes"],
    doc = "Fetch changes from all remotes."
}

# conf.d/50-templates.toml
[aliases]
ds = { definition = ["diff", "--stat"], doc = "Show diff statistics only." }

# conf.d/60-workflow-aliases.toml
[aliases]
d = { definition = ["diff"], doc = "Show changes in a diff." }
sh = { definition = ["show"], doc = "Show a revision's metadata and diff." }
bm = { definition = ["bookmark", "move"], doc = "Move bookmarks to a revision." }
bl = { definition = ["bookmark", "list"], doc = "List bookmarks." }
bs = { definition = ["bookmark", "set"], doc = "Create or update a bookmark at a revision." }

其实还有不少,不过是 Codex 做的,因此我只是放出来常用的。

Codex 还为我准备了很多模板、函数、别名等,同时优化了一些默认体验。例如说修改了默认的 log 模板,信息更加紧凑了:

下面是之前的效果:

1
2
3
4
5
6
7
8
@  mvysozts user@email.com 2026-08-03 15:10:02 2f3d5b51
(empty) (no description set)
vrwswtxn user@email.com 2026-08-03 14:02:06 86a95e63
│  本地变更
  zmmlmmtx user@email.com 2026-07-25 14:01:45 main c757c228
│  远程变更 1
  muqnswyr user@email.com 2026-07-25 13:53:21 76718fbe
│  远程变更 2

下面是现在的:

1
2
3
4
5
6
7
8
@  mvysozts 2f3d5b51 user 3 minutes ago
(empty) (no description set)
vrwswtxn 86a95e63 user 1 hour ago
│  本地变更
  zmmlmmtx c757c228 user 1 week ago main
│  远程变更 1
  muqnswyr 76718fbe user 1 week ago
│  远程变更 2

相应的配置(template-aliases 的 doc 是后面才添加的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[templates]
log = "compact_log"

[template-aliases]
compact_log.definition = 'compact_log(self)'
compact_log.doc = "Render the default compact two-line commit log."
'compact_log(commit)'.definition = '''
if(commit.root(),
  format_root_commit(commit),
  label(
    separate(" ",
      if(commit.current_working_copy(), "working_copy"),
      if(commit.immutable(), "immutable", "mutable"),
      if(commit.conflict(), "conflicted"),
    ),
    concat(
      separate(" ",
        format_short_change_id_with_change_offset(commit),
        format_short_commit_id(commit.commit_id()),
        format_short_signature_oneline(commit.author()),
        commit_timestamp(commit).ago(),
        commit.bookmarks(),
        commit.tags(),
        commit.working_copies(),
        format_commit_labels(commit),
      ) ++ "\n",
      separate(" ",
        if(commit.empty(), empty_commit_marker),
        if(commit.description(),
          commit.description().first_line(),
          label(if(commit.empty(), "empty"), description_placeholder),
        ),
      ) ++ "\n",
    ),
  )
)
'''
'compact_log(commit)'.doc = "Render one commit in the compact two-line log format."

这样看着更紧凑一点,之前 Zellij 拆多面板的时候看日志可以节约更多空间。

其他默认的模板也或多或少进行了一些改进。例如说 jj show 默认模板没有修改的概要信息,因此之前还得再跑一个 jj diff --stat,现在用不到了:

1
2
3
4
5
6
7
8
9
10
11
[templates]
show = "show_with_stat"

[template-aliases]
show_with_stat.definition = '''
concat(
  builtin_log_detailed,
  surround("Stat:\n", "\n", indent("    ", diff.stat())),
)
'''
show_with_stat.doc = "Render detailed commit information followed by diff statistics."

以及写 description 的时候,也希望可以看到具体的变更:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[templates]
draft_commit_description = "draft_description_with_summary"

[template-aliases]
draft_description_with_summary.definition = '''
concat(
  coalesce(description, default_commit_description, "\n"),
  "\n",
  "JJ: Change ID: " ++ format_short_change_id(change_id) ++ "\n",
  surround(
    "JJ: This commit contains the following changes:\n", "",
    indent("JJ:     ", diff.summary()),
  ),
  "\n",
  "JJ: ignore-rest\n",
  diff.git(),
)
'''
draft_description_with_summary.doc = "Build the draft commit description with change ID, summary, and patch context."

不过 JJ 文档其实还不够完善,例如说「剪刀行」JJ: ignore-rest,文档中只在一个示例中出现了。


  1. 虽然说名字里是 Merge,但实际上更像是 Squash and Rebase。我到了这学期课程项目中用了 GitLab 才知道原来 GitHub 是多好用。在 GitLab 上如果主分支 main 有了更新(或者没开 fast-forward),那就真的会在主分支额外创建一个 merge commit,我遇到了才明白原来 GitLab 才是真正的 Squash and Merge。而 GitHub 只要没冲突,主分支更新了也可以直接接上去,实际上我感觉应该算是 rebase。 ↩︎

  2. 修订 Revision 这个概念就不展开了,总之可以用 change id, commit id,甚至书签、函数等。此外这里不严格区分单个修订和修订集的概念。 ↩︎

  3. @ 类似 Git 的 HEAD,Git 中可以 HEAD~ HEAD~3 等(不过我没怎么详细了解过),JJ 中也可以 @ @- 或是使用函数等。 ↩︎