版本控制 Jujutsu

深入交流 8 views 3 replies
#1 ·

我虽然行过死荫的幽谷,也不怕遭害,因为你与我同在。
诗篇 23:4

#3 ·

如果版本控制也能变得更好用:Jujutsu 入门与实践

大家好,我是 Chris Krycho。今天我想和大家聊聊 Jujutsu 版本控制系统,简称 JJ。这个名字听起来可能有些特别。过去十年左右,对大多数开发者来说,版本控制几乎就意味着 Git。我认为,现在是时候改变这种局面了。

今年二月初,我发表了一篇很长的文章,深入讨论 Jujutsu。从去年九月前后开始,我基本上已经把它作为唯一的日常版本控制工具;在那之前,我也已经使用过一段时间。为什么我们应该考虑超越 Git?我先解释一下原因,然后会通过实际演示来说明。

为什么我想从 Git 向前迈一步

Git 在它诞生的时代,以及它所提供的能力范围内,是一款非常出色的工具。Git 出现时,我们确实需要它带来的那些能力。分布式版本控制相较于此前的非分布式版本控制,是一次巨大的进步。正在观看视频的各位中,有些人可能从未经历过 CVS,甚至 SVN 的时代,更不用说以 Polytron 版本控制系统为底层、再由 Serena 提供包装的那类工具了。我经历过那个时代,那确实是一段不太愉快的时期。

Git、Mercurial,以及 2000 年代中期出现的其他一些工具,都比此前的系统前进了一大步。突然之间,我可以在自己的机器上拥有一份完整的仓库副本,你也可以在自己的机器上拥有一份完整副本。即使我们恰好修改了同一部分内容,也没有关系;我们不必先锁定文件,再等到稍后才能提交。分支也成为了一等公民。对于过去十年才进入这个行业的人来说,这些事情可能已经理所当然了:当然可以随手创建一个分支。但在 Git、Mercurial 以及同一代版本控制系统出现之前,情况并不是这样。我绝不会想回到那个年代,不过,我确实希望继续向前发展。

过去十年我一直在使用 Git,在此之前使用的是 Mercurial。简单来说,Git 本身也值得受到一些批评。它的命令行界面以复杂难用而闻名,而且这种复杂性并不总是因为它所处理的问题本身足够复杂。对于从未使用过 Mercurial 的人来说,我们有时会把它称为一个“存在性证明”:它证明版本控制系统的命令行界面完全可以设计得很好。Mercurial 的版本控制命令行体验确实不错。

然而,大多数人最终还是使用 Git,因为 GitHub 的规模和影响力非常大,Linux 项目也极具影响力,而且大量工具都默认你会使用 Git。Git 逐渐成为了整个生态系统所预期的版本控制工具。与此同时,Git 内部也积累了很多复杂性,这些复杂性来自它的历史,以及项目背后的人所做出的选择和偏好,而这些选择和偏好并不一定符合我的想法。

这时,Jujutsu 出现了。当然,这一代还有其他值得关注的版本控制系统,例如 Meta 的 Sapling,以及作为独立项目发展的 Pijul 等。不过,在这些选择中,我最喜欢的是 Jujutsu。

Jujutsu 与 Git 兼容。这意味着,正如接下来会看到的那样,我们可以直接在现有 Git 仓库上使用它。这是它推广和采用方面的一项巨大优势。事实上,Git 当年能够成功,很大程度上也是因为它能够与 SVN(Subversion)仓库互操作。Jujutsu 的命令行界面则更多受到 Mercurial 的启发,也更符合我所认同的通用界面设计原则,而不是沿袭 Git 的设计。这一点非常好。

Jujutsu 还有一些很有意思的能力。它把冲突作为一等概念来理解,也把移动提交作为一等操作来处理,并且提供了比 Git 的交互式 rebase 更好的体验。Git 的交互式 rebase 我当然会用,也用过很多次,但它本来可以做得更好。Jujutsu 对合并提交有清晰的理解,也知道合并冲突是如何工作的。由于它能够把冲突作为日常工作流程中的一等部分来处理,所以冲突不会让整个工作停摆,也不会阻止你继续前进。你完全可以先说“这个问题稍后再解决”,然后继续做其他工作。

