blog.chay.dev

Basic jj workflows

Assumed audience: Devs interested in improving git workflows. Probably heard of and interested to try jj.

jj is an alternative interface to git. It's getting some traction recently.

blog.chay.dev

This post is yet another contribution to the growing list of jj shilling (check out the official documentation, and some of the awesome exploration journals out there). Here, I'll show you the core workflows that I use daily, and demonstrate jj's ease of use and versatility.

Note: this is not a tutorial, so I'm skipping all the installation and initialisation steps here. Check out the links above if you need help getting started!

Write and publish code

I often start my dev journey by checking out the history of the repo.

jj log

This is just like git log, but it looks cooler, plus the main identifier is something called a "change ID", which in the jj world is much more useful than the git "commit ID".

Note: whenever commits are reordered or have their contents/message changed, so will their commit IDs. The change IDs, however, remains the same. Since jj encourages rewriting history, these static references are really handy.

I'm gonna start saying "change" instead of "commit" from here on.

After identifying the target change ID, perform a "checkout":

jj new <change_id>

This puts us at a change that's automatically created on top of the target <change_id> - that's why this command is called new.

Note: this is referred to as the "working copy commit". It is automatically updated on every file change.

Here, most tutorials will say that you should write a commit message right now by going jj describe, but I don't like doing that because it feels uncomfortable.

Whenever I feel ready to create commits, I use my editor's git toolings to stage the desired changes, and then:

git commit -m "<commit message>"

jj is designed to work alongside git, so this is not too sacrilegious.

Note: the purist "jj way" is to ignore git's staging area - a.k.a. the "index" - and use a combination of new, split, and squash to divide the code into commits. I prefer the git way because the git-editor integration is better (for now).

Once I'm ready to submit my code as a PR, I create a branch:

jj log # look up target change_id
jj branch create -r <target_id> <branch_name>

Since adopting jj, I've started to think about my commit history in terms of "commit stacks". The concept of "git branches" enters the picture only when I need to push my code - to update the main trunk, share my work with others, or submit my code for review.

Note: git branches are just movable pointers to a commit.

This is a major cognitive shift, but after I got used to it, it felt like I was released from the matrix.

Pushing is as simple as:

jj git push -b <branch_name>

Respond to PR reviews

Say a reviewer requested some edits to my code. I like to make the edits at the tip of the branch.

jj new <branch_name>

Then, I'll move the edits into the relevant changes.

jj log # look up target change_id
jj squash -i --to <target_change_id>

This brings up a special TUI that takes some time to get used to, but it's a really easy way to move lines/hunks of code from one commit to another. All the descendants of the change are automatically rebased. It's magic, try it!

Making edits to commits often calls for editing the commit message too.

jj describe <change_id>

Sometimes, the rebasing operation is not as straightforward as expected, and results in conflicts. When this happens, the editor will scream at you, so just pull up the first affected change and resolve the conflicts accordingly:

jj log # find the first change with conflicts
jj new <change_id>
jj resolve
# resolve the conflicts
jj squash

resolve is a blocking operation that pulls up the configured diff editor. I use vscode, which has a great 3-way merge editor, plus my git tooling makes it easy to inspect the resolution before squashing them in.

In my experience, resolving the first conflict often resolves everything else automatically, since descendants of changes are automatically rebased.

Push up the changes when everything is done:

jj git push

I often assume that jj will figure out which branch I'm on and push it automatically. Feels risky, but I've not been bitten by it yet.

Rebase on latest changes

My PR often gets blocked because someone merged their PR first, and repo's policy disallows outdated branches to be merged. Classic.

jj git fetch
jj rebase -d main

Rebasing often feels too easy. I get suspicious easily, so I like to run my test suites again. Once everything works, update the remote:

jj git push

Like before, there's no need to indicate that this is a force push operation. jj lowers the barrier for rewriting history, so making it feel less scary is good for those who are still wary about it.

Test a branch

When it's my turn to do PR reviews, sometimes I pull the code to test the changes.

I'm usually in the middle of doing something else. If the context switching is potentially disruptive, I'll first document the working copy commit:

jj describe

Now I'm ready to jump to the reviewee's branch.

jj git fetch
jj new <branch_name>

Note: there's no need to do any kind of stashing operation, since all work is automatically "saved" in the working copy commit.

Often, I'm curious about this branch's impact on my own task. I simply stack my commits on this branch and see what happens.

jj rebase -s <my_change> -d <branch_name>

As usual, all the descendants of <my_change> will be automatically rebased.

Rearrange commits

PRs should tell a story. Sometimes, a commit is too big, or the logical sequence of the stack doesn't quite make sense. Here's a simple scenario:

before:
A -> B -> C

C is too big, break C into two:
A -> B -> C1 -> C2

move C2 before B:
A -> C2 -> B -> C1

Splitting a commit into two separate commits is trivial in jj:

jj split -r <change_id>

This is a guided process that brings up a TUI - the same one mentioned previously for interactive squashing - for selecting the lines that belong to the first commit, then composing the commit messages for both halves. Easy.

Reordering the commits is easier:

jj rebase -r C2 --before B

If the commits are reasonably atomic, there's a good chance of avoiding conflicts.

Very often, reordering commits will result in the branch reference pointing at something you don't want. Set the branch to the right change, and push it:

jj log # identify the target change
jj branch set -r <change_id> <branch_name>
jj git push

Undo mistakes

Unlike git, jj has a really intuitive command for undoing an action, called..

jj undo

Most likely, the regret kicks in only after a series of bad decisions. I like to bring up the time machine, and inspect what the jj log is like at a particular point in time:

jj op log # pick a restore point
jj --at-op=<op_id> log

Once I find a suitable candidate op_id, I double check the diffs before doing back in time:

jj --at-op=<op_id> diff
# looks good!
jj op restore <op_id>

#dev #git