在 Magit 中使用 Git Worktrees
坦白说,直到不久前,我甚至不知道 git worktrees 的存在。这功能在 git 里已经存在了十年1,而我从未觉得需要它。功能分支完全够用:建个分支,干活,合并,删掉分支,然后重新开始。
真正让我接触到 worktrees 的,恰恰是 AI 编程代理。Claude Code 这类工具会为每个任务创建一个 worktree,这样多个代理(或同一个代理的多个会话)就能在同一个仓库上并行工作,互不干扰,也不会干扰你。突然之间,我的项目目录里挤满了像 cider-this 和 cider-that 这样的目录,我意识到自己应该搞清楚这到底是怎么回事。
Worktrees 与 Branches
分支只是指向某个 commit 的可移动指针,创建它的成本几乎为零。问题在于,一个仓库只有一个工作目录。如果要同时在两个分支上工作,就得在这个目录里来回切换。你肯定熟悉这套流程:把没做完的工作 stash 起来(或者先 commit),切到另一个分支,干活,切回来,再 unstash。虽然可行,但很繁琐。更糟糕的是,当两个分支让项目处于不同的构建状态时,每次切换都得重新编译半个世界。
Worktree 为你提供了附加在同一仓库上的额外工作目录:
$ git worktree add ../cider-smart-targeting -b smart-form-targeting
现在,~/projects/cider-smart-targeting 就成了该分支的完整 checkout,而你的主 checkout 则保持原样。对象数据库、refs、stashes 和 remotes 都是共享的——worktree 并不是 clone,所以在其中一个 worktree 里 fetch,等同于在所有 worktree 里 fetch,创建过程也几乎瞬间完成。每个 worktree 拥有自己的 HEAD 和 index,git 强制执行一条简单规则:一个分支同时只能在一个 worktree 中被 checkout。
什么时候值得用?只要需要两件事同时发生就行:在一个分支上跑长测试时,你在另一个分支上干活;评审 PR 而不打扰手头未完成的代码;又或者——如今大家热议的原因——让 AI 代理在隔离环境中独立工作。代价其实很小:你的工作文件在磁盘上存了一份以上的副本,而任何 git 不跟踪的东西(依赖项、构建缓存、node_modules 等)在每个 worktree 里都得重新配置一遍。
如果你觉得分支从来都不是什么限制,那完全没问题——对我来说,十多年来都是如此。Worktree 是那种功能,直到工作流发生变化,你才意识到自己以前需要它。
那么 Jujutsu 呢?
既然聊到工作副本,当前版本控制领域最有趣的事莫过于 Jujutsu (jj)。这是一个兼容 Git 的版本控制系统,基本消除了 Worktree 要解决的问题。在 jj 中,工作副本即是一个提交,系统会随着你的操作自动创建快照。由于不存在未提交状态,也就不会有数据丢失或阻碍切换的情况,因此没有暂存区和 stash,随时切换上下文都是安全的。它还原生支持并行工作目录(jj workspace),以及操作日志,让几乎所有操作都可撤销。最后这一点也是 AI Agent 群体对其产生兴趣的原因。
你可以在现有的 Git 仓库上使用 jj(他们称之为混合仓库),同事——包括 Magit——看到的仍是一个普通的 Git 仓库。我目前只是个旁观者,但显然这是个值得关注的項目。
Magit 中的 Worktrees
回到 Emacs 世界。Magit 多年来一直支持 Worktree,隐藏在 Z 键之下:
| 按键 | 命令 | 描述 |
|---|---|---|
Z b |
magit-worktree-checkout |
在新 Worktree 中检出已有分支 |
Z c |
magit-worktree-branch |
一次性创建新分支和 Worktree |
Z g |
magit-worktree-status |
跳转到另一个 Worktree 的状态缓冲区 |
Z m |
magit-worktree-move |
移动 Worktree |
Z k |
magit-worktree-delete |
删除 Worktree |
最棒的是不需要学习其他东西。每个 Worktree 都有独立的 Magit 状态缓冲区,所有 Magit 命令都作用于当前缓冲区所属的 Worktree。Z g 是唯一需要的切换机制,甚至它只是访问其他状态缓冲区的快捷方式。
以下是来自我( admittedly 较近)的几点实用经验:
- 默认情况下,status buffer 不会列出你的 worktree。加上这段配置即可解决:
(magit-add-section-hook 'magit-status-sections-hook
#'magit-insert-worktrees
nil t)
现在每个 status buffer 都会显示仓库的所有 worktree,按 RET 就能直接跳过去。
-
把 worktree 创建在主 checkout 的同级目录,并起个有意义的名字(比如在
cider旁边建cider-smart-targeting),不要嵌套在里面——嵌套会让grep、find等很多工具出问题。 -
Magit 的分支选择界面会用 worktree 的路径标注那些已在其他 worktree 中检出的分支,并拒绝再次检出(这是 git 的规则,不是 Magit 故意为难你)。如果你曾纳闷某个分支“为什么检不出来”,通常就是这个原因。
-
对
project.el(或 Projectile)来说,每个 worktree 都是一个独立项目,所以项目切换、按项目区分 buffer、搜索等功能都能自然工作。 -
git 未跟踪的文件不会跟着走。每个 worktree 都得单独装一次依赖,而且构建缓存也是冷的。对 Elisp 来说没什么成本;但对大型 JVM 或 JS 项目来说,这是整个方案最主要的缺点。
-
用完一个 worktree 后,可以用
Z k删除(或者在命令行执行git worktree remove)。如果你是手动删掉目录的,运行git worktree prune可以清理残留的记录。
结语
现在我使用 agent 的工作流程通常是这样的:agent 在一个 worktree 里干活,我在 Magit 中审查它的改动(常常与此同时另一个任务正在第二个 worktree 里运行),分支合并后就把 worktree 删掉。也许有一天我会发现 worktree 的其他用途——走着瞧吧。
你在用 git worktree 吗?是不是也像我一样绕了弯路才发现它?欢迎在评论区分享!
今天就到这里。继续折腾(并行地)!
-
worktree 早在 2015 年 7 月发布的 git 2.5 中就已引入。 ↩