总的来说,Jujutsu 的使用体验在很多方面都比 Git 前进了一大步。有些差异很难在真正使用之前用语言说清楚。我现在每次因为某些原因不得不回到 Git 时——而过去六个月里,这种情况已经越来越少——都会觉得自己像是在泥地里游泳,或者像是在躲避一连串障碍物。无论用什么比喻,体验都不太好。

关于这些问题,我可以讲上一整天;你也可以去读我那篇一万字的文章。不过,我们不如直接看一些例子。接下来几分钟,我会带大家了解 Jujutsu 的基本工作方式。最后,我还会提到社区中另一位成员制作的一个视频:他正在开发一个基于 Web 的用户界面,并计划把它放进一个轻量级的包装应用中,从而能够分发到 Windows、Linux 和 macOS。它的用户界面是基于 Web 技术构建的,使用了一项叫作 Tauri 的技术。关于这些内容,之后可以再去了解更多。虽然我接下来展示的终端演示已经很不错,但那个界面在某些方面甚至更好。不过先别离开,我们现在就开始。

在现有 Git 仓库中使用 Jujutsu

我现在使用的是 iTerm2。先从一个实际例子开始:克隆一个我知道很适合演示的仓库。我把它简称为 tm。True Myth 是一个我经常使用的包,也是我大约七年半前编写的项目。这里使用 GitHub 自己提供的命令行工具 gh 来克隆仓库。gh 有时会在这里卡住,不过这次没有问题。克隆完成后,我进入 true-myth 目录。

现在可以看到,这是一个有着较长 Git 历史的仓库。最近的提交大多是 Dependabot 自动生成的依赖版本更新,因此单看这段历史并没有太多值得说明的内容。我已经安装了 Jujutsu,而且安装的是最新公开版本。接下来展示的内容,都可以通过 Homebrew 安装,也可以从 Nix 软件包获取;当然,也可以使用 Cargo 安装,因为 Jujutsu 是用 Rust 编写的工具。它运行很快,也相当稳定,我很喜欢使用它。

假设我正在开发 True Myth,并且想做一些修改。首先,我可以在这个 Git 仓库里运行:

jj git init

这条命令会让 Jujutsu 接入当前已有的 Git 仓库,并导入底层 Git 仓库中的变更。此时,我还没有在本地关联任何远程分支,因此接下来可以设置跟踪关系:

jj branch track main@origin

这样就建立了一个跟踪分支,和在其他版本控制工作流中设置跟踪分支的方式差不多。

现在先看看仓库状态:

jj status

输出告诉我,工作副本(working copy)是干净的。这符合预期。不过,有意思的是,Jujutsu 会明确标识工作副本。在 Git 中,工作副本的状态与暂存区(index)密切相关;而在 Jujutsu 中,工作副本本身有明确的身份,并且对应着一个提交和一个变更。稍后我会详细解释这一点。

再运行:

jj log

可以看到一份提交历史。乍看之下,它和 git log --oneline --graph 之类的输出差不多,还没有什么特别之处。但请注意,我只是运行了 jj git init,就已经可以在这个仓库里使用 Jujutsu 了。如果查看仓库根目录,原来的 .git 目录、GitHub 相关文件以及其他内容都还在,只是多出了一个 .jj 目录。进入 .jj 后,可以看到其中有仓库数据和工作副本相关目录,这些内容保存着 Jujutsu 自己的内部状态,作用类似于 Git 的 .git 目录。

关键在于,Jujutsu 理解 Git,并且能够与 Git 协同工作。每当我们通过 Git 做了一些操作,Jujutsu 都可以导入这些变化;反过来,当我们通过 Jujutsu 修改一个由 Git 仓库作为底层存储的项目时,这些变更也会提交到底层 Git 仓库中。

例如,我创建一个文件:

touch hello.txt

然后查看状态:

jj status

Jujutsu 会告诉我,工作副本发生了变化,并且新增了一个名为 hello.txt 的文件。这和 Git 的状态信息类似。接下来,我可以给这次变更添加描述:

jj describe -m "whoa"

再运行 jj log,就能看到一个新的提交。它位于 Git 提交历史之上,提交说明是 whoa。这正是我们预期的结果。与此同时,我也可以运行:

git log

会看到同样的提交,因为刚才的操作已经同步写入底层 Git 仓库。

不过,Jujutsu 并不只是一个更好用的 Git 命令行界面。首先,Jujutsu 未来会拥有自己的底层存储后端。这意味着,它可以与 Google 的大型单体仓库(monorepo)协同工作——Jujutsu 正是在 Google 开发的;也可以使用自定义后端,例如通过网络与 FUSE 文件系统协作。它还可以使用自己的原生后端,从而支持 Git 本身并不具备的功能。

即使仍然使用 Git 作为底层存储,我们也能做一些普通 Git 工作流中不容易实现的事情。这是因为 Jujutsu 对版本控制有着不同的理解。尤其重要的一点是:它区分“提交”(commit)和“变更”(change)。

提交与变更:Jujutsu 的核心模型

在 Git 中,我们通常主要考虑提交。Git 的底层当然还有其他概念,如果你是 Git 高手,可能也了解这些内部细节,但对日常操作来说,提交通常就是最基本的工作对象。

Jujutsu 不仅有提交的概念,还有变更的概念。下面通过一个全新的演示仓库来看看它们在实践中意味着什么。

我先创建一个演示目录,进入其中,并初始化一个仓库。这里先尝试指定 Git 后端:

jj git init

如果当前目录还不是 Git 仓库,这样做就会失败,因为它并不是一个现有的 Git 仓库。那么,如果我并不在意底层是不是 Git 仓库,只想直接使用 Jujutsu 呢?当然可以:

jj init

现在,我得到的是一个原生的 Jujutsu 仓库。如果查看它的内部目录结构,会发现它和刚才的仓库大体相似。不过,如果进入存储目录,就会看到其中有一个 Git 文件夹。刚才我没有展示这一点,因为当时还不重要。这里的确存在一个 Git 仓库,它与把 .git 目录直接放在项目根目录下所得到的仓库是一样的。

Jujutsu 允许你采用这种布局,但并不要求你这么做。在与已有仓库互操作时,这种能力很有用。以后我也许会用另一段视频更深入地展示这些内容;今天我只想说明,在需要的时候,Jujutsu 可以创建一个 Git 仓库作为底层存储,但它不必把这个实现细节直接暴露给你,也不必要求 .git 目录出现在项目根目录中。

接下来创建文件 a.txt,写入内容 hello Jujutsu。然后查看状态:

jj status

可以看到工作副本发生了变化。首先介绍一个命令:

jj describe

describe 的简写是 desc。现在我给当前工作副本添加说明:

jj describe -m "add a.txt"

注意,工作副本仍然对应着同一个变更 ID(change ID)。之前我提到过变更 ID,现在看到的就是它。与此同时,它也有一个提交 ID(commit ID)。此时,我们有了一个代表工作副本状态的新 Git 提交。换句话说,工作副本既是一个提交,也是一个变更。这个设计非常巧妙,因为它意味着我们可以直接在原地持续修改同一个变更。

在 Git 中,要实现类似效果,通常需要借助暂存区,或者通过 amend 等方式修改已有提交。Jujutsu 不需要我们采用这种方式。比如,在继续做其他事情之前,我再创建一个文件 b.txt,写入 hey JJ,然后再次查看状态:

jj status

这时,工作副本的变更 ID 仍然和之前相同,但由于底层 Git 仓库中对应的提交发生了变化,所以提交 ID 已经更新。它仍然只是同一个变更。

现在我可以意识到,之前设置的提交说明不够准确:这次变更并不只是添加 a.txt,还添加了 b.txt。因此,我可以直接修改说明:

jj describe -m "add a.txt and b.txt"

由于 Git 的工作方式,底层 Git 仓库现在又有了一个不同的提交,但 Jujutsu 中的变更 ID 仍然保持不变。

这正是 Jujutsu 强大之处的一部分:一个变更的历史可以不断演化,而不必把底层 Git 提交视为不可更改的最终记录。Jujutsu 可以持续更新这个变更的状态,并在需要时把旧版本保留下来供追溯。

追踪变更的演化与恢复操作

如果我想看看这个变更是如何随时间演化的,可以运行:

jj obs log

