尝试把VCS从git迁移到jj

TLDR

这不是一篇"jj 比 Git 好"的布道文,而是一个 Git power user 严谨评估和迁移到 jj 的过程记录。现阶段的结论:jj 值得在个人 repo 中作为主工作流使用,Git 退到协议和生态层。对这套 dotfiles 来说,git.nix 约 68% 的能力已被 jj 覆盖,lazygit.nix 约 55% 。

配置参考:github.com/xbpk3t/dotfiles/home/base/devops/jj.nix

本文Ref: Jujutsu: The Future of Version Control | Medium

Why: 为什么考虑 jj

起点:worktrunk 与 AI agent 工作流的摩擦

我的日常开发大量依赖 AI agent。工作流大致是:

  1. 接到一个 task,创建一个 worktree
  2. AI agent 在这个 worktree 里完成开发
  3. 提 PR,merge 后删除 worktree

这种模式用了很久,但积累了几个越来越疼的问题:

worktree 的数量膨胀。 并行任务多的时候,.worktrees/ 目录里躺着一堆 stale worktree。每个 worktree 有自己的 index、build cache、node_modules(如果没处理好 excluded patterns)。Git 本身不提供"这个 worktree 还活着吗"的语义,全靠在外部工具层面管理。

同一 branch 不能多 worktree。 Git worktree 的限制(每个 branch 只能被一个 worktree checkout)意味着多个 agent 要基于同一 base 工作时,必须创建五花八门的临时分支名。分支命名本身就成了一个 overhead。

build cache 的浪费。 每个 worktree 独立 checkout,如果项目有编译产物缓存(.cache、.turbo、.next),跨 worktree 共享很麻烦。worktrunk 提供了 copy-ignored 机制来规避,但终究是 workaround。

state 管理的脆弱性。 Git worktree 的 index 文件可能过期,.git/ 里的 HEAD 可能指向不存在的 commit。worktrunk 的 post-start hooks 在后台运行,失败不会阻塞(但也不通知你)。

这些不是 worktrunk 的问题——worktrunk 做得很好。这是 Git worktree 架构的上限。

"每个 commit 都是历史" vs "每个 change 都可编辑"

使用 Git 几年后,我发现我的工作流在反复围绕同一个矛盾:

  • Git 的模型是"commit = 不可变快照"。你要通过 rebase 来"改写"历史,本质上是在旧的旁边创建新的 commit,然后把指针移过去。
  • 实际开发中,我需要的模式是"这个 change 还在编辑中"——改代码、做尝试、拆开、合并、甚至抛弃。

Git 的 staging area 和 index 为此而生,但它本质上是一个被设计成防止你犯错的工具,而不是鼓励你尝试的工具。

jj 的核心设计哲学完全不同:

  • working copy 本身就是 commit
  • 自动 snapshot,不用担心"我改了半天忘 commit 了"
  • 操作级别的 undo(jj op log + jj undo),不用心惊胆战地去 reflog 捞
  • conflict 可以作为状态保留在 commit 里,不阻塞后续工作

What: 兼容性、能力覆盖与生态现状

jj 是否能兼容 git

这是开始评估前必须搞清楚的问题。答案是:能,但不是无痛兼容。

jj 通过 colocated 模式与 Git 共存于同一个 repo:

# 在已有 Git repo 中初始化
cd your-repo
jj git init --colocate

初始化后,.jj.git 目录并存。jj 的 object store 和 Git 的 object store 保持同步——jj git push 把 jj 的 change 导出为 Git commits,推送到远端。远端看到的仍然是正常的 branch 和 PR。

核心约束是一条铁律:Git 只读,jj 修改。

  • 在 colocated repo 里,Git 会处于 detached HEAD。用 git checkout / git commit 等 mutating 命令很容易造成状态混乱。
  • 只读 Git 命令(git loggit diffgit blame)可以正常使用。
  • Git LFS 和 submodules 不支持。hooks、.gitattributes 等 Git 机制在 colocated 模式下也不会生效。
  • IDE 后台 Git 操作(自动 git fetch/git checkout)可能破坏 jj 的状态感知。如果你的 IDE 有自动 Git 操作且无法关闭,colocated 模式会遇到频繁的 state confusion。
  • jj 的 bookmark 不会像 Git branch 一样随新 commit 自动前进,需要用 jj bookmark move 显式管理——这是最容易被忽略的习惯差异。
  • Shallow/partial clone 支持有限,大 repo 初始 clone 需要完整历史。Colocated 模式下 refs 同步在高频操作时可能变慢——jj 官方 release note 会标注相关改进。

