jj for developing Go

Michael Pratt

Last updated: 2024-12-28

I use jj on a day-to-day basis for all of my contributions to the Go project. Go uses Gerrit for code review, which is more complex to work with from jj than GitHub. This note describes my configuration and workflow.

Note: While this document is focuses on working with the Go open source project specifically, most of the contents are generally applicable to any Gerrit-based project. The main difference is that some Gerrit servers allow SSH authentication (rather than .gitcookies).

This page assumes basic familiarity with jj. For more guidance on jj itself, see the references below.

References

Installation

See upstream instructions.

Gerrit Configuration

Working with Gerrit from jj presents three critical complications:

Note: jj has an open issue for explicit Gerrit command, plus an in-progress PR for a jj gerrit command that promises to make this more polished.

My approach to address these limitations is:

  1. The repo is cloned with jj using the standard unauthenticated URL. e.g., https://go.googlesource.com/go. I call this remote origin (the default). This allows me to fetch updates using the built in jj git fetch command.
  2. The repo is created as a colocated repository. By default, jj hides the underlying git repository somewhere in .jj/ directory. In a colocated repository, a .git directory is created just like any other git repository. This makes it possible to use normal git commands on the repository.

Warning: Mixing jj and git commands can easily get the repository in a confusing state. I recommend reading the colocated repository documentation. I limit my use of git commands to the ones below for pushing changes to Gerrit.

  1. To add Change-Id lines to commit messages, I use a custom commit message table assigned automatically add a Change-Id line to commit messages.

Example

Let's put it all together and contribute to a project using jj!

  1. Install jj first if you haven't already.
  2. Set up your name and email:
$ jj config set --user user.name "Michael Pratt"
$ jj config set --user user.email "prattmic@example.com"
  1. Add the Change-Id commit message template to ~/.config/jj/config.toml.
  2. Clone a repository.
$ jj git clone --colocate https://go.googlesource.com/go
Fetching into new repo in "/tmp/go"
bookmark: dev.boringcrypto@origin        [new] untracked
...
Setting the revset alias "trunk()" to "master@origin"
Working copy now at: wpwmovqk a34ab857 (empty) (no description set)
Parent commit      : rpzkxnvr 17bf224a master | crypto/internal/nistec: don't use go:embed
Added 13679 files, modified 0 files, removed 0 files
  1. Make a change and create a new commit.
$ echo "Hello from jj" >> README.md
$ jj commit
Working copy now at: mpuptrql 666d0e7e (empty) (no description set)
Parent commit      : tkqzmxpq 47f1f842 (…) jj test commit
  1. Fetch latest upstream changes (if desired).
$ jj git fetch
  1. Rebase this change on master.
$ jj rebase --source t --destination master@origin

Note: --source t comes from the change ID reported by jj commit as tkqzmxpq. In the terminal, t is colored to indicate that is the unique short version of this change ID.

  1. Push this change to Gerrit.
$ git push origin HEAD:refs/for/master
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 12 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 358 bytes | 35.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (2/2)
remote: Waiting for private key checker: 1/1 objects left
remote: Processing changes: refs: 1, new: 1, done
remote:
remote: SUCCESS
remote:
remote:   https://go-review.googlesource.com/c/go/+/630275 WIP: jj test commit [WIP] [NEW]
remote:
To https://go.googlesource.com/go
 * [new reference]         HEAD -> refs/for/master

Note: See the Gerrit upload documentation if you are unfamiliar with the Gerrit upload syntax/options.

Warning: Here we pushed git HEAD to Gerrit. In a colocated repository, git HEAD is the state of the repository when the most recent jj command was run (Even semantically read-only commands like jj status may mutate the git repository due to jj's "everything is a commit" model).

Since I just made my commit and have not modified anything else or switched to a different commit, HEAD is exactly what I intend to upload.

If that is too subtle for you, you can also specify the exact git commit you want to push. The git commit sha1 is the second random-looking string shown with commits.

i.e., in tkqzmxpq 47f1f842 (…) jj test commit, tkqzmxpq is the jj change ID and 47f1f842 is the git sha1.

Push this explicitly with git push origin 47f1f842:refs/for/master.

git-codereview

Personally, I have never used git-codereview, so I don't feel its loss when using jj.

jj has no hooks, so you won't get the built-in hooks:

Someone more familiar with git-codereview is welcome to add more documentation here!

From the source, git codereview mail looks like it may work OK (it just constructs a git push command line), but use at your own risk.

Downloading CLs

Sometimes it is nice to download CLs from Gerrit's "Download Patch" dialog, which provides a command like:

$ git fetch https://go.googlesource.com/go refs/changes/77/626277/8 && git checkout -b change-626277 FETCH_HEAD

In theory, jj can import this new branch from the colocated git repository.

In practice, I find this often makes a mess of things. In particular, if the Gerrit CL uses commits that are older versions of jj change IDs, jj gets very upset (this is normally an impossible condition in jj because jj automatically rebases changes whenever their ancestors change).

When in this state, the repository still technically works, but any time you reference one of these change IDs jj asks you which version of the change you meant, which gets old really fast.

I'd love a better solution here. For now, since I download CLs fairly rarely, I just use a separate git checkout for that purpose.

Workflow

jj has a unique model where "everything is a commit". There is no index or staging area; when you have a commit checked out any modifications automatically become part of that commit.

This has interesting implications for the workflow you might use. Steve Klabnik's Tutorial covers two popular workflows you can use with jj.

I personally use something like the "squash" workflow, where I make all changes in an empty commit, which I then either turn into a named commit with jj commit, or squash into an existing commit with jj squash.

Tips and tricks

Appendix

Change-Id template

This template should be added to ~/.config/jj/config.toml.

This extends the standard draft commit message to add a Change-Id line if one does not already exist. If you split a commit into two, make sure to remove the Change-Id line from all but one of the commits to avoid duplicates. Keep the original Change-Id on the commit you want assosciated with the existing Gerrit CL (if you have already uploaded. jj describe the new commits to re-add a Change-Id line.

Thanks to Vamsi Avula and Filippo Valsorda for this suggestion.

[templates]
# 6a6a636c is hex("jjcl")
draft_commit_description = '''
concat(
  description,
  if(
    !description.contains("Change-Id: I"),
    "\n\n" ++ "Change-Id: I6a6a636c" ++ change_id.normal_hex() ++ "\n",
  ),
  surround(
    "\nJJ: This commit contains the following changes:\n", "",
    indent("JJ:     ", diff.summary()),
  ),
)
'''