这里的 obs 是 observation 的缩写。这个命令也许将来会改成类似“evolution log”的名字;社区目前还在讨论这件事。命令输出中有一处高亮内容,它表示识别这个对象所需的最短标识符。在 Jujutsu 的很多操作中,这个标识符甚至只需要一个字母。

例如,我可以用变更 ID 对应的修订来查看演化历史:

jj obs log -r <change-id>

这样就能看到这个变更随时间发生过的变化。输出中的一些版本会被标记为隐藏,因为它们已经不再属于当前有效的变更状态。最初,这个变更只是一个空提交,没有任何文件内容,也没有设置描述;它对应着一个早期标识符。随后,在同一个变更 ID 下,它演化到了后续版本。

每当我们运行 jj status 或其他 Jujutsu 命令时,Jujutsu 都会对工作副本进行快照。如果底层使用 Git 存储,它也会创建相应的 Git 提交,同时保留同一个变更。因此,你实际上会看到多个 Git 提交,它们分别对应同一变更在不同时间的状态。之后,这些不再需要的提交可以由 Git 的常规垃圾回收机制处理。

更重要的是,如果我们想恢复其中某一个旧版本,也可以做到。我们能够看到一个变更如何通过这些 Git 提交逐步演化,并且可以在需要时恢复某个历史状态。当你想弄清楚“我刚才到底做了什么”时,这会非常有用。在 Git 中,虽然可以通过 reflog 在一定程度上做到类似的事情,但操作起来并不轻松;而在 Jujutsu 中,这种能力是直接提供的。

仓库整体也有类似的历史记录。运行:

jj op log

这里的 op 是 operation,也就是操作。它会展示整个仓库中发生过的每一次操作。这比 Git 的 reflog 更完整一些。请注意,其中不仅包括提交相关操作,也包括诸如“因为你运行了 jj status,所以对工作副本做了一次快照”,或者“因为你运行了 jj describe,所以更新了这个提交”这样的记录。你所做的每一项操作,都会出现在操作日志中。

如果想回到操作历史中的某个时间点,可以取得对应的操作修订 ID,然后运行:

jj op restore <operation-id>

这样就能恢复到那个操作发生时的仓库状态。我现在不打算这样做,因为我想保留 a.txt 和 b.txt,但这确实是一项非常强大的能力:你可以直接查看仓库的操作历史,并且回到历史中的任意一个时间点,轻松恢复当时的状态。

这也意味着,Jujutsu 自带一个非常方便的撤销命令:

jj undo

它相当于找到最近的一次操作,并恢复到这次操作发生之前的状态。任何操作都可以撤销,包括修改提交说明这样的操作。

例如,我再运行一次 jj op log,可以看到刚才修改了描述。如果我想撤销这个描述修改,只需输入:

jj undo

工作副本就回到了之前的状态:它的描述又变回只添加 a.txt。值得注意的是,这里恢复的是我们之前使用的同一个 Git 提交 ID。底层并没有在这段时间内把它垃圾回收掉,因此我们能够直接回到那个状态。

再查看操作日志,会发现刚才多了一条 undo 操作。如果我又想撤销这次撤销,因为我希望回到同时包含 a.txt 和 b.txt 的描述状态,也可以像刚才一样使用 jj op restore,恢复到撤销操作之前的那个操作状态。这样,我们就回到了原来的位置。整个操作日志也会相应地显示这次恢复。

这些能力主要体现在底层机制上。不过,大多数时候,我只是在仓库里提交代码、修改文件,并把工作推进下去。接下来看看更日常的操作。

拆分变更,创建新的提交

我再创建一个文件 c.txt,写入 neat JJ is cool,然后运行:

jj status

这时我意识到,我并不想让 c.txt 成为当前这个提交的一部分,而是希望把它放进一个新的提交里。

在 Git 中,我需要使用暂存区来控制哪些内容属于某个提交、哪些内容不属于它。而在 Jujutsu 中,我只需要运行:

jj split

这里我使用的是一位贡献者提供的、内置的语义化 diff 编辑器(semantic diff editor)。在编辑器中,我可以把 a.txt 和 b.txt 保留在第一个部分。这个部分的提交说明可以是 add a and b,这和之前的内容一致。然后,我可以为第二个部分添加说明:add c.txt。