这条"Git 只读,jj 修改"的原则,是使用 jj 过程中最重要的规则。违反它的后果通常是两边的状态都变得难以解释,只能回退到 reflog 或 op log 去排查。

能力覆盖矩阵

有了兼容性前提,再看具体能力覆盖。以下基于我 dotfiles 中的 git.nix 配置做评估。

先给框架:任何工具迁移的评估都可以系统化为三层过滤——完全覆盖、部分覆盖、未覆盖。 不只是 VCS,编辑器、CI、部署工具的切换都可以套用。方法是:列出所有工具的核心能力(不是工具名,而是能力),然后分层评估,最后看 blocker 是否在可接受范围内。

完全覆盖(核心 VCS)

场景 Git jj 备注
commit git commit jj describe/jj new 无 staging area,需要适应
diff git diff jj diff/jj show difftastic 集成更好
log git log jj log revset 比 git log 的参数组合更灵活
push git push jj git push bookmark 映射到 branch
pull git pull --rebase jj git fetch + jj rebase 更显式的两步操作
signing git commit -S jj describe --sign SSH signing 配置一致
merge/rebase git merge/git rebase jj rebase/jj new rebase 是日常操作而非特殊动作
stash git stash 自动 snapshot / jj workspace 无需显式 stash
reset/undo git reset / reflog jj undo / jj op log 操作级 undo,大幅度降低心理负担

部分覆盖(工作流工具)

  • worktrunk(~40%):jj workspace 对应其核心价值(并行工作区创建/切换/管理),但 LLM commit message 生成、merge hooks、自动 branch cleanup 等运维自动化需要额外配置。
  • git-extras(~30%)git summary / git effort 等只读统计可从 jj revset 部分查到;git squashjj squash 对应。但 git releasegit browsegit obliterategit sed 无直接对应。
  • git-quick-stats(~25%):贡献者统计、活跃时段等可通过 jj revset + template aliases 拼出,但不如其开箱即用。

未覆盖(生态工具)

  • git-lfs:最大的缺口。jj 不支持 LFS,只能在 colocated repo 中用 Git 操作 LFS。
  • gitleaks / gitlint:扫描/检查工具仍可在 colocated 模式下工作,但只会看到 Git-visible 的历史。
  • gh browse → TUI 集成:lazyjj / jjui 的功能覆盖大约为 lazygit 的 55%,差距主要在 stash 管理、交互式 rebase、custom commands 等高级功能。

总结估算

  • git.nix 约 68% 被覆盖:核心 VCS 操作因为两套工具共享 Git 协议层,基本都有对应。差距在生态工具和自动化运维。
  • lazygit.nix 约 55% 被覆盖:lazyjj 和 jjui 的 diff/browse/amend 可用,但交互流畅度和功能丰富度还有距离。

jj 生态的现况

jj 本身是 CLI,但围绕它已经有一些有价值的工具:

  • lazyjj:类似 lazygit 的 jj TUI,支持 change log、diff 浏览、amend 等核心操作
  • jjui:功能更强的 TUI(我的 dotfiles 中已配置 jjui,带 SSH agent wrapper 和自定义 edit file 快捷键)。但它的交互方式和 lazygit 不同,不能直接套用 lazygit 的操作习惯
  • jj-fzf:基于 fzf 的模糊搜索,快速定位 change ID 或 bookmark
  • jj-vine:面向 stacked PR/MR 的辅助工具,适合多人 review 场景

TUI 生态是我认为 jj 目前最大的体验缺口。lazygit 经过多年迭代,交互设计和功能覆盖已经到了"可以替代大部分 CLI 操作"的程度。而 jjui 和 lazyjj 目前能覆盖的大约只有 lazygit 的 55%——核心的 diff/browse/amend 可用,但 stash 管理、交互式 rebase、custom commands、文件级 staging 挑选等高级功能还有差距。


How: 从 Git 到 jj 的常用操作指南

开始之前:在已有 Git repo 中执行 jj git init --colocate 完成初始化。之后遵循 "Git 只读,jj 修改" 原则——Git 处于 detached HEAD,不要用 git checkout/git commit 等 mutating 命令。下面所有操作都在 colocated jj 环境中进行。

1. commit & push — 日常核心工作流

没有 staging area,怎么管理不同 change?

这是 jj 和 Git 的核心思维差异。Git 的 staging area 让你在 commit 前精细挑选文件。jj 则相反:改了就是改了,直接 jj describe 就把当前所有变化标记为当前 change。

