The (Petty) Reason We Didn't End Up Using jj

The (Petty) Reason We Didn’t End Up Using jj

我们最终没有使用 jj 的(琐碎)原因

June 2, 2026 | Laura Kassovic | Developer Experience 2026年6月2日 | Laura Kassovic | 开发者体验

Why a small .gitattributes detail kept us from adopting Jujutsu as our Git-compatible VCS, and what we use instead. 为什么一个微小的 .gitattributes 细节阻碍了我们采用 Jujutsu 作为 Git 兼容版本控制系统,以及我们最终选择了什么替代方案。

Table of Contents

目录

  • The actual blocker: gradlew.bat and .gitattributes
  • 真正的阻碍:gradlew.bat 与 .gitattributes
  • What we’re sticking with: git worktree
  • 我们坚持使用的方案:git worktree
  • We’re not done with jj
  • 我们并未放弃 jj

Introduction

引言

Jujutsu (jj) has been picking up steam as a Git-compatible version control system. When used with its Git backend, jj stores commits and files in Git and works with existing Git repositories, but it does away with the staging area so your working copy is represented as a real commit that updates as you go. Rewriting history is cheap and safe: edit or reorder a commit and jj rebases its descendants for you; conflicts can be recorded instead of halting the operation; and operations can be undone from the operation log. Jujutsu (jj) 作为一种与 Git 兼容的版本控制系统,正日益受到关注。当使用其 Git 后端时,jj 将提交和文件存储在 Git 中,并能与现有的 Git 仓库协同工作。但它取消了暂存区(staging area),因此你的工作副本被表示为一个实时更新的真实提交。重写历史既廉价又安全:编辑或重新排序提交时,jj 会自动为你变基(rebase)后续提交;冲突可以被记录下来,而不会中断操作;并且所有操作都可以通过操作日志(operation log)撤销。

Recently, one of our engineers set out to replace git with jj in their day-to-day flow. Unfortunately, it didn’t work out. Not because of anything fundamental about jj’s model, but because of a small detail of how Gradle projects are shaped on disk. This is worth writing down, because we suspect a lot of teams in the JVM ecosystem will hit the same wall. 最近,我们的一位工程师尝试在日常工作流中用 jj 替换 git。遗憾的是,尝试失败了。这并非因为 jj 的模型存在根本性问题,而是因为 Gradle 项目在磁盘上的组织方式存在一个微小细节。这件事值得记录下来,因为我们怀疑 JVM 生态系统中的许多团队都会遇到同样的障碍。

The actual blocker: gradlew.bat and .gitattributes

真正的阻碍:gradlew.bat 与 .gitattributes

A typical Gradle repository ships the Gradle Wrapper, which usually includes gradlew.bat. That script uses labels and goto, which cmd.exe can mishandle when a batch file has LF-only line endings, so gradlew.bat has to be checked out with CRLF to run reliably on Windows. We enforce this with .gitattributes, a file that tells Git how to handle specific files or paths, controlling things like line-ending normalization and whether files are treated as binary or text: 一个典型的 Gradle 仓库会包含 Gradle Wrapper,其中通常包括 gradlew.bat。该脚本使用了标签(labels)和 goto 命令,如果批处理文件使用 LF 换行符,cmd.exe 可能会处理出错,因此 gradlew.bat 必须以 CRLF 换行符检出才能在 Windows 上可靠运行。我们通过 .gitattributes 文件来强制执行此操作,该文件告诉 Git 如何处理特定文件或路径,从而控制换行符归一化以及文件是被视为二进制还是文本:

*.bat text eol=crlf

This is standard Git behavior: the file is stored normalized in the index, and the eol=crlf attribute tells Git to materialize CRLF in the working copy on checkout. Edits are normalized back on the way in. You forget about it after the first commit. 这是标准的 Git 行为:文件在索引中以归一化形式存储,而 eol=crlf 属性告诉 Git 在检出时将工作副本中的换行符转换为 CRLF。编辑后的文件在存入时会被重新归一化。在第一次提交后,你基本就不用再操心它了。