现在,我就得到了两个部分。第一个部分仍然是原来的那个变更,变更 ID 没有变化,但提交 ID 更新了;第二个部分则是一个新的变更。运行 jj log,就可以看到它们之间的关系。

我经常需要做这种事情。过去,我通常使用 Git 暂存区来完成;而在 Jujutsu 中,我可以一直修改工作副本,直到它达到自己满意的状态,然后输入:

jj new

这样就会创建一个新的提交。

前面我展示过 jj describe。Jujutsu 也有 jj commit 命令,可以像之前那样直接使用一条消息提交。但 jj commit 实际上只是 jj describe 和 jj new 的简写组合。也就是说,我可以在任何时候创建新的提交,也可以在之后单独修改它们的描述。

例如,假设我现在位于一个新的空提交上,准备继续做一些工作,但我不喜欢当前的描述。我可以运行:

jj describe -r <revision> -m "add c.txt to the history"

这里需要指定要描述的修订。只要给出对应的修订标识符,就可以修改那个提交的说明。完成后,Jujutsu 会提示它对一个后代提交进行了 rebase。原因是,我修改了这个提交的父提交。记住,在 Jujutsu 中,@ 符号始终表示当前所在的位置,也就是当前工作副本在仓库历史中的位置。

再看一下日志,整体结构仍然和之前一样。我现在位于一个空提交上,但它的父提交说明已经被修改为 add c.txt to the history。我还可以给更早的那个提交换一个完全不同的描述,例如把它改成 start it up with a and b。执行后,Jujutsu 会对两个后代提交进行 rebase。也就是说,我只是在提交图中修改了一个较早提交的描述,位于它上方的提交也会随之重新定位。这种操作在 Jujutsu 中很自然;如果使用 Git,通常需要执行 git rebase --interactive,而且过程会更繁琐。

在历史中移动,并在原地修改提交

现在我再创建一个文件 d.txt,写入 D,然后运行:

jj status
jj commit -m "add D"

这里没有什么特别之处。

不过,如果我想回到之前的某个提交,并对它进行修改呢?在 Git 中也能做到,但通常需要使用交互式 rebase,再配合 amend、squash 等操作。在 Jujutsu 中,我只需要移动到想编辑的修订:

jj edit <revision>

例如,我想给 c.txt 增加一些内容,就可以切换到对应的提交。此时再看仓库状态,@ 表示我当前所在的位置。我只是向后移动到了历史中的一个提交,这并不是什么问题。刚才创建的 D 就不再处于当前工作副本的位置了。

Jujutsu 会自动把后代提交 rebase 到更新后的工作副本上,因为我们正在原地修改这个变更。它会提示有一个后代提交被重新应用到工作副本上。我们现在编辑的是同一个变更,而不是必须创建一个全新的提交,再手动处理后续历史。这种方式很不一样:在 Git 中,你并不能轻易获得这样的自动 rebase 行为。

如果我想在两个提交之间再插入一个提交,也同样很简单。我可以运行:

jj new <revision>

例如,指定某个提交作为新提交的父提交,就能在它和后续提交之间创建一个新的空提交。Jujutsu 会自动对后代提交进行 rebase。现在我位于这个新创建的空提交上,可以做一些不同的事情:再次创建 d.txt,写入 different way to get D in the repo,然后给这个提交添加描述。

这样就可能产生一个冲突。问题在于,之前的自动 rebase 已经把一个后代提交重新应用到当前工作副本上,但我们现在又在另一条历史路径中添加了 D。于是,同一个文件在两个提交中都发生了变化。

我们可以切换到另一个提交查看状态:

jj edit <revision>
jj status

这里会出现冲突,因为这个提交也添加了 D。文件中会出现冲突标记。从外观上看,它与 Git 中常见的冲突标记没有太大区别。但有意思的是,Jujutsu 允许我们带着这个冲突继续工作,并不会因此陷入停顿,也不会强迫我们立即解决冲突。

我完全可以说:“这个问题稍后再处理,我现在还有其他事情要做。”即使当前存在冲突,也可以继续创建新的文件、修改其他内容。例如,我再创建一个 e.txt,然后继续工作。Jujutsu 对这种情况完全没有问题。即使 d.txt 仍然处于冲突状态,我也可以继续向前推进。