但这就引出一个问题:如果我想同时做两个不相关的改动,怎么避免它们混在一起?

有三种策略:

事前隔离(推荐,90%+ 场景):每件事开一个 change,像开不同的工作区。

jj new -m "feat: add login api"
# ...改 login 相关文件...
jj describe

事后拆分:不小心混在一起了,用 jj split 拆开(见下节)。

选择性 squash:少数场景用 jj squash --partial 把别的 change 的部分改动合并过来。

★ 事前隔离的流程是"开新 change → 改 → describe → 再开新 change",不是在 staging area 里挑选。心智模型从"改了 → 选 → 提交"变成了"改了 → 描述"。对 AI agent 来说,不需要理解 staged/unstaged 的区别,只需要知道当前在哪个 change 里。

写完了就推上去,和 Git 一样简单:

# 首次推送,自动创建远端 bookmark (branch)
jj git push -c '@'

# 后续推送(bookmark 已存在)
jj git push

★ 唯一要注意的习惯差异:bookmark 不自动前进。Git 的当前 branch 指针随新 commit 自动移动,jj 需要你显式管理 bookmark。jj git push 只推送已有 bookmark 指向的 change,新 change 需要用 -c 指定。

2. PR — 创建 bookmark 并提交流程

jj 的 bookmark 映射到 Git branch。想要提 PR,先要有 bookmark。

代码改完了,想提 PR:

jj bookmark create luck/luc-25-blog-for-llm   # 给当前 change 打 bookmark
jj git push                                    # push bookmark → 远端创建同名 branch
gh pr create                                   # PR 照常提

如果还没创建 bookmark,jj git push -c '@' 可一步到位(首次推送会自动创建远端 bookmark)。

★ bookmark 和 change 是分离的概念。change 是"你正在编辑的内容",bookmark 是"给某个 change 贴的名字(映射到 Git branch)"。Git 的 branch 指针随 commit 自动前进,jj 需要你显式管理。更新 bookmark 指向用 jj bookmark move,删除用 jj bookmark delete

3. split — 把混改拆成独立 commit

多个不相关的修改不小心混在一起了。Git 的做法是用 git add -p 逐段 staging,在 commit 前精细挑选。jj 不需要——你先 jj describe 存下来,再拆。

jj split  # 交互式选择哪些文件/行属于第一个 change

拆分完成后,原来的 change 被拆成两个独立的 change。

★ jj 是"先合再拆",方向与 Git 相反。Git 鼓励你在 commit 前精细挑选,选好了再提交。jj 默认"当前所有修改都属于当前 change",commit 之后用 split(拆)或 squash(合)来整理。对 AI agent 工作流来说,这更自然——agent 先一股脑改完,你再决定怎么分。

4. pull / sync — 同步远端变更

远端有新 commit,拉取到本地。Git 的 git pull --rebase 把 fetch 和 rebase 合为一步。jj 拆成两步,更显式:

jj git fetch              # 拉取远端所有更新
jj rebase -d main@origin  # 把当前 change 重新基于最新的 main

★ jj 的 rebase 不痛苦——因为 jj 能保留 conflict 在 commit 中作为状态,不阻塞后续工作。你可以在有 conflict 的情况下继续改代码,之后再来解决。这跟 Git 的"rebase 过程中必须马上解决所有冲突"完全不同。

5. undo — 撤销操作

jj describe 写错了、不小心 jj new 了、rebase 错了——任何操作都可以撤销。

jj undo              # 撤销上一步操作
jj undo --undo       # 重做(redo)
jj op log            # 查看操作历史,找到要回退到的操作
jj undo <operation>  # 撤销指定操作

★ 操作级撤销,不用心惊胆战。Git 的 reflog 记录的是 HEAD 的移动历史,你得找 commit hash,然后 git reset --hard——一个误操作可能丢工作。jj 的 op log 记录的是你执行过的操作(describe/rebase/new/split 等),jj undo 直接撤销操作本身。这是 jj 最让我安心的特性。

6. switch context — 多任务切换(jj workspace)

为什么普遍认为 jj workspace 比 Git worktree 好?

Git worktree 有几个结构性缺陷:每个 worktree 有独立的 .git、index 易过期、同一 branch 只能 checkout 一次。AI agent 需要理解多目录结构,出错概率高。jj 的 workspace 共享 object store、在同一目录切换、自动 snapshot——对 AI agent 极其友好。

