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

Why a small .gitattributes detail kept us from adopting Jujutsu as our Git-compatible VCS, and what we use instead.

Table of Contents

Introduction

Jujutsu (jj) has been picking up steam as a Git-compatible version control system.

JJ Github intro

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.

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.

The actual blocker: gradlew.bat and .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:

*.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.

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.

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.

JJ Github comment

jj’s author suggests one route on the issue: a non-colocated workspace (jj git init --git-repo=<path> 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.

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. 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.

What we’re sticking with: 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:

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.

We’re not done with 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.

Discuss