甚至在冲突之后,我仍然可以继续修改这个文件。Jujutsu 不会因此阻止我。查看状态时,d.txt 依然显示为冲突,但我仍然可以继续创建新的提交。之后,我们再回来处理这个冲突即可。

在不停止工作的情况下解决冲突

现在我们来解决刚才留下的冲突。大致有两种方式:第一种是直接编辑冲突文件,就像刚才看到的那样;第二种是基于冲突所在的提交创建一个新的提交,在这个新提交中完成修复。

到目前为止,我运行 jj new 时都没有带参数。实际上,可以给它传入一个修订标识符,这个修订就会成为新提交的父提交。例如,我可以基于之前那个存在冲突的提交创建一个新提交。这样,我就创建了一条分支。

从 Jujutsu 的角度来看,分支并不是什么特别复杂的东西:我只是创建了一个以某个提交为父提交的新提交,它自然就与原来的提交分开了。现在我已经切换到这个新提交,可以在这里解决冲突。

最简单的方式就是直接编辑文件,保留冲突两侧都需要的内容,并调整相应的标点。完成后,合并冲突就解决了。再查看状态,这个提交已经没有冲突。

接下来,我希望把这个解决冲突的提交合并到它的父提交中。如果从 Git 的思路考虑,我可能需要先 squash,再执行 rebase,才能让最终历史变成自己想要的样子。但在 Jujutsu 中,我不需要这么做,只需运行:

jj squash

这会把当前提交的内容压入父提交。输出会告诉我,有一个冲突得到了解决。再看提交图,原来那个冲突已经消失了,不过另一条分支或变更中仍然存在冲突。这是因为我之前还在那条路径上给文件添加过一些内容。

因此,我可以再创建一个基于那条变更的新提交,检查文件内容。这里的状态也是合理的:我们之前确实把一个带有冲突的变更提交了,所以现在仍然能看到一些额外的冲突内容。不过,我可以检查文件,发现实际上只需要保留其中一部分。使用能够理解差异的工具处理这类内容也完全没有问题。现在,我再次解决冲突。

运行:

jj status

可以确认冲突已经解决。接下来仍然可以把当前提交 squash 到父提交中。之前存在的冲突已经得到解决,或者被放弃;如果某些冲突内容不再重要,也可以直接丢弃。现在,仓库里已经没有冲突了。之前为了修复冲突而创建的额外分支也消失了,因为我们把那个额外的空分支提交合并掉了。于是,我们可以继续正常工作。

这其实很有意思。在 Git 中,这样的操作可能会非常痛苦,也很难处理;而在这里,我们刚刚完成了整个过程。我甚至是用比较困难的方式完成的,因为我直接在 d.txt 里用普通文本编辑器处理了冲突。如果愿意,我也可以使用:

jj resolve

配合 diff 编辑器来解决冲突。以后的视频中,我会展示这种方式,也会进一步说明 Jujutsu 在 rebase 场景下还能如何发挥作用。

总结:版本控制可以更自然

今天的演示已经差不多有三十分钟了,先讲到这里。最重要的是,希望大家看到:Jujutsu 很有意思,它能够完成一些在 Git 中比较困难的操作,而且整个过程又显得相当自然。它的界面设计不错,创建分支也很容易,之后我们还会继续讨论这些内容。

总体而言,使用 Jujutsu 的感觉就是很多事情本来就应该如此。等你再回到 Git 时,可能会突然意识到:“等等,我这些年来一直都是这样工作的?”是的,过去确实如此,但其实我们不必一直这样。

希望这次对 Jujutsu 的深入入门介绍对你有所帮助。之后我还会在某个时候继续制作相关内容。如果你想知道后续视频什么时候发布,可以订阅频道。如果你喜欢这期视频,也请点个赞,并把它分享给其他人,帮助更多人发现这项内容。

感谢观看。

我虽然行过死荫的幽谷,也不怕遭害,因为你与我同在。
诗篇 23:4

#4 ·

我虽然行过死荫的幽谷,也不怕遭害,因为你与我同在。
诗篇 23:4