jj doesn’t read .gitattributes, so it can’t apply a per-file rule. There’s a long-standing issue (jj-vcs/jj#53) tracking support for at least the eol attribute, but until it lands jj’s only lever is the global working-copy.eol-conversion setting. That’s the rough equivalent of Git’s core.autocrlf: it applies to every file at once, with no way to single out .bat. jj 不读取 .gitattributes,因此无法应用针对特定文件的规则。目前有一个长期存在的问题(jj-vcs/jj#53)在跟踪对 eol 属性的支持,但在该功能落地之前,jj 唯一的手段是全局设置 working-copy.eol-conversion。这大致相当于 Git 的 core.autocrlf:它会一次性应用于所有文件,无法单独针对 .bat 文件进行处理。

In a colocated repo, the problem shows up immediately: Git stores gradlew.bat as LF and checks it out as CRLF, but jj doesn’t honor the attribute, so when it snapshots the working copy it sees the CRLF on disk and records a change against Git’s LF blob. The result is a persistent phantom modification on gradlew.bat in affected Gradle projects. And it’s not just us: one user reports six-figure file counts of phantom changes, enough that they gave up on jj over it. 在共存(colocated)仓库中,问题会立即显现:Git 将 gradlew.bat 存储为 LF 并将其检出为 CRLF,但 jj 不遵循该属性,因此当它对工作副本进行快照时,它会看到磁盘上的 CRLF,并将其记录为相对于 Git LF blob 的变更。结果就是受影响的 Gradle 项目中 gradlew.bat 会出现持续的“幽灵修改”。不仅是我们遇到了这种情况:有用户报告称出现了数十万个文件的幽灵变更,以至于他们因此放弃了 jj。

jj’s author suggests one route on the issue: a non-colocated workspace (jj git init —git-repo= outside the Git working copy). But as our engineer pointed out, that doesn’t actually solve it: jj still materializes gradlew.bat from the stored LF blob, so it lands on disk as LF, and an LF gradlew.bat won’t run on Windows. You could force CRLF with the global working-copy.eol-conversion = input-output, but that rewrites every text file to CRLF on checkout, which is its own mess. jj 的作者在该问题下建议了一种方案:使用非共存工作区(在 Git 工作副本之外执行 jj git init --git-repo=<path>)。但正如我们的工程师指出的那样,这并不能真正解决问题:jj 仍然会从存储的 LF blob 中生成 gradlew.bat,因此它在磁盘上仍是 LF 格式,而 LF 格式的 gradlew.bat 无法在 Windows 上运行。你可以通过全局设置 working-copy.eol-conversion = input-output 来强制使用 CRLF,但这会导致所有文本文件在检出时都被重写为 CRLF,这会引发另一场混乱。

There is a fix: commit gradlew.bat with CRLF directly into the repository and stop normalizing it, so Git and jj agree on CRLF in both the stored tree and the working copy. No conversion, no phantom diff, and it still runs on Windows. Concretely that means flipping the rule from .bat text eol=crlf to treating .bat as non-normalized (.bat -text) and committing the CRLF bytes once. 有一个解决方法:直接将 CRLF 格式的 gradlew.bat 提交到仓库中并停止对其进行归一化,这样 Git 和 jj 在存储树和工作副本中都会认可 CRLF。没有转换,没有幽灵差异,而且它在 Windows 上依然可以运行。具体来说,这意味着将规则从 *.bat text eol=crlf 改为将 .bat 视为非归一化文件(*.bat -text),并提交一次 CRLF 字节。

It works, but it’s fragile. That fragility is the price of the fix: *.bat -text gives up the self-healing that text eol=crlf provided. Before, a stray LF was silently re-normalized on commit. Now it sticks, and gradlew.bat quietly breaks on Windows until someone notices. It’s a one-time conversion, and a stray edit would usually get caught in review. So it may be a petty reason, but it’s a real one. The one fix that works asks every Gradle project to commit CRLF and trust that no one’s editor quietly rewrites it, and we’re not comfortable betting the ecosystem on that. 这确实有效,但很脆弱。这种脆弱性是该方案的代价:*.bat -text 放弃了 text eol=crlf 提供的自我修复能力。以前,意外产生的 LF 会在提交时被静默重新归一化。现在它会保留下来,导致 gradlew.bat 在 Windows 上悄无声息地损坏,直到有人发现。这虽然是一次性转换,且意外编辑通常能在代码审查中被发现。所以这可能是一个琐碎的原因,但它确实是一个现实的问题。唯一有效的修复方法要求每个 Gradle 项目都提交 CRLF,并信任没有人的编辑器会悄悄重写它,我们不敢拿整个生态系统去赌这一点。

What we’re sticking with: git worktree

我们坚持使用的方案:git worktree

jj’s “workspaces” would have given us multiple working directories sharing one repo, so a long CI run or an in-progress change doesn’t block the next task. We already have that in git worktree, and it’s good enough: jj 的“工作区”(workspaces)本可以让我们拥有共享同一个仓库的多个工作目录,这样长时间的 CI 运行或正在进行的变更就不会阻塞下一个任务。我们已经在 git worktree 中实现了这一点,而且它已经足够好用:

git worktree add ../gradle-feature-x feature-x
git worktree add ../gradle-hotfix hotfix-7.6

Two checkouts, one object store, no stashing, no second clone. We’ve leaned on this pattern for years for parallel work, and for keeping a long-lived main checkout warm while iterating elsewhere. 两个检出,一个对象存储,无需暂存(stashing),无需二次克隆。多年来,我们一直依赖这种模式进行并行工作,并在其他地方进行迭代的同时,保持一个长期运行的主检出处于就绪状态。

We’re not done with jj

我们并未放弃 jj

None of this is a verdict on jj for Gradle projects. jj’s operation log alone is worth a deeper look, and eol support is a tractable problem: there’s already discussion on the issue about doing it via gix-filter. When that lands, we’ll try again. Until then, gradlew.bat keeps us on Git, and git worktree does the job. If you’ve found a clean way to run jj against a Gradle repo without the CRLF churn, we’d love to hear about it. 以上内容并非对 jj 在 Gradle 项目中应用的最终判决。仅 jj 的操作日志就值得深入研究,且 eol 支持是一个可解决的问题:关于通过 gix-filter 实现该功能的问题讨论已经存在。当该功能落地时,我们会再次尝试。在此之前,gradlew.bat 让我们继续留在 Git 上,而 git worktree 也能完成任务。如果你找到了在不产生 CRLF 混乱的情况下针对 Gradle 仓库运行 jj 的简洁方法,我们非常乐意听听你的见解。