jj new              # 存档当前进度,开新 change(日常最常用)
jj edit <change>    # 切到已有 change 继续编辑
jj workspace new    # 正式多工作区(共享 .jj store)

jj new 本质上等于 Git 的 "stash + 切到干净状态" 一步到位。不需要显式 stash,jj 已经通过自动 snapshot 帮你保存了当前状态。同目录切换意味着 AI agent 不需要理解 worktree 路径、不需要 cd、不需要处理多个独立的 .git。

7. view — 查看状态

当前有什么改动?查看方式跟 Git 类似:

jj status    # 查看当前 change 和 working copy 状态
jj diff      # 查看变更内容(默认集成 difftastic)
jj log       # 查看 change DAG
jj log -r 'main@origin..'  # revset:只看本地有但远端没有的 change

jj log 的 revset 比 git log 的参数组合更灵活。jj log -r 'author(luck) & description(fix)' 这样的查询一次表达。revset 是 jj 的"查询语言",熟悉之后能大幅提升查看效率。

zzz


› 有个问题哈,这个jj的bookmark怎么作为worktree来提PR呢?

  我在goland的changes里看到的是所有bookmark的changes都混到一起了

  做个判断

我的判断:jj bookmark 不能当 worktree 用。

jj 里:

  • bookmark 是“指向某个 commit 的名字”,更接近 Git branch/ref,适合当 PR 的 head。
  • workspace 才是“工作目录”,对应 Git worktree。

你现在这个仓库只有一个 workspace:jj workspace list 只有 default;而 git status 里本地 Git 还是 main。所以 GoLand 看到的是“一个 Git worktree 的当前文件状态”,它并不理解 jj bookmark,自然会把当前 workspace 里的累计 diff 混成一坨。严格说,不是“所有 bookmark 的 changes”,而是“当前工作目录相对 Git base 的总 diff”。

怎么做

  • 如果你要“一个 PR 一套独立的 GoLand Changes”,就用“一个 PR 一个 jj workspace”。
  • bookmark 只负责给这条 PR 命名,不负责隔离工作目录。
  • 推 PR 时,把 bookmark 推到远端,GitHub 上它就是同名 branch。

新开一个独立 PR workspace,推荐这样:

jj workspace add ../dotfiles-luc-51 --name luc-51 -r main@origin
cd ../dotfiles-luc-51

jj bookmark create luck/luc-51-handle-dotfiles-todo

# 开发...
jj git push -b luck/luc-51-handle-dotfiles-todo
gh pr create --head luck/luc-51-handle-dotfiles-todo --base main

如果是已有 bookmark,要单独开 workspace:

jj workspace add ../dotfiles-luc-51 --name luc-51 -r luck/luc-51-handle-dotfiles-todo

这样新目录里的文件状态就只对应这条线,GoLand 打开这个目录时不会和别的 PR 共用一个 Changes 视图。

关键原则

  • 如果两个 bookmark 是 stacked 的,后面的 bookmark 就算放进单独 workspace,PR 到 main 仍然会带上前面的祖先提交。
  • 独立 PR:每个 bookmark 都直接基于 main
  • Stacked PR:后一个 PR 的 base 设成前一个 PR 的 branch/bookmark,不要都对 main
  • 如果你主要依赖 GoLand 的 Changes 和内置 PR UI,最稳妥的模型就是:workspace = PR 隔离单元bookmark = PR 分支名

如果你要,我可以直接按你现在这个 luck/luc-51-handle-dotfiles-todo 给你列一套最少命令,把它变成一个独立 workspace 并按这个流程发 PR。


决策参考:什么时候该 / 不该迁移

Tip

适合尝试的场景

  • 个人项目为主,团队协作仍通过 Git/GitHub PR,不干涉你本地用什么工具
  • AI agent 驱动的多尝试开发,频繁 create / rebase / squash / split
  • 对操作级 undo 有强烈需求(jj undogit reflog 安全得多)
  • 愿意接受"先粗后细"的 commit 管理(先 describe 再 split/squash 调整)

建议谨慎的场景

  • 项目重度依赖 Git LFS 或 submodules
  • IDE 后台自动执行 mutating Git 操作且无法关闭
  • git add -p 的精细 staging 控制是日常刚需
  • 不能接受"Git 只读,jj 修改"的纪律要求

不在上述"谨慎"列表里的项目,都值得从 jj git init --colocate 开始试一周。最大成本不过是 rm -rf .jj 回到纯 Git。

Reviewing large changes with Jujutsu - Ben Gesoff