coverintroduction-introductionintroduction-what-is-jj-and-why-should-i-careintroduction-how-to-readhello-world-hello-worldhello-world-how-to-installhello-world-creating-a-repositoryhello-world-viewing-the-current-statushello-world-describing-commitshello-world-creating-new-changeshello-world-viewing-contentshello-world-viewing-changeshello-world-tracking-fileshello-world-recapreal-world-workflows-introreal-world-workflows-the-squash-workflowreal-world-workflows-the-edit-workflowreal-world-workflows-splitting-changesreal-world-workflows-absorbing-changesreal-world-workflows-editing-a-commitbranching-merging-and-conflicts-introbranching-merging-and-conflicts-anonymous-branchesbranching-merging-and-conflicts-revsetsbranching-merging-and-conflicts-mergingbranching-merging-and-conflicts-conflictsbranching-merging-and-conflicts-resolving-conflictssharing-code-introsharing-code-named-branchessharing-code-remotessharing-code-updating-prssharing-code-tagssharing-code-updating-trunksharing-code-forkssharing-code-gerritadvanced-more-advanced-workflowsadvanced-simultaneous-editsadvanced-stacked-prsadvanced-duplicating-changesadvanced-fixing-filesadvanced-running-commandsadvanced-workspacesadvanced-working-alongside-gitadvanced-non-colocated-reposfixing-problems-introfixing-problems-undoing-mistakesfixing-problems-the-operation-logfixing-problems-the-evologfixing-problems-divergent-changesfixing-problems-reverting-changesfixing-problems-bisectingcustomization-customizing-your-experiencecustomization-configuring-jjcustomization-templates

Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Steve’s Jujutsu Tutorial

For jj 0.45.1
Generated 2026-09-04 from revision movvmmml / 8b435f5

Introduction

Hi there, I’m Steve. This is a tutorial for Jujutsu—a version control system.

This tutorial exists because of a particular quirk of mine: I love to write tutorials about things as I learn them. This is the backstory of TRPL, of which an ancient draft was “Rust for Rubyists.” You only get to look at a problem as a beginner once, and so I think writing this stuff down is interesting. It also helps me clarify what I’m learning to myself.

Anyway, I have been interested in version control for a long time. I feel like I am the only person alive who doesn’t mind git’s CLI. That’s weird. I also heard friends rave about “stacked diffs” but struggled to understand what exactly was going on there. Nothing I read or conversations I had have clicked.

One time I was talking with Rain about this and she mentioned jj being very cool. I looked at it but I didn’t get it. I decided to maybe just come back to this topic later. A very common thing people have when learning Rust is, they’ll try it, quit in frustration, and then come back months later and it’s easy and they don’t know what they were struggling with. Of course, some people never try again, and some who try don’t get over the hump, but this used to happen quite often, enough to be remarkable. So that’s what I decided with jj. I’d figure this stuff out later.

Well, recently Chris Krycho wrote an article about jj. I liked it. I didn’t fully grok everything, but it at least felt like I could understand this thing finally maybe. I didn’t install it, but just sat with it for a while.

I don’t know if that is why, but I awoke unusually early one morning. I couldn’t get back to sleep. Okay. Fine. Let’s do this. I opened up the official jj tutorial, installed jj, made this repository on GitHub, followed the “cloning a git repo” instructions to pull it down, and started this mdBook.

What follows is exactly what the title says: it’s to teach me jj. I am publishing this just in case someone might find it interesting. I am very excited about jj. I feel like I’m getting it. Maybe your brain will work like mine, and this will be useful to you. But also, it may not. I make no claim that this tutorial is good, complete, or even accurate.

You can find the source for this content here. Feel free to open issues or even send me pull requests for typos and such. Zero guarantees I will respond in a timely fashion though.

Anyway, let’s get on with it.

What is jj and why should I care?

jj is the name of the CLI for Jujutsu. Jujutsu is a DVCS, or “distributed version control system.” You may be familiar with other DVCSes, such as git, and this tutorial assumes you’re coming to jj from git.

So why should you care about jj? Well, it has a property that’s pretty rare in the world of programming: it is both simpler and easier than git, but at the same time, it is more powerful. This is a pretty huge claim! We’re often taught, correctly, that there exist tradeoffs when we make choices. And “powerful but complex” is a very common tradeoff. That power has been worth it, and so people flocked to git over its predecessors.

What jj manages to do is create a DVCS that takes the best of git, the best of Mercurial (hg), and synthesize that into something new, yet strangely familiar. In doing so, it’s managed to have a smaller number of essential tools, but also make them more powerful, because they work together in a cleaner way. Furthermore, more advanced jj usage can give you additional powerful tools in your VCS sandbox that are very difficult with git.

I know that sounds like a huge claim, but I believe that the rest of this tutorial will show you why.

There’s one other reason you should be interested in giving jj a try: it has a git compatible backend, and so you can use jj on your own, without requiring anyone else you’re working with to convert too. This means that there’s no real downside to giving it a shot; if it’s not for you, you’re not giving up all of the history you wrote with it, and can go right back to git with no issues.

How to read this tutorial

This tutorial is split up into a few different sections. While I make sure that each concept is introduced before its used, if you’re okay with looking some stuff up on your own, you can skip around if you’d like.

This tutorial is intended to be read from front to back. You can even follow along with the examples if you’d like. But I’m not your dad, you can do whatever you’d like.

Here’s what each section is about:

Hello, world!

In this section, we show off the very core concepts of jj: repositories, changes, looking at history, stuff like that. This stuff is simple but also very important! Everything else is built on top of this.

If you want to skip around, I would at least consider reading this part first. Okay, just skim. Point is, if you don’t already know this stuff, you’ll have a harder time understanding later sections.

Real-World Workflows

The workflow we developed in “Hello, world!” works, but isn’t as pleasant as it should be. There are two basic workflows that are used by the majority of jj enthusiasts, and so we go over both of them in detail.

Branching, merging, and conflicts

Once you’ve got a core workflow down, the next concept involves branching, and how to merge branches. Once you do that enough, you’ll also have to learn how to resolve merge conflicts.

jj natively is a “branchless” VCS, which sounds especially weird coming from git. If you’ve ever wondered what that’s like, this part will show you how, and if you’re wondering how you interact with git’s named branches, well, you’re getting ahead of me! That’s next!

Sharing your code with others

Because you’ll be sharing your code with git users, you’ll need to understand how to use named branches in jj. We’ll cover that, and then also share how you can upload your code to GitHub (or any other remote repository, really) and even respond to pull request feedback that upstream may give you.

More advanced workflows

Now that we have a very solid understanding of jj, we can start to do very cool things. Up until now, we’ve been fitting jj into git shaped workflows, to make it easier to understand. This section will show you the power of jj’s various primitive operations by some practical workflows you may decide to incorporate into your usage of jj.

Additionally, we’ll cover two features you may never need, but should know exist: workspaces, which give you several working copies over one repository, and non-colocated repositories, which put the git repository backing your work out of other tools’ reach.

Fixing problems

At some point, you’ll probably make some sort of mistake. Maybe you combine two commits you didn’t intend to combine. Maybe you tried to fix a merge conflict, but you don’t like where you ended up.

If you’ve ever used git reflog, you know exactly what this chapter is about. But if you don’t, well, you know how some people say “you can’t ever lose your work in git” even though you absolutely can? This section will show you why it’s even more difficult to do so in jj.

Customizing your experience

Finally, jj is very customizable. We’ll talk about how you can customize various parts of jj, including its very powerful templating system for showing exactly the output you want from any command.

Hello, world!

Let’s create a new repository with jj, and show off some basic commands. Here’s what we’re going to learn:

  • How to install jj
  • Creating a repository with jj git init
  • Viewing the current status with jj st
  • Using jj describe to describe our commits in a human-friendly way
  • Using jj new to create new changes
  • Viewing the contents of your repository with jj log
  • Seeing what changed with jj diff and jj show
  • Which files jj tracks, and how to ignore the rest

How to install jj

This tutorial is written when jj is at version 0.45.1. It may work for later versions, but you also may need to adapt.

For the full range of ways to install jj, you can visit the Installation and Setup page of the official documentation. Personally, because I am a Rust developer, and jj is written in Rust, I installed my copy like this:

$ cargo install jj-cli@0.45.1 --locked

If you’re not a Rust developer, please read the documentation to figure out how to install things on your platform; I could replicate that information here, but I’m not going to waste your time.

Creating a repository with jj git init

Let’s make a new repository! First, we need a project to track. I am going to use a Rust project in this example, since it is my favorite language, but you can use whatever you’d like: we won’t be writing complex code here, just giving ourselves something to work with.

We can use cargo new to make a new Rust project, and we’ll tell it not to create any version control repository for us, so we can do it ourselves.

$ cargo new hello-world --vcs=none
     Created binary (application) `hello-world` package
$ cd hello-world

In Cargo projects, the main source file is stored in src/main.rs, whose contents look like this:

fn main() {
    println!("Hello, world!");
}

Perfect. Now, this is kind of funny, but jj doesn’t have an equivalent of .gitignore, and instead, just supports .gitignore. So let’s put this in a .gitignore file:

/target/

If you’re using another language, you may want to add something like node_modules if you’re in JavaScript, or the equivalent of whatever language you’re using.

Now that we’ve got a project, let’s initialize our repository:

$ jj git init
Initialized repo in "."

Now, you may be wondering, “why not just jj init?” The deal is this: the native repository format is still a work in progress. So we’re creating a repository that’s backed by a real git repository, because in practice, this early in jj’s life, that’s the right thing to do. There’s no jj init command at all; if you type it, jj will tell you that you probably wanted jj git init.

If you peek at the directory listing, you’ll notice something: jj git init made two directories.

$ ls -d .git .jj
.git
.jj

This is called a “colocated” repository: a .jj directory and a real .git directory side by side, over one working copy. It’s the default, and it’s what we’ll use throughout this tutorial. jj imports from and exports to the git repository on every command, so git, gh, your editor, and CI all see a perfectly normal git repo. Mind you, if you run git log right this second, it will complain that there aren’t any commits yet — we haven’t made any! Once we have some history, git log will show the commits we make with jj, and we’ll see exactly that later in the tutorial.

Because both tools share one store, we’ll let jj make changes to the repository and use git for reading it. We’ll look at the details later in the tutorial. There is an escape hatch, --no-colocate, for workflows that need to keep Git-aware tools away from the working copy. Unless we have a specific reason to make that tradeoff, the colocated default is the useful place to start.

Anyway, now we’ve got a repository! In the next section, we’ll take a peek inside.

Viewing the current status with jj st

We can view the status of our repository with jj st. Let’s run that now:

$ jj st

This is the jj “pager”, a program that shows the output of commands, and lets you scroll through them if they get really long. You can hit q to get back to your console.

You can request to not use the pager by using jj st --no-pager, or if you hate the pager and want to turn it off, you can configure that with

$ jj config set --user ui.paginate never

I am going to present the examples in the book as if this is the configuration, because managing text is easier than wrangling screenshots. The same information ends up on the screen no matter which way you prefer. Speaking of, let’s actually talk about the output of jj st:

$ jj st --no-pager
Working copy changes:
A .gitignore
A Cargo.lock
A Cargo.toml
A src/main.rs
Working copy  (@) : qzmzpxyl bc915fcd (no description set)
Parent commit (@-): zzzzzzzz 00000000 (empty) (no description set)

There’s a surprising amount of stuff to talk about here! Let’s dig into it.

Working copy changes:
A .gitignore
A Cargo.lock
A Cargo.toml
A src/main.rs

This is the first thing we need to talk about: unlike git, jj has no index. Wait, don’t close the tab! Here’s the thing: jj gives you the same ability to craft artisanal, beautiful commits that have exactly what you want in them. But it doesn’t need an index to do it.

This is a running theme with jj: it gives you fewer tools, but those tools end up having equivalent or even more power than their git counterparts. Because there are fewer tools, there’s also less to learn. Now I am not one of those “the git CLI is too complex and git is too hard to learn” people, but I do acknowledge that puts me in the minority. But let’s reframe that: if we can make something more powerful and easier? Sign me up!

We’ll get into how to reproduce the power of an index later. For now, what we need to know is that every time you run a jj command, it examines the working copy (the files on disk) and takes a snapshot. So here, it’s noticed that we’ve Added some new files. You’ll also see Modified files, and Deleted files.

Working copy  (@) : qzmzpxyl bc915fcd (no description set)
Parent commit (@-): zzzzzzzz 00000000 (empty) (no description set)

Our brand new repo shows that we have two changes. You’ll notice that the text on the second one says “commit” there, and… yeah okay so: jj has a few different concepts here. The first is a commit. Our two commits have the identifiers bc915fcd and 00000000. But there’s also the idea of a “change,” and that’s that in jj, commits can evolve over time. But we still need a stable identifier to talk about those changes, so we have a “change ID,” and that’s qzmzpxyl and zzzzzzzz. One really cool thing is that they use a disjoint set of identifiers: qzmzpxyl can never be a commit ID, but must be a change ID, and bc915fcd can never be a change ID, but must be a commit ID. This is surprisingly handy.

Anyway, we’ll talk more about commits and changes soon, and how they’re different, but first we should talk about the rest of the details here. The first part is that each repository always has a zzzzzzzz 00000000 change, and it’s always empty. This is called the “root commit” and it is the foundation of the whole repository. Given that it’s empty, jj has created a second change based on top of it, in this case, qzmzpxyl and it is tracking the contents of the working copy. Since it’s not empty, its line here doesn’t have the (empty) bit like our root change has.

Finally, both of our changes say (no description set), and that’s because we haven’t given them a description yet! We’ll talk about descriptions in the next section.

Using jj describe to describe our commits in a human-friendly way

While we can refer to our changes by their change ID or commit ID, that’s not always great. Text is a much better way to describe things for humans.

However, before we can describe commits, we have to let jj know who we are. Let’s set some quick configuration:

$ jj config set --user user.name "Steve Klabnik"
Warning: This setting will only impact future commits.
The author of the working copy will stay "you <you@example.com>".
To change the working copy author, use "jj metaedit --update-author".
$ jj config set --user user.email "steve@steveklabnik.com"
Warning: This setting will only impact future commits.
The author of the working copy will stay "you <you@example.com>".
To change the working copy author, use "jj metaedit --update-author".

Obviously, unless you’re me, you should be putting your own name and email in there.

About that warning: we created our repository before we set our identity, and jj had already made a working copy change for us at that point, stamped with whatever identity it could find. Changing the setting doesn’t reach back and rewrite it. Since our first change is about to be described anyway, let’s just fix the author on it:

$ jj metaedit --update-author
Modified 1 commits:
  qzmzpxyl bc915fcd (no description set)
Working copy  (@) now at: qzmzpxyl bc915fcd (no description set)
Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)

jj metaedit changes a commit’s metadata without touching its contents. Set your identity before jj git init next time and you’ll never see the warning.

Okay, with that out of the way, we’re ready to describe some changes.

Whenever we feel like it, we can describe our changes with jj describe. The simplest way to use it is with the -m, or “message” flag. This allows us to pass the description on the command line:

$ jj describe -m "hello world"
Working copy  (@) now at: yyrsmnoo 524d2bf4 hello world
Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)

(You may notice that the change ID changed here: that’s just some book-writing magic. I am editing this book manually, and so may make adjustments that end up giving you different change IDs and commit IDs than I do. You’ll figure it out, just match the output of your commands to the inputs you give and you’ll be fine.)

Our message, hello world, has replaced the (no description set) text. We’re gonna be able to see this whenever we look at our repository history.

For more real changes though, you’ll probably want to not use the -m flag. And, since descriptions can be set at any time, we can also change them too. Let’s try it again:

$ jj describe

An editor will pop up; I’m on Windows, so I’m getting notepad.

This window shows my original message, “hello world,” and then a bunch of lines that start with JJ: . As the final one mentions, these lines are ignored when forming the commit description. So let’s make a longer description, like this:

hello world

This is an initial "Hello, world!" implementation, nothing fancy.

More fun stuff to come.

JJ: This commit contains the following changes:
JJ:     A .gitignore
JJ:     A Cargo.lock
JJ:     A Cargo.toml
JJ:     A src/main.rs

JJ: Lines starting with "JJ: " (like this one) will be removed.

After saving and closing, we’ll get this output:

Working copy  (@) now at: yyrsmnoo ac691d85 hello world
Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)

We only see that first line, but the rest are still there.

Eagle eyed readers may notice one other change. Let’s take two of these outputs and put them next to each other:

Working copy  (@) now at: yyrsmnoo 524d2bf4 hello world
Working copy  (@) now at: yyrsmnoo ac691d85 hello world

Changing our description changed the commit ID! This is why we have both IDs: the change ID has not changed, but the commit ID has. This allows us to evolve our commit over time, but still have a stable way to refer to all versions of it.

We will come back to this more in the future, because first, I’d like to show you how to make new changes.

Using jj new to create new changes

We’re done with our first commit, and we’re ready to do more work. Let’s start that work by using jj new:

$ jj new
Working copy  (@) now at: puomrwxl 01a35aad (empty) (no description set)
Parent commit (@-)      : yyrsmnoo ac691d85 hello world

It’s that easy! We now have a new change, puomrwxl, that’s empty and has no description. But its parent is our previous change, yyrsmnoo.

Let’s check out jj st:

$ jj st
The working copy has no changes.
Working copy  (@) : puomrwxl 01a35aad (empty) (no description set)
Parent commit (@-): yyrsmnoo ac691d85 hello world

Nice, a clean working copy: all of our changes were made in yyrsmnoo, and we’re starting this change fresh.

We now technically have a very primitive, but near-complete, workflow. That’s really all you need to know to get started. To practice, let’s make another change. This time, I’m going to describe things first, before I make any changes:

> jj describe -m "it's important to comment our code"
Working copy  (@) now at: puomrwxl a0f0bc71 (empty) it's important to comment our code
Parent commit (@-)      : yyrsmnoo ac691d85 hello world

Just what we expected, still an empty change, but with a description, and our commit ID has updated while the change ID stays the same.

Let’s modify src/main.rs:

/// A "Hello, world!" program.

fn main() {
    println!("Hello, world!");
}

We can double check that jj has noticed our change:

$ jj st
Working copy changes:
M src/main.rs
Working copy  (@) : puomrwxl 7a096b8a it's important to comment our code
Parent commit (@-): yyrsmnoo ac691d85 hello world

Excellent, src/main.rs has been Modified, we have a new commit ID. Since we’re done with this change, let’s start a new one:

$ jj new
Working copy  (@) now at: ywnkulko 46b50ed7 (empty) (no description set)
Parent commit (@-)      : puomrwxl 7a096b8a it's important to comment our code

Wonderful.

Just seeing the parent change is very restrictive, though. It would be nice if we could look at all of the work we’ve done. Let’s tackle that next.

Viewing the contents of your repository with jj log

Let’s look at our chain of changes:

> jj log
@  ywnkulko steve@steveklabnik.com 2024-02-28 20:40:00 46b50ed7
│  (empty) (no description set)
○  puomrwxl steve@steveklabnik.com 2024-02-28 20:38:13 7a096b8a
│  it's important to comment our code
○  yyrsmnoo steve@steveklabnik.com 2024-02-28 20:24:56 ac691d85
│  hello world
◆  zzzzzzzz root() 00000000

As you can see, this is sort of like git log, but also very different. There’s a bunch going on here, let’s talk about various parts of this.

The very first character at the top left is an @. @ is a special name for “whichever commit the working copy reflects.” At first I kind of thought about it like HEAD in git, but that’s not correct: HEAD is the most recent commit, but @ represents the working copy, which may be “dirty” from git’s point of view. This is our first glimpse into the power of the index-less workflow, though we’ll explore that fully in the next chapter. For this moment, just realize that we have one less concept, but haven’t actually lost any of its power.

Next, we see the change ID. On the far right, we see the commit ID. This is because when viewing things this way, we almost certainly don’t care about the commit ID: we care about the sequence of stable change identifiers. Between the two is the author and the time, and on the line below, we have our description.

At the bottom, we have our root commit, but instead of an author and a time, it says root(). This is a revset, which is a feature we’ll explore later. But the short of it is this: jj has a really powerful way to select lists of revisions. root() is a function in this language (yes, it has functions) that returns the root commit.

You may also have noticed that the root commit’s symbol is a different shape than the others: instead of . That shape means the commit is immutable: jj will refuse to rewrite it. That makes sense for the root commit, which is the foundation the whole repository is built on. Right now it’s the only immutable commit we have. Once we start sharing code, jj will also protect commits selected by its immutability policy, such as trunk and tags. Trunk is the main line of development everyone shares — usually the main branch on the origin remote, which jj writes main@origin. A pushed feature commit is not necessarily immutable; we’ll return to the exact rules when we work with remotes.

One more thing: my text representation of the output of jj log was missing something. Here’s a screenshot of my terminal, and you may notice something interesting:

The output has color, and a lot of it! But the most important bit is the highlights in the revision IDs and commit IDs. See how yw is in magenta, but the rest of the ID is in grey, nkulko? Similar to git, when talking about an ID, you only need to refer to the unique prefix, and not the whole ID. So that magenta bit is showing you said prefix; we could run commands that refer to ywnkulko or yw and they’d both work. Why is it two characters? Well, because we also have yyrsmnoo. But see how the middle change, p is the prefix and uomrwxl is in grey? Because this is the only change that starts with p. But since we have two changes starting with y, we need a second character to make them unique.

It’s very cool that the UI is communicating this to us! And, it’s also why the format of change IDs is pretty cool: by only using letters K-Z, there will never be a conflict with a commit ID.

There are many more secrets to jj log, but for now, this is enough to be able to go back and look at all of your changes, so we’re gonna keep it there. Let’s recap what we’ve learned.

Seeing what changed with jj diff and jj show

So far, jj st has told us which files we’ve touched, and jj log has told us which changes exist. Neither tells us what’s actually in those changes. For that, we can use jj diff.

First, let’s give ourselves something to look at. Our working copy is sitting at an empty change, so let’s make an edit to src/main.rs:

/// A "Hello, world!" program.

fn main() {
    println!("Hello, world!");
    println!("Goodbye, world!");
}

Now we can ask what we’ve done:

$ jj diff
Modified regular file src/main.rs:
   1    1: /// A "Hello, world!" program.
   2    2: 
   3    3: fn main() {
   4    4:     println!("Hello, world!");
        5:     println!("Goodbye, world!");
   5    6: }

With no arguments it shows what’s in @: the difference between your working copy and its parent. Since your working copy is a commit like any other, that’s the same thing as “what this commit contains”.

This format can take a moment to read if you’re used to git. There are two columns of line numbers: the line’s position before, and after. A line present in both gets both numbers. A line only in the new version, like our Goodbye, has an empty left column. Deleted lines are the other way round. Suppose that instead of adding a goodbye, we had replaced the hello with something else. Then we’d see:

$ jj diff
Modified regular file src/main.rs:
   1    1: /// A "Hello, world!" program.
   2    2: 
   3    3: fn main() {
   4     :     println!("Hello, world!");
        4:     println!("Hi!");
   5    5: }

Line 4 went away and a new line 4 arrived. In a terminal this is colored, and jj highlights the words that differ rather than just the lines, which is why it’s called a “color words” diff. It’s good at showing small edits inside long lines, where git’s line-at-a-time view makes you hunt.

If you’d rather have the format every other tool understands:

$ jj diff --git
diff --git a/src/main.rs b/src/main.rs
index a4fd3383fc..ba1a7af2a4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,4 +2,5 @@
 
 fn main() {
     println!("Hello, world!");
+    println!("Goodbye, world!");
 }

This is handy when we want to paste a patch somewhere. We can also use -s to get just the summary, which is the same list of names jj st shows:

$ jj diff -s
M src/main.rs

Any change, not just this one

We can use -r to show a different change. Let’s look at the parent of our working copy, the one where we added that comment:

$ jj diff -r @-
Modified regular file src/main.rs:
        1: /// A "Hello, world!" program.
        2: 
   1    3: fn main() {
   2    4:     println!("Hello, world!");
   3    5: }

There are our two new lines, with nothing in their left columns. A file that’s new in a change looks like this for its whole contents — an empty left column all the way down, since none of it was there before.

--from and --to compare two revisions that needn’t be adjacent:

$ jj diff --from @-- --to @
Modified regular file src/main.rs:
        1: /// A "Hello, world!" program.
        2: 
   1    3: fn main() {
   2    4:     println!("Hello, world!");
        5:     println!("Goodbye, world!");
   3    6: }

This is the combined effect of two changes: the comment from the parent, and the goodbye from the working copy, in one diff. I find this useful before pushing: jj diff --from 'trunk()' shows everything our branch does.

jj show

jj diff shows us the contents of a change. jj show adds who made it, when they made it, and why:

$ jj show @-
Commit ID: 7a096b8a2a7108ebfe30e05e7d38ee0dc77b9b29
Change ID: puomrwxlkmpxwnnrtqoumzlsvqzkuxpn
Author   : Steve Klabnik <steve@steveklabnik.com> (2024-02-28 20:38:13)
Committer: Steve Klabnik <steve@steveklabnik.com> (2024-02-28 20:38:13)

    it's important to comment our code

Modified regular file src/main.rs:
        1: /// A "Hello, world!" program.
        2: 
   1    3: fn main() {
   2    4:     println!("Hello, world!");
   3    5: }

We get both IDs in full, both timestamps, the description, and the diff. jj show -r takes a revision in the same way. I tend to use jj show when I’m inspecting somebody else’s commit, and jj diff when I’m checking my own work.

Note the two timestamps. Author is when the change was first made; committer is when this version of it was written. Rewrite a commit — amend it, rebase it — and the second moves while the first stays put.

Comparing two versions of the same change

There’s a third command for a situation git doesn’t handle very well. Let’s say we’ve pushed a pull request, someone has reviewed it, and then we’ve rewritten the change. We may want to know what changed between those two versions, rather than what the change itself does.

$ jj interdiff --from 64fe802a --to @
Modified commit description:
   1     : feature v1
        1: feature v2
Modified regular file feat.txt:
   1     : one
        1: one and two

That’s jj interdiff, and it compares the effects of two commits rather than their contents, so the work underneath them doesn’t get in the way. The old commit ID comes from jj evolog, which we’ll meet properly later — it keeps every version of a change, so the thing the reviewer saw is still there to compare against.

I find it surprisingly useful to be able to answer “what did you change since my review?” precisely. This is another benefit of jj keeping old versions of our changes rather than overwriting them.

Before we move on, let’s put src/main.rs back the way it was. This goodbye was just something to point our new commands at; delete that line again, and jj st will confirm we’re back to a clean, empty change:

$ jj st
The working copy has no changes.
Working copy  (@) : ywnkulko 0b3dd9d2 (empty) (no description set)
Parent commit (@-): puomrwxl 7a096b8a it's important to comment our code

Don’t worry, we’ll write a real goodbye feature soon enough.

Which files jj tracks

You may have noticed something missing. We’ve created a bunch of files, and they’ve shown up in our commits, but at no point did we run anything like git add. Let’s talk about why.

New files are tracked automatically. If we make one, it will be part of our working copy commit:

$ echo "secret" > secrets.env

$ jj st
Working copy changes:
A secrets.env
Working copy  (@) : ywnkulko 0c334409 (no description set)
Parent commit (@-): puomrwxl 7a096b8a it's important to comment our code

There’s no staging area, so there’s nothing to add things to. Remember, @ is a commit, and its contents are whatever is in our working directory. This is the same snapshotting we’ve relied on all along, even for files that didn’t exist before.

This is convenient, but that file has a suspicious name! We probably didn’t want secrets.env in a commit. Let’s see how to keep it out.

Ignoring files

jj reads .gitignore, with the same syntax you already know. We already have one, from back when we created the project — it keeps cargo’s build output out of our repository. Let’s add a rule to it:

$ echo "*.env" >> .gitignore

$ jj st
Working copy changes:
M .gitignore
A secrets.env
Working copy  (@) : ywnkulko 6ac6112a (no description set)
Parent commit (@-): puomrwxl 7a096b8a it's important to comment our code

.gitignore shows up as Modified, which makes sense: it’s a normal, tracked file, and you want it committed so that everyone working on the project ignores the same things. But secrets.env still shows up too! The previous jj st snapshotted it into @, so it’s already tracked. Adding an ignore rule never removes a tracked file, exactly as in git. That’s what jj file untrack is for:

$ jj file untrack secrets.env

$ jj st
Working copy changes:
M .gitignore
Working copy  (@) : ywnkulko f77fddd4 (no description set)
Parent commit (@-): puomrwxl 7a096b8a it's important to comment our code

The file is out of the commit and still on disk, which is what you want for something like a local config file:

$ ls
Cargo.lock    Cargo.toml    secrets.env    src

jj insists you ignore it first:

$ jj file untrack Cargo.toml
Error: 'Cargo.toml' is not ignored.
Hint: Files that are not ignored will be added back by the next command.
Make sure they're ignored, then try again.

This may seem fussy, but it makes sense. If the file wasn’t ignored, jj would track it again the next time it took a snapshot. Requiring both steps keeps us from thinking we ignored something when we didn’t.

One thing this doesn’t do: untracking removes the file from @ going forward, not from history. A password you committed five changes ago is still in those changes, and still in what you push. Getting it out of the past is a rewrite, not an untrack.

Looking inside files

There are a handful of other useful commands under jj file. We can read a file as of a particular change without touching our working copy:

$ jj file show -r @- src/main.rs
/// A "Hello, world!" program.

fn main() {
    println!("Hello, world!");
}

Listing what a revision contains:

$ jj file list
.gitignore
Cargo.lock
Cargo.toml
src/main.rs

Searching the tracked files:

$ jj file search --pattern "Hello" .
src/main.rs:/// A "Hello, world!" program.
src/main.rs:    println!("Hello, world!");

And here’s one worth remembering, because the name may not suggest it at first. jj file annotate is the equivalent of git blame:

$ jj file annotate src/main.rs
puomrwxl steve@st 2024-02-28 20:38:13    1: /// A "Hello, world!" program.
puomrwxl steve@st 2024-02-28 20:38:13    2: 
yyrsmnoo steve@st 2024-02-28 20:24:56    3: fn main() {
yyrsmnoo steve@st 2024-02-28 20:24:56    4:     println!("Hello, world!");
yyrsmnoo steve@st 2024-02-28 20:24:56    5: }

We get each line along with the change that last touched it. The first column is a change ID, so we can pass it straight to jj show to see what that change was doing.

That’s it for our tour of the basics. Before we recap, let’s clean up after this experiment: delete secrets.env, and take the *.env line back out of .gitignore. That leaves our working copy empty again, ready for the next section.

A recap and some thoughts

So here is our current workflow:

  1. Create new repositories with jj git init.
  2. To start working on a new change, use jj new.
  3. To describe a change so humans can understand them, use jj describe.
  4. We can look at our work with jj st.
  5. When we’re done, we can start our next change with jj new.

Finally, we can review our repository’s contents with jj log, and look at what any individual change actually does with jj diff and jj show.

Steps 3 and 5 are common enough together that there’s a shorthand for them: jj commit -m "message" describes the current change and then starts a new one on top, which is exactly jj describe followed by jj new. It reads like git commit, which makes it a comfortable landing spot on your way in. I’ll keep using the two separate commands in this book, because they make it clearer which of the two things is happening.

This is… pretty simple! We don’t need that many concepts to get started. Of course, we aren’t yet able to do some very important things like “share our code with others.” But we’ll get there.

An interesting thing that I’ve noticed while using jj is that sometimes things feel the same, but backwards. In git, we finish a set of changes to our code by committing, but in jj we start new work by creating a change, and then make changes to our code. It’s more useful to write an initial description of your intended changes, and then refine it as you work, than creating a commit message after the fact.

But also, this stuff is flexible: why should you have to create a commit message at the time of creating a commit, and not whenever you feel like it? The same stuff exists, but in more flexible pieces that I can combine together.

Before we get into topics like “how to share code with others” and more details about some of the things we’ve already learned, let’s talk about making our workflow a little nicer; you should get some more practice using jj before we start collaborating.

Real-world workflows

We can use jj at this point, but I wouldn’t say our workflow is great. In this chapter, we’re going to explore two different workflows that are popular with jj users. Like git, jj is extremely flexible, and so you can customize your workflow in many ways, but I’d like to show you two examples. For me, understanding the basics we’ve talked about wasn’t tough, but when I actually sat down to use jj, I found myself tripping up a bit. With a few more commands, we can have nicer workflows that let us work a bit more naturally.

We’ll start with the “squash workflow,” as it is the workflow that Martin, the creator of jj, prefers. We’ll then talk about the “edit workflow,” which is popular among people who don’t like the squash workflow. Finally, we’ll cover three commands you’ll want whichever workflow you settle on: jj split, which pulls one change apart into two, jj absorb, which files a pile of small fixes back into the commits they belong to, and jj diffedit, which reaches into a single commit and fixes what it contains.

The Squash Workflow

The first workflow we’re going to talk about is the “squash workflow,” the one preferred by jj’s creator, Martin. It’s called “the squash workflow” because it uses a command we haven’t interacted with yet, jj squash. The second reason that I’m talking about this workflow first is that it is the workflow that should appeal to people who are big fans of git’s index, and I think comparing and contrasting the two is interesting.

The workflow goes like this:

  1. We describe the work we want to do.
  2. We create a new empty change on top of that one.
  3. As we produce work we want to put into our change, we use jj squash to move changes from @ into the change where we described what to do.

In some senses, this workflow is like using the git index, where we have our list of current changes (in @), and we pull the ones we want into our commit (like git add).

Starting work by describing it

Let’s recap where we are in our project: @ currently is an empty commit:

> jj log
@  ywnkulko steve@steveklabnik.com 2024-02-28 21:12:07 b7de0217
│  (empty) (no description set)
○  puomrwxl steve@steveklabnik.com 2024-02-28 20:38:13 7a096b8a
│  it's important to comment our code
○  yyrsmnoo steve@steveklabnik.com 2024-02-28 20:24:56 ac691d85
│  hello world
◆  zzzzzzzz root() 00000000

Let’s describe the work that we want to do:

$ jj describe -m "print goodbye as well as hello"
Working copy  (@) now at: ywnkulko 4bfe3940 (empty) print goodbye as well as hello
Parent commit (@-)      : puomrwxl 7a096b8a it's important to comment our code

This change is currently empty, but we’ve now given it a useful name. This is the change we’re going to build up over time. But for now, it’s empty.

Create a new empty change

We need a place to hold our changes until we decide if we want to put them in our commit or not. So let’s make a new one:

$ jj new
Working copy  (@) now at: rkvxolny 5e020e00 (empty) (no description set)
Parent commit (@-)      : ywnkulko 4bfe3940 (empty) print goodbye as well as hello

We now have our change. It’s also empty! There’s no issue having two empty commits, one after the other. And since we are using this like an index, we don’t really need to give it a name either. It’s just a scratch space, but since it’s part of a change, we’re “always committed” in a sense.

Now it’s time for the fun stuff.

Use jj squash to move things into our “real” change

Let’s make a change to our code:

/// A "Hello, world!" program.

fn main() {
    println!("Hello, world!");
    println!("Goodbye, world!");
}

Now that our “feature” has been implemented, let’s see our current changes:

$ jj st
Working copy changes:
M src/main.rs
Working copy  (@) : rkvxolny aee5266d (no description set)
Parent commit (@-): ywnkulko 4bfe3940 (empty) print goodbye as well as hello

We now have an even wilder situation than before: our current change has stuff in it, but the parent is empty! Let’s change that. We want to take our change from our “staging area” and put it into our commit (change). We can do that with jj squash:

$ jj squash
Working copy  (@) now at: oopolqyp 9fb63b14 (empty) (no description set)
Parent commit (@-)      : ywnkulko ed71bb54 print goodbye as well as hello

Lots of changes here! @ is now empty, with no description, and the parent is now no longer empty. Our changes are now in ywnkulko.

What we did is kind of the equivalent of git commit -a --amend. But what about more focused changes? Well, if we only want to add a specific file, like git add <file> && git commit --amend we can pass it as an argument. Because we only had one file, the previous command was equivalent to

$ jj squash src/main.rs

But we can also get the equivalent of git add -p && git commit --amend, where we only add parts of a file to our commit. And it’s gonna blow your mind.

Make a few changes around your source file, and then do this:

$ jj squash -i

This will bring up a TUI!

By default, it’s showing a file-level view: we have our one file, and the ( ) indicates that we haven’t selected to include this. We could do so by hitting space, and that will fill the parenthesis in with a ● (screenshot outdated and shows an x):

Let’s press space again to undo that, and then hit f to toggle “folding”:

I added empty spaces in a few places, and you can see each individual section and line has its own checkbox. We can use the mouse to click, or arrow keys to navigate, and space to toggle if we’re accepting these changes.

Once we’re done, we can hit c to confirm our changes. I’m not selecting any, since these were just nonsense stuff I wanted to add to show off the TUI. I don’t want to keep them at all, so let’s just dump them. We can get rid of the stuff in @ with jj abandon:

$ jj abandon
Abandoned 1 commits:
  oopolqyp 44665581 (no description set)
Working copy  (@) now at: ootnlvpt 97b7a559 (empty) (no description set)
Parent commit (@-)      : ywnkulko ed71bb54 print goodbye as well as hello
Added 0 files, modified 1 files, removed 0 files

We’ve thrown away oopolqyp, and jj has helpfully made a new empty change for us.

One little surprise to warn you about before it happens to you. In this workflow, our scratch change never has a description, so jj squash quietly merges it into its parent. If the change you’re squashing does have a description — and so does the destination — jj doesn’t want to guess which one to keep, so it pops open your editor with both, for you to combine. You can skip the editor by passing -m for a new message, or -u to just keep the destination’s.

This is the kind of stuff I mean when I say “the same power, but less concepts.” We’ve got the tools that the index gave us, but they’re simpler because we don’t use some of them on the index, and some on commits: we use them all on commits.

That also implies that “the same power” isn’t exactly true: jj squash is more powerful than git add because it can work on any change and its parent, moving stuff between them. This gets into the kind of shenanigans you can get up to with git rebase -i, without the need for another command like that. Simpler, but more powerful, thanks to orthogonality.

Recap and thoughts

This workflow is not super different than our previous one, but by adding one more command, we get a bit more power. And we’ve learned that we can use jj squash to move contents of changes into their parent.

The Edit Workflow

While I like the previous workflow, some people just don’t. They use a workflow that adds a different command, jj edit, along with a second new command, jj next, as well as a new flag to jj new. Lots to learn!

The workflow goes like this:

  1. We create a new change to work on our feature.
  2. If we end up doing exactly what we wanted to do, we’re done.
  3. If we realize we want to break this up into smaller changes, we do it by making a new change before the current one, swapping to it, and making that change.
  4. We then go back to the main change.

Let’s see how to use jj this way.

Create a new change to work on our feature.

Let’s create a feature that’s un-doing our previous feature: we’ll revert to Hello, World! only.

Now, our previous workflow left @ at an empty change. But if you use this workflow, @ will often be on an existing change. So in the real use of this workflow, we’d start by:

$ jj new -m "only print hello world"

But since we have an empty change, what we’ll actually do is:

> jj describe -m "only print hello world"
Working copy  (@) now at: ootnlvpt bb06f041 (empty) only print hello world
Parent commit (@-)      : ywnkulko ed71bb54 print goodbye as well as hello

We are now ready to do some work.

Let’s change our file to:

/// A "Hello, world!" program.

fn main() {
    println!("Hello, world!");
}

Cool. We’re done. In the best case, we’re happy with this change, and we’re done. When we begin more work we start it with jj new -m "" and get to work.

But sometimes, when we’re working on something, we realize we also want a different change, and maybe it relies on this one. For example, let’s say that we were working on undoing this goodbye feature, but we realized we wanted to add some more comments first, because over-commenting a five-line program is a terrible idea in practice and so makes for a good example to play around with.

What we want to do is make a new change before this one. So let’s do that.

Make a new change and edit it

Let’s try this:

$ jj new -B @ -m "add more comments"
Rebased 1 descendant commits.
Working copy  (@) now at: nmptruqn 30a1f33b (empty) add more comments
Parent commit (@-)      : ywnkulko ed71bb54 print goodbye as well as hello
Added 0 files, modified 1 files, removed 0 files

We have a new flag to jj new, -B. This says to create the new change before the current one. That’s exactly what we asked!

The first line of the output should raise some eyebrows:

Rebased 1 descendant commits.

That’s right, because we have created a change before the one we’re on, it automatically rebased our original change. How can it do that? What if there are conflicts? Relax, we’ll get there. All I’ll say is something that’s probably hard to believe: this operation will always succeed, and we will have our working copy at the commit we’ve just inserted. You won’t learn how this works in this chapter, but in a future one.

In the meantime, let’s examine our log:

$ jj log
○  ootnlvpt steve@steveklabnik.com 2024-02-28 22:59:46 be40656e
│  only print hello world
@  nmptruqn steve@steveklabnik.com 2024-02-28 22:59:46 30a1f33b
│  (empty) add more comments
○  ywnkulko steve@steveklabnik.com 2024-02-28 22:09:40 ed71bb54
│  print goodbye as well as hello
○  puomrwxl steve@steveklabnik.com 2024-02-28 20:38:13 7a096b8a
│  it's important to comment our code
○  yyrsmnoo steve@steveklabnik.com 2024-02-28 20:24:56 ac691d85
│  hello world
◆  zzzzzzzz root() 00000000

We can see that @ is at our new empty change, and that we have our original change, ootnlvpt, is after us. Some of you may recognize ootnlvpt: even though we rebased it on top of our current change, nmptruqn, the change ID is the same. The commit changed from bb06f041 to be40656e, though. The change ID is stable, but we can keep track of how the commit changes over time. Neat.

Anyway, now we can edit @. Let’s change src/main.rs. When you first open up the file, you’ll see this:

/// A "Hello, world!" program.

fn main() {
    println!("Hello, world!");
    println!("Goodbye, world!");
}

Remember, this change is before the one where we removed the goodbye message, so that has returned. Here’s what we want to end up with:

/// A "Hello, world!" program.
/// 
/// This is the best implementation of this program to ever exist.

fn main() {
    println!("Hello, world!");
    println!("Goodbye, world!");
}

This is very silly. Regardless, we have finished. Let’s see our current status:

$ jj st
Rebased 1 descendant commits onto updated working copy.
Working copy changes:
M src/main.rs
Working copy  (@) : nmptruqn 90a2e97f add more comments
Parent commit (@-): ywnkulko ed71bb54 print goodbye as well as hello

Yet again, a rebase. Because we have changed the contents of our change, all of the changes that depend on it must be rebased. But again, this happens all the time, without fail. So it’s not something you’ll get stuck on at this stage.

Return to our main change

Now that we’re done, we’re going to go back to editing our original commit. To do that, we could use jj edit, which is where this workflow gets its name from. jj edit sets the working copy to the contents of a change, and now changes you make will update that change.

Doing that would look like this:

$ jj edit o

Since o is the unique prefix of ootnlvpt, our original feature change. However, looking up that revision is kind of annoying. Therefore, we can use a simpler command:

$ jj next --edit
Working copy  (@) now at: ootnlvpt e13b2585 only print hello world
Parent commit (@-)      : nmptruqn 90a2e97f add more comments
Added 0 files, modified 1 files, removed 0 files

jj next will move @, the working copy change, to the child of where it is now. The --edit flag means we’re now going to be editing that change, whereas if you leave it off, it works more like a variant of jj new, making a new change based on top of that change.

Let’s double check with jj log:

$ jj log
@  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments
○  ywnkulko steve@steveklabnik.com 2024-02-28 22:09:40 ed71bb54
│  print goodbye as well as hello
○  puomrwxl steve@steveklabnik.com 2024-02-28 20:38:13 7a096b8a
│  it's important to comment our code
○  yyrsmnoo steve@steveklabnik.com 2024-02-28 20:24:56 ac691d85
│  hello world
◆  zzzzzzzz root() 00000000

That’s correct, @ is at our original change.

Recap and thoughts

This workflow is also a good alternative. If your brain thinks this way is better than the other way, that’s great! A nice thing about the flexibility of these tools is you can work with them how you’d like!

Splitting a change with jj split

jj squash takes two changes and makes them one. Sooner or later, we’ll want to go the other way. Maybe we sat down to fix a bug, noticed something unrelated, tidied it up while we were there, and ended up with one change doing two jobs. Those jobs will be easier to understand and review if we separate them.

That’s what jj split is for.

Here’s the situation. We meant to add a function, but we wrote some documentation at the same time:

$ jj st
Working copy changes:
A DOCS.md
A main.rs
Working copy  (@) : ynplyknw dce433ae two unrelated things at once
Parent commit (@-): mllnrzow 4d1201b4 initial

Two files, two unrelated jobs, one change. Let’s pull the docs out:

$ jj split DOCS.md -m "add some docs"
Selected changes : ynplyknw cd1826ed add some docs
Remaining changes: mxvnxuyl 618910f4 two unrelated things at once
Working copy  (@) now at: mxvnxuyl 618910f4 two unrelated things at once
Parent commit (@-)      : ynplyknw cd1826ed add some docs

We named the paths we wanted to peel off, and -m gave the new change its description. Now there are two:

$ jj log --limit 3
@  mxvnxuyl steve@steveklabnik.com 2024-03-20 11:44:17 618910f4
│  two unrelated things at once
○  ynplyknw steve@steveklabnik.com 2024-03-20 11:44:17 cd1826ed
│  add some docs
○  mllnrzow steve@steveklabnik.com 2024-03-20 11:43:36 4d1201b4
│  initial

The docs went into the first change, and everything else stayed in the second. The second still has the old description, “two unrelated things at once”, which isn’t accurate any more. We should use jj describe to give it a better name. jj can split the change for us, but it can’t know what the two parts mean.

One detail worth noticing: the selected part kept the original change ID, ynplyknw, and the remainder got a fresh one. The part you pull out is treated as the continuation of the original change.

Splitting interactively

Naming paths works when the split falls on file boundaries. Often it doesn’t — two changes tangled in the same file. Leave off the paths:

$ jj split

and you get the same TUI we saw with jj squash -i, where you pick individual hunks and lines. Everything you select goes into the first change, everything you don’t goes into the second. jj split -i does the same thing while still letting you narrow to some paths first.

Splitting something other than the working copy

Everything so far has split @. Use -r for any other change:

$ jj split -r mxvnxuyl extra.txt -m "just the extra file"
Rebased 1 descendant commits.
Selected changes : mxvnxuyl 14798ea1 just the extra file
Remaining changes: nkrkylxx 6f0f5589 the middle commit
Working copy  (@) now at: uwmkvzlt f2e56704 a child commit
Parent commit (@-)      : nkrkylxx 6f0f5589 the middle commit

That change was in the middle of a stack with work on top of it, and we can see that jj rebased the descendants automatically. Just like our earlier rewrites, the working copy didn’t move.

The automatic rebase is especially useful when the change is in the middle of a stack. With git, we would usually start an interactive rebase, stop at the commit, separate its contents, and then continue the rebase. Here we can name any commit we’re still allowed to rewrite, and jj split takes care of its descendants for us.

Two children instead of a parent and a child

By default the halves end up stacked, one on top of the other. If they’re genuinely independent, -p makes them siblings instead:

$ jj split -p DOCS.md -m "add some docs"

Now you have two changes side by side, both on the original parent — an anonymous branch, of the sort we made back in the branching chapter. Handy when you split one change into two pull requests that don’t depend on each other.

Where this fits

Between jj squash and jj split, we can move work in either direction: merge changes together, break them apart, and move pieces between them. The next chapter adds jj absorb, which places each hunk into whichever ancestor already touched those lines. Together, these commands make rearranging history feel a lot more like ordinary editing.

Absorbing changes with jj absorb

We’ve seen that jj squash moves our work into one commit, while jj split breaks one commit into two. Both need us to say where the work should go. jj absorb can work that out for us.

The situation it’s built for turns up all the time. Let’s say we have a stack of finished commits and, while reading over them, we spot small problems in several commits at once: a typo here, a rename we missed there. We fix them all in our working copy, and now we have one change whose pieces belong in several different commits.

Here’s a small version. Two finished commits, each having touched one file:

$ jj log
@  wmtwzxmv steve@steveklabnik.com 2024-03-26 09:14:48 e9cfed95
│  (no description set)
○  towkwvlp steve@steveklabnik.com 2024-03-26 09:14:48 013493d0
│  add an evaluator
○  wumvwlxp steve@steveklabnik.com 2024-03-26 09:14:48 8fa7ccba
│  add a parser
○  rlrtxyru steve@steveklabnik.com 2024-03-26 09:14:48 26dcb6b5
│  initial
◆  zzzzzzzz root() 00000000

We’ve gone back and touched up both of them from the working copy:

$ jj st
Working copy changes:
M a.txt
M b.txt
Working copy  (@) : wmtwzxmv e9cfed95 (no description set)
Parent commit (@-): towkwvlp 013493d0 add an evaluator

We could sort this out by hand with two jj squash --into commands and the right paths, or with a jj split followed by two rebases. Instead, let’s try jj absorb:

$ jj absorb
Absorbed changes into 2 revisions:
  towkwvlp 012a265e add an evaluator
  wumvwlxp 4891076c add a parser
Working copy  (@) now at: ttoyvukr 4c383532 (empty) (no description set)
Parent commit (@-)      : towkwvlp 012a265e add an evaluator

Each hunk went where it belonged, the descendants were rebased, and the working copy is empty again:

$ jj log
@  ttoyvukr steve@steveklabnik.com 2024-03-26 09:14:48 4c383532
│  (empty) (no description set)
○  towkwvlp steve@steveklabnik.com 2024-03-26 09:14:48 012a265e
│  add an evaluator
○  wumvwlxp steve@steveklabnik.com 2024-03-26 09:14:48 4891076c
│  add a parser
○  rlrtxyru steve@steveklabnik.com 2024-03-26 09:14:48 26dcb6b5
│  initial
◆  zzzzzzzz root() 00000000

How it decides

For each hunk, jj looks at which commit last touched those lines, and puts the hunk there. That’s the whole rule. It’s the same question git blame answers, used to route your edit rather than to assign blame.

This has two useful consequences:

  • A hunk whose lines nobody has touched has no home, so it stays in your working copy. Add a brand new file and jj absorb will tell you Nothing changed. Nothing gets guessed at.
  • It only writes to commits you’re allowed to rewrite. Lines whose last change is in an immutable commit — anything on trunk — are left where they are.

So jj absorb moves the parts it can place confidently and leaves the rest for us. If the working copy still has something in it afterwards, that’s the part we need to decide about.

Narrowing it

Paths limit it to part of the tree:

$ jj absorb src/

and --from / --into set the source and the candidate destinations explicitly:

$ jj absorb --from @ --into 'mutable()'

The default source is @, and the default destinations are the mutable ancestors, which is what you want almost every time.

Where this fits

jj squash, jj split, and jj absorb are the three commands that move work between commits after the fact. We can squash to combine, split to separate, and absorb when the destination is clear from the history. Between them, we can tidy up a stack before review without rebuilding it by hand.

Editing a commit’s diff with jj diffedit

jj split pulls one change into two, and jj squash moves work between changes. Sometimes we don’t want to move anything, though. We just want to fix what one commit contains: perhaps there’s a stray debug line three commits back, or a file that shouldn’t have been touched.

With git, we might start an interactive rebase, stop at the commit, amend it, and continue. jj diffedit gives us a more direct route. It opens the commit’s diff, lets us edit it, and writes the result back without checking out the commit.

Fixing a commit in place

Let’s point it at the revision we want to fix:

$ jj diffedit -r 'description(glob:"add feature*")'

With the default configuration, we’ll get the same TUI we saw with jj split and jj squash -i, showing that commit’s diff. The left side is the parent, and the right side is the commit. We can edit the right side until it contains what we intended, perhaps unticking a line we didn’t mean to add or deleting a debug statement. When we close the editor, jj updates the commit and rebases everything on top of it:

$ jj diffedit -r 'description(glob:"add feature*")'
Rebased 1 descendant commits.
Working copy  (@) now at: pxukppkx 5f724084 (empty) (no description set)
Parent commit (@-)      : nmvqtsqq f1b645ea add feature

Our working copy never moved! We didn’t need to check out the commit, use jj edit, or run jj new afterwards. As with any rewrite, rebasing its descendants can introduce conflicts that we’ll then need to resolve.

What you can and can’t do here

jj diffedit edits the content of one commit against its parent. You’re changing what the patch does, not where it sits. For the neighboring jobs:

  • Moving a whole file’s contents in from another revision is jj restore.
  • Moving a hunk between two commits is jj squash -i.
  • Splitting one commit into two is jj split.

That leaves jj diffedit for the case where one commit’s diff is almost right and we want to fix it in place.

Comparing two revisions instead

We can also use --from and --to to edit the diff between any two revisions. diffedit will write the result into the --to side:

$ jj diffedit --from A --to B

The -r form is just the common case of this, where --from is the parent. Most of the time -r is what you want.

Like every rewrite, editing a commit gives it a new commit ID and rebases its descendants, so the immutable rule applies: jj won’t let you diffedit a commit on trunk.

Branching, merging, and conflicts

You may have noticed that we haven’t talked about branches at all yet. This is another significant difference between jj and git: jj prefers to use anonymous branches, rather than named ones. People sometimes call this a “branchless” workflow. In this chapter, we’ll learn about how to do branches in the way jj prefers, and in the next chapter, we’ll talk about named branches.

Here’s what we’re going to learn:

  • What anonymous branches are, and how to use them
  • Figuring out where our changes are with revsets
  • Merging anonymous branches
  • Dealing with conflicts
  • Resolving them with jj resolve

What anonymous branches are, and how to use them

When I first heard of “anonymous branches,” I got very confused. Git models branches as a pointer to a commit, and that pointer needs a name, so that’s a branch.

Turns out, you don’t really need to name your branches, and doing so is also not really worth it. I have heard that, inside of Meta, where they use a similar VCS tool that also has anonymous branches, almost nobody bothers to name their branches once they get used to things.

Let’s talk about it.

What is a branch, conceptually?

When two changes share the same parent change, we say that they are “branching,” because the graph of commits would look like this:

                                 
                     ┌───┐ ┌───┐ 
                 ┌───┤ F ◄─┤ G │ 
                 │   └───┘ └───┘ 
                 │               
 ┌───┐  ┌───┐  ┌─▼─┐ ┌───┐ ┌───┐ 
 │ A ◄──┤ B ◄──┤ C ◄─┤ D ◄─┤ E │ 
 └───┘  └───┘  └───┘ └───┘ └───┘ 
                                 

Here, we’d say that F and G are two changes that are “on a branch,” because it looks like they’re branching off from D and E. In reality, in git, both would be “on a branch,” because everything is on some sort of branch in git. If you’re not on a branch, git will say something like this:

You are in ‘detached HEAD’ state. You can look around, make experimental changes and commit them, and you can discard any commits you make in this state without impacting any branches by switching back to a branch.

Git considers any commit that’s not part of a branch to be garbage, and so will garbage collect those commits at some point. Git is very branch-centric.

jj does not think about the world this way. It keeps track of the head of every branch — whether or not a bookmark names it — as part of the visible state of our repository. So an anonymous branch is in no danger of being collected: as long as a commit shows up in our log, jj holds onto it, no bookmark required. A commit only becomes a candidate for garbage collection once we hide it, by abandoning it or rewriting it — and even then the operation log keeps it around for a while, as we’ll see later. This is the reverse of Git’s default: there, a commit stays alive because a branch points at it, and here, a commit stays alive because we can still see it.

Consider the diagram above: we didn’t name any of these branches, yet the diagram still made sense. There’s not really an inherent need to name our branches, just like there isn’t an inherent need to describe our changes. That said, it would be nice to know how to refer to different branches, even if they’re not named.

Let’s see how this works.

Creating two branches from the same commit

Run jj st, and make sure your working copy is at an empty change. If not, use jj new to create one. We want to start from a blank slate here.

$ jj new
Working copy  (@) now at: yykpmnuq 0bc7a425 (empty) (no description set)
Parent commit (@-)      : ootnlvpt b5db7940 only print hello world

A common reason for branching is to work on two different ideas at once. Let’s start two different features: one to add some more documentation to our project, and another to split our print function into hello and goodbye functions.

First, we’ll work on the documentation, so let’s describe our current commit to be working on that:

> jj describe -m "add better documentation"
Working copy  (@) now at: yykpmnuq 4a95c1f9 (empty) add better documentation
Parent commit (@-)      : ootnlvpt b5db7940 only print hello world

Next, we want to make a change to work on our hello and goodbye functions. We want this change to build on top of ootnlvpt, so we can just say that:

$ jj new o
Working copy  (@) now at: xrslwzvq e9249c85 (empty) (no description set)
Parent commit (@-)      : ootnlvpt b5db7940 only print hello world

We want to create our new change with the parent o, which we could see is the short name for ootnlvpt. Your change ID may be different if you’re not following me exactly, so you may want to double check you’ve got the right change!

Let’s describe this one too:

$ jj describe -m "create hello and goodbye functions"
Working copy  (@) now at: xrslwzvq a70d464c (empty) create hello and goodbye functions
Parent commit (@-)      : ootnlvpt b5db7940 only print hello world

Excellent. We’ve got two different changes, yykpmnuq and xrslwzvq, both with the parent ootnlvpt. Success! We have created a branch. And we didn’t need to name it.

Let’s edit src/main.rs to update this description:

/// A "Hello, world!" program.
///
/// This is the best implementation of this program to ever exist.

fn main() {
    print_hello();
    print_goodbye();
}

fn print_hello() {
    println!("Hello, world!");
}

fn print_goodbye() {
    println!("Goodbye, world!");
}

This is pretty silly, but we’ll use it further along in the tutorial.

We can see that there’s a branch in the output of jj log:

$ jj log
@  xrslwzvq steve@steveklabnik.com 2024-02-29 23:06:23 e78a9350
│  create hello and goodbye functions
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments
○  ywnkulko steve@steveklabnik.com 2024-02-28 22:09:40 ed71bb54
│  print goodbye as well as hello
○  puomrwxl steve@steveklabnik.com 2024-02-28 20:38:13 7a096b8a
│  it's important to comment our code
○  yyrsmnoo steve@steveklabnik.com 2024-02-28 20:24:56 ac691d85
│  hello world
◆  zzzzzzzz root() 00000000

We’ve got our two changes, and there’s a fork in the road.

So if these branches don’t have names, how do we tell them apart? Well, the descriptions of their commits are right there. We can look at them and easily tell which of the two we care about, and then use their change IDs to distinguish between the two. Coming up with an extra name isn’t inherently helpful here. Of course, sometimes it can be, and that’s why eventually we’ll talk about named branches. But the important thing to realize here is that you only have to name branches where adding a name adds some sort of value.

Getting a list of branches

Okay, but what if we had tons of branches? How would we be able to see them? In this view, with only two, it makes sense, but what if we had way more?

We can actually ask jj log to show us the head of every anonymous branch. We do it like this:

> jj log -r 'heads(all())'
@  xrslwzvq steve@steveklabnik.com 2024-02-29 23:06:23 e78a9350
│  create hello and goodbye functions
~

○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
│  (empty) add better documentation
~

This shows both of our heads. But what is that heads(all()) stuff? It’s called a “revset,” and it’s what we’re going to talk about next.

Figuring out where our changes are with revsets

We have learned about two kinds of identifiers in jj: change IDs and commit IDs. But what if we want to talk about, for example, a range of commits?

jj has a concept called a “revset,” short for “revision set.” Sometimes people say “revision” instead of “commit,” and “revset” is just nicer to say than “comset”, so it stuck.

This sounds pretty intense at first, but I promise it’s simpler than you think: jj supports a functional language to describe revsets. Almost every command in jj takes a -r/--revision flag, which is the revision to operate on. This defaults to @. This means when we do jj new, we’re basically doing jj new -r @, that is, create a new change with a parent revision of the current working copy.

Symbols

@ is actually our first example of the revset language. This is called a “symbol”. Symbols are a means of specifying a single commit. @ refers to the change containing the current working copy, but a change ID or commit ID are other examples of symbols.

Operators

Operators let you describe more complex relationships between changes. For example, remember how in the squash workflow, we would move the contents of the working copy into the parent change? Well, the - operator refers to the parent of a given revision, and @ is the change referring to the current working copy, so we might say “we squashed the contents of @ into @-. And in fact, jj squash is short for jj squash -r @ or equivalently jj squash --from @ --into @-. There are many operators, including, but not limited to:

  • x & y: changes that are in both x and y
  • x | y: changes that are in either x or y
  • ::x Ancestors of x
  • x:: Descendants of x
  • x-: direct parents of x
  • x+: direct children of x
  • x::y: descendants of x that are also ancestors of y, including both x and y
  • x+::y: paths from direct children of x to y, excluding x and including y
  • x..y: ancestors of y that are not ancestors of x, including y but not x

And more. The final bit is the most interesting, and that’s functions.

Functions

Functions allow for even more complex selection of a series of changes. The simplest functions are:

  • root(): a function that returns the root change
  • all(): this function returns all visible changes
  • mine(): this function returns all changes authored by the current user

More complex functions can take arguments:

  • parents(x): the parent changes of x
  • ancestors(x): the same as ::x, but see the next example
  • ancestors(x, depth): limits the results to a certain depth, which you can’t do with the ::x syntax
  • heads(x): commits in x that are not ancestors of other commits in x
  • description(substring:x): commits that have a substring x in their description

That substring: prefix on the last one matters, by the way. If you leave it off and write description("print"), jj looks for a description that is exactly the string print — which, since descriptions keep their trailing newline, matches nothing at all. A bare string quietly gives you an empty set, and whatever command you ran does nothing. Use substring: or glob: and you’ll get what you meant.

Putting it all together

Now we can understand heads(all()) from before: these are two functions, where we’re asking for the head commits of every commit in the repository.

Revsets are very powerful, and very convenient. Would you like to find every commit by me containing the word “print” in the description? Try this:

$ jj log -r 'author("Steve Klabnik") & description(substring:print)'

Another really useful revset function is trunk():

$ jj log -r 'trunk()'
◆  zzzzzzzz root() 00000000

Right now, this doesn’t look very useful, but it will be more useful when we get into sharing our changes. trunk() resolves to the tip of the shared main line of development — in most repositories that’s main@origin, the main branch on the origin remote. More precisely, it looks for a remote named origin or upstream, looks for a main, master, or trunk branch on it, and provides that. Since we don’t have any of those right now, it gives us the same as root().

Additionally, on the jj Discord, several folks have settled on this as a decent revset for larger repositories:

$ jj log -r '@ | ancestors(remote_bookmarks().., 2) | trunk()'

This will show the history from the working copy, some detail about remote branches, as well as the trunk. What’s good varies between what you’re trying to do and what your repository looks like, so experiment with some of this stuff to find something that works well for you.

Selecting a whole anonymous branch

Let’s put those operators to work on a real problem. In the last chapter, we made two anonymous branches from the same parent. Sooner or later, we’ll make a branch we decide we don’t want: suppose we tried one idea for three changes, started another idea from the same point, and then realized that the first idea was a dead end. Our history would look like this:

common ─┬─ n1 ── n2 ── n3
        ╰─ x1 ── x2 (@)

Here, common, n1, and the other labels stand for change IDs. The n and x changes form two anonymous branches, but of course, there’s no branch marker to delete — that’s the whole point of anonymous branches. Even if we had put a bookmark on n3 and deleted it, n3 would still be a visible head. If we want that whole first idea gone from our visible history, we need to abandon all three of its changes, and revsets let us name all three at once.

Let’s start by asking jj to show us what n3 adds on top of common:

$ jj log -r 'common..n3'
○  n3
○  n2
○  n1
~

The .. operator excludes common: common..n3 means “ancestors of n3 that are not ancestors of common.” In this simple graph, that gives us exactly the three changes on the branch we want to abandon. It also leaves x1 and x2 alone, because neither is an ancestor of n3.

You may remember another range operator that looks similar. common::n3 means “changes on a path from common to n3,” including both ends. It selects common, n1, n2, and n3 — so we must not abandon that range, because the other branch still needs common. This is the sort of off-by-one that’s worth previewing with jj log before running anything destructive.

If we know the first change on the unwanted branch, n1::n3 gives us the exact inclusive range from n1 through n3. Let’s preview it, then abandon it:

$ jj log -r 'n1::n3'
○  n3
○  n2
○  n1
~
$ jj abandon 'n1::n3'
Abandoned 3 commits:
  n3
  n2
  n1

What if we know common but not n1? Then common+::n3 selects the same range: the + means “direct children of common,” so this starts just after common, and the ::n3 part keeps only changes on a path to n3. The two spellings can differ if the branch merges in history that isn’t descended from commoncommon..n3 would include that history, while common+::n3 excludes it — but for a simple branch like ours, they’re the same three changes.

While our two branches stay separate, all of these ranges leave x1 alone, because it isn’t an ancestor of n3. If we had merged x1 into n3, though, both common..n3 and common+::n3 would pick it up, since it would then be an ancestor of n3. n1::n3 would still exclude it, because x1 is not a descendant of n1. So when a branch has merges in it, n1::n3 is the precise range to reach for.

Both boundaries of the range matter. Abandoning only n3 wouldn’t remove the branch; it would just make n2 the new anonymous head, and we’d be back where we started. And if an n4 existed after n3, abandoning n1::n3 wouldn’t touch it — jj would rebase the survivor onto common and report:

Rebased 1 descendant commits onto parents of abandoned commits.

So to discard an entire branch, we want its actual first change and its actual tip as the boundaries. In our graph, n3 really is the tip, so abandoning the range leaves us with:

common ── x1 ── x2 (@)

One reassuring note to close on: this hides the commits, it doesn’t erase them. jj records the abandon in the operation log, whose earlier states keep the hidden commits around. That’s why jj undo can bring the branch back if abandoning it was a mistake. Only once the old operations referring to those commits are themselves abandoned can garbage collection remove them for good.

Revsets are very powerful, and you’ll learn some useful ones as you explore more. At some point, we’ll even talk about how to create custom aliases for revsets, but for now, let’s get back to dealing with branches and how to merge them.

Merging anonymous branches

Let’s recall where we are:

> jj log
@  xrslwzvq steve@steveklabnik.com 2024-02-29 23:06:23 e78a9350
│  create hello and goodbye functions
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world

We have what we consider to be the head of our repository, ootnlvpt, and then two branches, xrslwzvq and yykpmnuq. Let’s create another change with ootnlvpt as the parent, to simulate the idea that some changes have landed on our main branch while we were doing the work:

> jj new o -m "added some cool new feature"
Working copy  (@) now at: pzoqtwuv 9353442b (empty) added some cool new feature
Parent commit (@-)      : ootnlvpt b5db7940 only print hello world

Let’s take a look:

> jj log --limit 5
@  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
│  (empty) added some cool new feature
│ ○  xrslwzvq steve@steveklabnik.com 2024-02-29 23:06:23 e78a9350
├─╯  create hello and goodbye functions
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

We passed the --limit flag so that we didn’t see every commit; our history is already getting a little long.

Merging branches

Now, you may expect that you’d use a command like jj merge to merge branches together. There is no such command: it was deprecated in 0.14.0 and has since been removed. So how the heck do we create merges?

Well, what is a merge anyway? It’s a new change that has more than one parent. How do we make new changes? With jj new. So let’s ask it to make a change that has both pzoqtwuv and yykpmnuq as parents:

> jj new pzoqtwuv yykpmnuq -m "merge better documentation"
Working copy  (@) now at: rxzyvnkx f1c1bde8 (empty) merge better documentation
Parent commit (@-)      : pzoqtwuv 9353442b (empty) added some cool new feature
Parent commit (@-)      : yykpmnuq 210283e8 (empty) add better documentation

Just like we’d pass a parent revision to jj new, we can pass multiple parents, and it just works. No need for a special command. Let’s look at our history, choosing six as the limit since we just added a new change:

> jj log --limit 6
@    rxzyvnkx steve@steveklabnik.com 2024-03-01 15:21:11 f1c1bde8
├─╮  (empty) merge better documentation
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
│ │  (empty) add better documentation
○ │  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
├─╯  (empty) added some cool new feature
│ ○  xrslwzvq steve@steveklabnik.com 2024-02-29 23:06:23 e78a9350
├─╯  create hello and goodbye functions
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

We can see the lines connecting to both of our parents, and we can still see the xrslwzvq branch is left over too.

But here’s something really wild: we can just do this as much as we want, no need to stop at two parents. To try this out, we’re going to run a command I haven’t told you about yet:

$ jj undo
Undid operation: 4a1f0e6c8b2d (2024-03-01 15:21:11) new empty commit
Restored to operation: 91c33bb0d5ef (2024-03-01 15:06:59) new empty commit
Working copy  (@) now at: pzoqtwuv 9353442b (empty) added some cool new feature
Parent commit (@-)      : ootnlvpt b5db7940 only print hello world
$ jj log --limit 5
@  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
│  (empty) added some cool new feature
│ ○  xrslwzvq steve@steveklabnik.com 2024-02-29 23:06:23 e78a9350
├─╯  create hello and goodbye functions
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

That’s right, we can undo our last command with a simple jj undo. There’s a whole section on this later — “Fixing Problems” — because it goes much deeper than it looks. For now, it’s like our merge never happened.

Let’s try merging all three in at the same time:

$ jj new pzoqtwuv yykpmnuq xrslwzvq -m "merge three branches"
Working copy  (@) now at: vuztuxmz 717232df (empty) merge three branches
Parent commit (@-)      : pzoqtwuv 9353442b (empty) added some cool new feature
Parent commit (@-)      : yykpmnuq 210283e8 (empty) add better documentation
Parent commit (@-)      : xrslwzvq e78a9350 create hello and goodbye functions
Added 0 files, modified 1 files, removed 0 files
$ jj log --limit 6
@      vuztuxmz steve@steveklabnik.com 2024-03-01 15:38:49 717232df
├─┬─╮  (empty) merge three branches
│ │ ○  xrslwzvq steve@steveklabnik.com 2024-02-29 23:06:23 e78a9350
│ │ │  create hello and goodbye functions
│ ○ │  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
│ ├─╯  (empty) add better documentation
○ │  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
├─╯  (empty) added some cool new feature
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

Just as easy as that: a merge commit with three different parents.

Once again I am reminded of the theme I discussed at the start: simpler can also be more powerful. jj has eliminated the need for an entire command but not lost any functionality.

But what if we didn’t want to create a merge commit? Don’t worry, jj has rebase as well.

Rebasing branches

Like git, jj has a command called rebase. It does what it says, it takes a change and, instead of its current “base,” aka parent, moves it to have a different parent, “basing” it again, or “re-basing” it.

Let’s undo our merge again:

$ jj undo
Undid operation: 4a1f0e6c8b2d (2024-03-01 15:21:11) new empty commit
Restored to operation: 91c33bb0d5ef (2024-03-01 15:06:59) new empty commit
Working copy  (@) now at: pzoqtwuv 9353442b (empty) added some cool new feature
Parent commit (@-)      : ootnlvpt b5db7940 only print hello world
$ jj log --limit 5
@  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
│  (empty) added some cool new feature
│ ○  xrslwzvq steve@steveklabnik.com 2024-02-29 23:06:23 e78a9350
├─╯  create hello and goodbye functions
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

Excellent. Let’s keep a linear history by using a rebase instead of a merge.

You can use jj rebase in a few different ways. Let’s show off the simplest: rebasing a single change. Let’s rebase our “create hello and goodbye functions” change on top of our current change:

$ jj rebase -r xrslwzvq -o pzoqtwuv
Rebased 1 commits to destination.

This rebases a single revision with -r, onto a certain destination revision, hence -o. Since our branch only had one revision, this would be the same as passing -b xrslwzvq, which would move the whole branch that revision is on, or -s xrslwzvq, which rebases that revision as well as all of its descendants.

Note that the working copy didn’t move. Let’s look at our log:

$ jj log --limit 5
○  xrslwzvq steve@steveklabnik.com 2024-03-01 16:08:37 6c4afc8f
│  create hello and goodbye functions
@  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
│  (empty) added some cool new feature
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

We have rebased our commit successfully. But you may have noticed something surprising: @ is still at pzoqtwuv. This actually belies a very deep difference between jj and git that I learned from Austin Seipp, one of jj’s maintainers. And here it is:

jj commands primarily operate on the data structures stored in its repository, rather than on the working copy.

This simple statement has some profound implications. One of the simplest consequences of this is jj’s speed. Because the working copy is itself a commit, and commits are in the database, it can treat it like any other commit. In this case, the difference is even larger: git rebase works on the working copy. This is why it has to stop you and make you resolve things in the case where a conflict happens, because it’s about to create a new commit from the working copy, and if that’s in conflict, it has to be fixed or the next commit is nonsense. We’ll talk about how jj handles conflicts shortly, but as I said before: rebases always succeed in jj. So this change is quick: it’s only modifying information in the repository, not touching any of the files we have in our working copy. This also means our working copy hasn’t changed, so @ is in the same place it was before the rebase.

Some commands do move @ by default, like jj new. This is because if you’re creating a new change, you probably want to start working on it. But it has a flag you can pass instead to create a new change but not modify @:

$ jj new -m "not gonna start this yet" --no-edit
Created new commit owlpoptm df6620cb (empty) not gonna start this yet
$ jj log --limit 6
○  owlpoptm steve@steveklabnik.com 2024-03-01 16:28:54 df6620cb
│  (empty) not gonna start this yet
│ ○  xrslwzvq steve@steveklabnik.com 2024-03-01 16:08:37 6c4afc8f
├─╯  create hello and goodbye functions
@  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
│  (empty) added some cool new feature
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

New change, yet we’re still where we are. Let’s undo that real quick:

$ jj undo
Undid operation: 7d2b41ac9e08 (2024-03-01 16:28:54) new empty commit
Restored to operation: 1e5f77c2ab3d (2024-03-01 16:08:37) rebase commit
$ jj log --limit 5
○  xrslwzvq steve@steveklabnik.com 2024-03-01 16:08:37 6c4afc8f
│  create hello and goodbye functions
@  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
│  (empty) added some cool new feature
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

Cool. Okay so that theory sounds cool, and it’s nice that it makes things fast, but what about when we do want to move @? Well, the fact that the --no-edit flag is what we passed to jj new gave it away:

$ jj edit xrslwzvq
Working copy  (@) now at: xrslwzvq 6c4afc8f create hello and goodbye functions
Parent commit (@-)      : pzoqtwuv 9353442b (empty) added some cool new feature
Added 0 files, modified 1 files, removed 0 files
$ jj log --limit 5
@  xrslwzvq steve@steveklabnik.com 2024-03-01 16:08:37 6c4afc8f
│  create hello and goodbye functions
○  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
│  (empty) added some cool new feature
│ ○  yykpmnuq steve@steveklabnik.com 2024-02-29 23:03:22 210283e8
├─╯  (empty) add better documentation
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

We can now rebase our other change on top too:

$ jj rebase -r yykpmnuq -o xrslwzvq
Rebased 1 commits to destination.
$ jj log --limit 5
○  yykpmnuq steve@steveklabnik.com 2024-03-01 16:35:47 7bea29b6
│  (empty) add better documentation
@  xrslwzvq steve@steveklabnik.com 2024-03-01 16:08:37 6c4afc8f
│  create hello and goodbye functions
○  pzoqtwuv steve@steveklabnik.com 2024-03-01 15:06:59 9353442b
│  (empty) added some cool new feature
○  ootnlvpt steve@steveklabnik.com 2024-02-28 23:26:44 b5db7940
│  only print hello world
○  nmptruqn steve@steveklabnik.com 2024-02-28 23:09:11 90a2e97f
│  add more comments

Excellent. But before we move @, I want to show you a little trick. We could type in the change ID, and in this case, yyk is the unique prefix, so it isn’t that hard. But we can also use a revset:

$ jj edit @+
Working copy  (@) now at: yykpmnuq 7bea29b6 (empty) add better documentation
Parent commit (@-)      : xrslwzvq 6c4afc8f create hello and goodbye functions

+ means “the child of this revision”, so @+ is “the child revision of the working copy”, which in this case is exactly where we wanted to go.

We’ve alluded to conflicts several times in this tutorial. We’re finally ready to address those.

Dealing with conflicts

When you’re merging or rebasing, if your changes are incompatible with each other, you may introduce a conflict. Conflicts are often regarded as painful by users of version control systems. jj can’t make that pain go away entirely, but it can help a lot.

Let’s deliberately introduce a conflict. First, we make a new change:

$ jj new -m "remove goodbye message"
Working copy  (@) now at: povouosx e2c9628c (empty) remove goodbye message
Parent commit (@-)      : yykpmnuq 2b93da0c (empty) add better documentation

And then update src/main.rs appropriately:

/// A "Hello, world!" program.
/// 
/// This is the best implementation of this program to ever exist.

fn main() {
    print_hello();
}

fn print_hello() {
    println!("Hello, world!");
}

Let’s also make a new change off of the previous head:

$ jj new yykpmnuq -m "refactor printing"
Working copy  (@) now at: vvmrvwuz 44205653 (empty) refactor printing
Parent commit (@-)      : yykpmnuq 2b93da0c (empty) add better documentation
Added 0 files, modified 1 files, removed 0 files

And if we open src/main.rs again, we’ll see that of course, it’s back to the state it was before we made our other change:

/// A "Hello, world!" program.
///
/// This is the best implementation of this program to ever exist.

fn main() {
    print_hello();
    print_goodbye();
}

fn print_hello() {
    println!("Hello, world!");
}

fn print_goodbye() {
    println!("Goodbye, world!");
}

Let’s make a very silly change: our own print function. Edit src/main.rs to look like this:

/// A "Hello, world!" program.
/// 
/// This is the best implementation of this program to ever exist.

fn main() {
    print("Hello, world!");
    print("Goodbye, world!");
}

fn print(m: &str) {
    println!("{m}")
}

Excellent:

$ jj log --limit 3
@  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:29:12 5f858c15
│  refactor printing
│ ○  povouosx steve@steveklabnik.com 2024-03-01 17:27:14 28010506
├─╯  remove goodbye message
○  yykpmnuq steve@steveklabnik.com 2024-03-01 17:07:36 2b93da0c
│  (empty) add better documentation

Everything looks to be in order. Let’s rebase our goodbye message change onto our refactor printing change:

$ jj rebase -r povouosx -o @
Rebased 1 commits to destination.
New conflicts appeared in 1 commits:
  povouosx 793ce8e0 (conflict) remove goodbye message
Hint: To resolve the conflicts, start by creating a commit on top of
the conflicted commit:
  jj new povouosx
Then use `jj resolve`, or edit the conflict markers in the file directly.
Once the conflicts are resolved, you can inspect the result with `jj diff`.
Then run `jj squash` to move the resolution into the conflicted commit.

Wait a minute, I thought I told you that rebases always succeed. Well… it did:

> jj log --limit 3
×  povouosx steve@steveklabnik.com 2024-03-01 17:30:32 793ce8e0 (conflict)
│  remove goodbye message
@  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:29:12 5f858c15
│  refactor printing
○  yykpmnuq steve@steveklabnik.com 2024-03-01 17:07:36 2b93da0c
│  (empty) add better documentation

Remember, @ stays where it is, and we moved a commit ahead of us, so we’re good. Go ahead, check out src/main.rs, you’ll see that it’s still just like it was before, with our printer refactoring.

However, you’ll notice that in our log output, it says that povouosx is now conflicted. This is why rebases always succeed in jj: if there’s a conflict, it doesn’t make you stop and fix it, it records that there’s a conflict and still performs the rest of the rebase. This is very powerful. Even in this case with one change, it lets us handle the conflict when we’re ready. We can keep making changes to our current change if we want to:

/// A "Hello, world!" program.
/// 
/// This is the best implementation of this program to ever exist.

fn main() {
    print("Hello, world!");
    print("Goodbye, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}

And then we check our log again:

$ jj log --limit 3
Rebased 1 descendant commits onto updated working copy.
×  povouosx steve@steveklabnik.com 2024-03-01 17:49:07 a912c809 (conflict)
│  remove goodbye message
@  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing
○  yykpmnuq steve@steveklabnik.com 2024-03-01 17:07:36 2b93da0c
│  (empty) add better documentation

jj automatically rebased povouosx again. It’s still in conflict. But that’s totally okay. We only need to handle it when we’re ready. This automatic rebasing behavior only works because jj is okay with commits being in conflict. And if we had even more children commits, they’d all be rebased, automatically.

Resolving the conflict

The output we got back when the conflict was created gave us some advice:

Hint: To resolve the conflicts, start by creating a commit on top of
the conflicted commit:
  jj new povouosx
Then use `jj resolve`, or edit the conflict markers in the file directly.
Once the conflicts are resolved, you can inspect the result with `jj diff`.
Then run `jj squash` to move the resolution into the conflicted commit.

This advice is good, but also more complex than we need to do right now. Doing this is a great way to handle a complex resolution, where you want to double check what you’ve done before you apply the changes. But we are just using a small example to make a point. Therefore, we can just edit povouosx and remove the conflict markers directly:

> jj edit povouosx
Working copy  (@) now at: povouosx a912c809 (conflict) remove goodbye message
Parent commit (@-)      : vvmrvwuz d41c079b refactor printing
Added 0 files, modified 1 files, removed 0 files
Warning: There are unresolved conflicts at these paths:
src/main.rs    2-sided conflict

Here’s src/main.rs:

/// A "Hello, world!" program.
/// 
/// This is the best implementation of this program to ever exist.

fn main() {
<<<<<<< conflict 1 of 1
+++++++ vvmrvwuz d41c079b "refactor printing" (rebase destination)
    print("Hello, world!");
    print("Goodbye, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}
%%%%%%% diff from: yykpmnuq 2b93da0c "add better documentation" (parents of rebased revision)
\\\\\\\        to: povouosx 28010506 "remove goodbye message" (rebased revision)
     print_hello();
-    print_goodbye();
 }
 
 fn print_hello() {
     println!("Hello, world!");
 }
-
-fn print_goodbye() {
-    println!("Goodbye, world!");
-}
>>>>>>> conflict 1 of 1 ends

git uses a combination of <<<<, =====, and >>>> to mark conflicts. jj has richer conflict markers. It still uses the >>> and <<<s to indicate the start and end, but has two others: +++++++ marks a snapshot, and %%%%%%% marks a diff. Each one is labelled with the commit it came from, which is what makes them readable.

So: the snapshot is the destination we rebased onto, vvmrvwuz, verbatim. Then the diff is our own change, expressed as what povouosx did to its old parent yykpmnuq — it dropped the print_goodbye() call and the whole print_goodbye function. jj couldn’t apply that diff to that snapshot, because the lines it wanted to remove aren’t there any more; vvmrvwuz rewrote them into print(...) calls. So it hands you both halves and lets you do it.

Notice how much of the file is inside the conflict region. jj isn’t conflicting on one line — the diff and the snapshot overlap across most of main() and the functions below it, so the whole span gets marked.

To resolve this, we apply our own take on the diff to the snapshot:

/// A "Hello, world!" program.
/// 
/// This is the best implementation of this program to ever exist.

fn main() {
    print("Hello, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}

Let’s take a look:

$ jj st
Working copy changes:
M src/main.rs
Working copy  (@) : povouosx 7647f7a0 remove goodbye message
Parent commit (@-): vvmrvwuz d41c079b refactor printing
$ jj log --limit 3
@  povouosx steve@steveklabnik.com 2024-03-01 18:08:23 7647f7a0
│  remove goodbye message
○  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing
○  yykpmnuq steve@steveklabnik.com 2024-03-01 17:07:36 2b93da0c
│  (empty) add better documentation

Conflict resolved!

Automatic rebasing conflict resolution

A wild thing about this though is the combination of conflicted changes and automatic rebasing. Here, I’ll show you. First we need to undo our resolution, and then we’ll make a new change on top of our conflicted change:

> jj undo
Undid operation: 3c9a71fd50e2 (2024-03-01 18:08:23) snapshot working copy
Restored to operation: 8b40cc21ae77 (2024-03-01 17:49:07) snapshot working copy
New conflicts appeared in 1 commits:
  povouosx a912c809 (conflict) remove goodbye message
Hint: To resolve the conflicts, start by creating a commit on top of
the conflicted commit:
  jj new povouosx
Then use `jj resolve`, or edit the conflict markers in the file directly.
Once the conflicts are resolved, you can inspect the result with `jj diff`.
Then run `jj squash` to move the resolution into the conflicted commit.
Working copy  (@) now at: povouosx a912c809 (conflict) remove goodbye message
Parent commit (@-)      : vvmrvwuz d41c079b refactor printing
Added 0 files, modified 1 files, removed 0 files
> jj new povouosx --no-edit
Created new commit mlzwmxzs 07bb727d (conflict) (empty) (no description set)
> jj log --limit 4
×  mlzwmxzs steve@steveklabnik.com 2024-03-01 18:10:08 07bb727d (conflict)
│  (empty) (no description set)
@  povouosx steve@steveklabnik.com 2024-03-01 17:49:07 a912c809 (conflict)
│  remove goodbye message
○  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing
○  yykpmnuq steve@steveklabnik.com 2024-03-01 17:07:36 2b93da0c
│  (empty) add better documentation

We have our conflict, and then our new change, mlzwmxzs, is also in conflict. So fix the conflict in main.rs again, and then let’s see what happens:

> jj log --limit 4
Rebased 1 descendant commits onto updated working copy.
○  mlzwmxzs steve@steveklabnik.com 2024-03-01 18:12:43 9a4ad229
│  (empty) (no description set)
@  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 f68d1623
│  remove goodbye message
○  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing
○  yykpmnuq steve@steveklabnik.com 2024-03-01 17:07:36 2b93da0c
│  (empty) add better documentation

Not only did we fix our issue, but after we did, jj automatically rebased mlzwmxzs, and the fix propagated correctly. mlzwmxzs is no longer in conflict.

By the way, let’s abandon that change, as we don’t intend to use it for anything right now:

$ jj abandon mlzwmxzs
Abandoned 1 commits:
  mlzwmxzs 9a4ad229 (empty) (no description set)

Great, we’ve cleaned that up.

These behaviors, namely recording conflicts and automatic rebasing, form the behaviors necessary for a very cool jj workflow, and that’s stacking pull requests. We’ll get to it in the “More advanced workflows” section.

We fixed this conflict by editing the markers out of the file ourselves. Next, the other way of going about it: handing the file to a merge tool with jj resolve.

Resolving conflicts with jj resolve

In the last chapter, we resolved a conflict by opening the file and editing the markers by hand. That always works, and for a small conflict it’s often the quickest approach. We can also use jj resolve, which hands each conflicted file to a merge tool one at a time.

Since we cleaned up the conflict in our project already, I’ve made a little scratch repository to play with: two changes, “say hi” and “say hey”, that each put a different greeting in greeting.txt, and a merge of the two on top. That merge is, of course, conflicted.

First, let’s find out what’s actually conflicted:

$ jj resolve --list
greeting.txt    2-sided conflict

Two-sided is the ordinary case — two branches, two versions. You’ll see three-sided and worse if you merge more than two things at once, or conflict on top of a conflict.

Then run it:

$ jj resolve

That opens your merge editor on greeting.txt, with the two sides and the base version. Save and exit, and jj records the result; exit without changing anything and it stops, leaving the conflict alone. If several files are conflicted, you get them one after another, and you can name paths — jj resolve greeting.txt — to deal with one at a time.

Which tool opens depends on ui.merge-editor, which we’ll set in the customization section. The default is jj’s built-in editor.

Taking one side wholesale

Sometimes there’s nothing to merge because one side is simply right. There are two built-in tools that let us skip the editor:

$ jj resolve --tool :ours
Working copy  (@) now at: plwvlnst 80d01ccc merge the two
Parent commit (@-)      : tmqzyrmt ef8a1082 say hi
Parent commit (@-)      : xvqtknsp 828bdd03 say hey
$ cat greeting.txt
hi

:ours takes the first side of the conflict, :theirs the second. Be careful with the names: in a merge, “ours” is the first parent you gave jj new, and in a rebase it’s the commit being rebased onto. Check with jj diff afterwards rather than trusting the word.

Resolving somewhere else

Conflicts don’t have to be resolved in the working copy. -r points jj resolve at any conflicted commit. Back in the last chapter, when povouosx was sitting above us in conflict, we could have fixed it without moving @ at all:

$ jj resolve -r povouosx

This is worth knowing because jj’s hint suggests another approach: make a new commit on top of the conflicted one, fix it there, then use jj squash to move the fix down. Both work. The hint’s version keeps the conflicted commit untouched while we experiment, while -r edits it directly.

Afterwards

Resolving is just editing, so the usual tools apply. jj st stops warning you:

$ jj st
Working copy changes:
M greeting.txt
Working copy  (@) : plwvlnst 80d01ccc merge the two
Parent commit (@-): tmqzyrmt ef8a1082 say hi
Parent commit (@-): xvqtknsp 828bdd03 say hey

jj diff shows what you settled on, and jj undo puts the conflict back if you got it wrong. A resolution is a normal edit to a normal commit, with all the same escape hatches.

And as we saw last chapter, resolving a conflict once is enough: descendants get rebased onto the resolved version automatically, and the conflict doesn’t come back for each commit in turn the way it does with git rebase.

That’s branching, merging, and conflicts covered. Before we can get to the stacked pull request workflow those behaviors make possible, we have to talk about using jj with GitHub in the first place. Let’s go over that next.

Sharing your code with others

One of the best parts about using a version control system is the ability to share that code with other people. So far, we’ve been using jj entirely on our own machine, but it’s time to explore how we can interface with tools that let us collaborate.

Here’s what we’re going to learn:

  • Using named branches in jj
  • Working with remotes, e.g., GitHub
  • Adding commits to a pull request
  • Marking a release with tags
  • Catching up when other people move trunk underneath you
  • Contributing through a fork, with two remotes
  • How to use jj with Gerrit rather than GitHub

Using named branches in jj

Named branches (or, starting with jj 0.22, “bookmarks”) are mostly an interoperability feature in jj; other than some sort of “main branch” that indicates where shared history lives, other branches aren’t necessary to get work done. However, if you use a tool like GitHub, which bases a lot of its functionality around git branches, then you’ll end up using more than one named branch.

To create a named branch (bookmark) in jj, we can use jj bookmark create:

$ jj bookmark create trunk
Created 1 bookmarks pointing to povouosx f68d1623 trunk | remove goodbye message
$ jj log --limit 2
@  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 trunk f68d1623
│  remove goodbye message
○  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing

I like the name trunk here, but you can use main if you prefer, whatever you like really. But if we look on the right hand side of the first log line above, we can see trunk as an identifier here. We can use the name trunk as a revision or use it in a revset if we’d like:

> jj log -r 'ancestors(trunk, 2)'
@  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 trunk f68d1623
│  remove goodbye message
○  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing
~

One interesting thing about branches in jj that’s different than branches in git is that branches do not automatically move. For example, let’s make a new change:

> jj new
Working copy  (@) now at: pzkrzopz 3f14c03f (empty) (no description set)
Parent commit (@-)      : povouosx f68d1623 trunk | remove goodbye message
> jj log
@  pzkrzopz steve@steveklabnik.com 2024-03-01 22:41:37 fcf669c5
│  (empty) (no description set)
│ ○  qtlkpytx steve@steveklabnik.com 2024-03-01 20:09:25 e6667f9e
├─╯  (empty) (no description set)
○  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 trunk f68d1623
│  remove goodbye message

Oh look, we have an extra empty commit lying around. That happens sometimes, let’s forget about it:

> jj abandon qt
Abandoned 1 commits:
  qtlkpytx e6667f9e (empty) (no description set)
> jj log --limit 3
@  pzkrzopz steve@steveklabnik.com 2024-03-01 22:41:37 fcf669c5
│  (empty) (no description set)
○  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 trunk f68d1623
│  remove goodbye message
○  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing

Even though @ has moved to pzkrzopz, trunk is still at povouosx. This behavior is a bit surprising for folks coming from git, though it fits in with jj more nicely, I think.

Regardless, let’s update trunk to point at @:

$ jj bookmark set trunk
Moved 1 bookmarks to pzkrzopz fcf669c5 trunk | (empty) (no description set)
$ jj log --limit 2
@  pzkrzopz steve@steveklabnik.com 2024-03-01 22:41:37 trunk fcf669c5
│  (empty) (no description set)
○  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 f68d1623
│  remove goodbye message

If you want to replicate git’s behavior, typing an additional command after each change is done feels like overkill. But I would argue this is not the right way to use jj; as you’ll see, we’ll be either re-writing commits at the tip of branches, or doing multiple steps of work before updating where a branch points. In practice, it means I check the branch status before pushing code, rather than as I work. That is, the branch name tends to sit at the same change as the remote server, and when it’s time to update the remote, that’s when I update things locally.

Speaking of remotes, let’s talk about that next.

Working with remotes, e.g., GitHub

We’re going to talk about working with remote git servers, and using GitHub as an example. The same principles apply to any given git server, though.

Pushing our code to GitHub

The first thing to do is to create the remote server. I have made a GitHub project at https://github.com/steveklabnik/jj-hello-world. I’m adding it as an upstream like this:

> jj git remote add origin git@github.com:steveklabnik/jj-hello-world.git

That’s the direction we need here, because our repository already exists. Going the other way, jj git clone <url> takes a project that’s already on a server and makes a jj repository from it. Like jj git init, it gives you a colocated repository — a .jj directory and a working .git one — so gh and anything else that reads .git keep working in a clone too.

You may have noticed we’ve been talking about change IDs as if they were permanent, and they usually are, even across a clone. jj writes each change ID into a small header on the underlying git commit, so a plain git clone carries it along too, not just jj git clone — and it survives any number of repeated clones, pure git included, as long as none of them rewrite the commit. The one thing that loses it is rewriting the commit along the way, say with git rebase or a squash-merge on GitHub, since that header isn’t part of git’s own data model.

Before we push our commit up, we need to fix our repository:

$ jj log --limit 2
@  pzkrzopz steve@steveklabnik.com 2024-03-01 22:41:37 trunk fcf669c5
│  (empty) (no description set)
○  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 f68d1623
│  remove goodbye message

Let’s swap @ back to the previous change, and then abandon this one. We can do that like this:

$ jj edit @-
Working copy  (@) now at: povouosx f68d1623 remove goodbye message
Parent commit (@-)      : vvmrvwuz d41c079b refactor printing
$ jj bookmark set trunk --allow-backwards
Moved 1 bookmarks to povouosx f68d1623 trunk | remove goodbye message
$ jj abandon pzkrzopz
Abandoned 1 commits:
  pzkrzopz fcf669c5 (empty) (no description set)
$ jj log --limit 2
@  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 trunk f68d1623
│  remove goodbye message
○  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing

We need the --allow-backwards flag to set the trunk branch to the previous commit because it is a dangerous operation: if we had pushed trunk, things would get weird when we try and push now. We’ve kept it all local, so there’s no issues with doing this.

Anyway, let’s push trunk up to GitHub. A bare jj git push won’t do it: jj refuses to create a bookmark on the remote that it doesn’t already know about, so we have to name it explicitly with -b. We can use --dry-run to inspect that push before performing it:

$ jj git push
Warning: Refusing to create new remote bookmark trunk@origin
Hint: Run `jj bookmark track trunk@origin` and try again.
Nothing changed.
$ jj git push --dry-run -b trunk
Changes to push to origin:
  bookmark: trunk [add to f68d16233bdc]
Dry-run requested, not pushing.
$ jj git push -b trunk
Changes to push to origin:
  bookmark: trunk [add to f68d16233bdc]
Warning: The working-copy commit became immutable; a new commit has been created on top of it.
Working copy  (@) now at: znurnwmk f853107d (empty) (no description set)
Parent commit (@-)      : povouosx f68d1623 trunk | remove goodbye message

Once the bookmark exists on the remote and is tracked, a bare jj git push is enough for subsequent pushes.

That warning in the middle deserves a word. Remember those symbols from back when we first looked at jj log? Pushing trunk just created some more of them:

$ jj log
@  znurnwmk steve@steveklabnik.com 2024-03-01 18:15:00 f853107d
│  (empty) (no description set)
◆  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 trunk f68d1623
│  remove goodbye message
~

Two things happened. trunk turned into a , and everything below it vanished behind a ~. That’s not --limit doing it. With no -r, jj log uses this revset:

present(@) | ancestors(immutable_heads().., 2) | trunk()

Which is to say: the working copy, whatever’s stacked above the immutable commits, and trunk. History you can’t rewrite doesn’t get drawn, on the theory that it’s rarely what you’re looking at. Ask for it explicitly and it’s all still there:

$ jj log -r '::@'
@  znurnwmk steve@steveklabnik.com 2024-03-01 18:15:00 f853107d
│  (empty) (no description set)
◆  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 trunk f68d1623
│  remove goodbye message
◆  vvmrvwuz steve@steveklabnik.com 2024-03-01 17:49:07 d41c079b
│  refactor printing
◆  yykpmnuq steve@steveklabnik.com 2024-03-01 17:07:36 2b93da0c
│  (empty) add better documentation

trunk and everything behind it are now immutable. The idea is simple: other people can see this history now, so rewriting it would be antisocial. jj enforces that for you rather than trusting you to remember:

$ jj describe trunk -m "let's rewrite some shared history"
Error: Commit f68d16233bdc is immutable
Hint: Could not modify commit: povouosx f68d1623 trunk | remove goodbye message
Hint: Immutable commits are used to protect shared history.
Hint: For more information, see:
      - https://docs.jj-vcs.dev/latest/config/#set-of-immutable-commits
      - `jj help -k config`, "Set of immutable commits"
Hint: This operation would rewrite 1 immutable commits.

This is also why we got that warning during the push. Our working copy was sitting on povouosx, and povouosx had just become immutable, so jj moved us up to a fresh change on top of it. It didn’t ask, because there’s no sensible alternative: you can’t keep editing a commit you’re no longer allowed to edit.

By default the immutable set is trunk(), any tags, and any remote bookmarks you’re not tracking. It’s a configuration setting, not a law of nature; if you really do need to rewrite shared history, you can adjust revset-aliases."immutable_heads()" or pass --ignore-immutable. But the default is a good default, and I’d leave it alone until you have a specific reason not to.

And now our project is up on GitHub!

Updating the trunk branch from GitHub

If you collaborate on a project, as commits land on the main branch, you’ll want to update your local copy of that branch. I’m going to make a change in the GitHub UI:

All I did was update the Cargo.toml to remove some comments. If you’re following along, you can make any change you’d like.

Let’s fetch those changes:

$ jj git fetch
bookmark: trunk@origin [updated] tracked
$ jj log --limit 3
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml
│ @  znurnwmk steve@steveklabnik.com 2024-03-01 18:15:00 f853107d
├─╯  (empty) (no description set)
◆  povouosx steve@steveklabnik.com 2024-03-01 18:12:43 f68d1623
│  remove goodbye message
~

In this instance, trunk did move to the new commit: we asked jj to fetch information from our origin, and so it’s adjusted things to match. However, @ is still at our current commit (ie. the empty commit created by jj git push).

Let’s fix that:

$ jj new trunk
Working copy  (@) now at: vmunwxsk be917d2e (empty) (no description set)
Parent commit (@-)      : ksrmwuon e202b67c trunk | Update Cargo.toml
Added 0 files, modified 1 files, removed 0 files

Now we’re working ahead of our trunk.

Creating a pull request

On GitHub, pull requests are tied to a branch. But if you’re doing this jj-native workflow, you aren’t really thinking about branch names. Does this advantage go away when you start working with pull requests? Not particularly.

Let’s make this empty change we’re on into a real change. Update src/main.rs with a new comment:

/// A "Hello, world!" program.
/// 
/// This is the best implementation of this program to ever exist.

/// add documentation for main
fn main() {
    print("Hello, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}

Then let’s add a description, and push our change to GitHub so we can make a PR:

$ jj describe -m "add a comment to main"
Working copy  (@) now at: vmunwxsk 9410db49 add a comment to main
Parent commit (@-)      : ksrmwuon e202b67c trunk | Update Cargo.toml
$ jj git push -c @
Creating bookmark push-vmunwxsksqvk for revision vmunwxsksqvk
Changes to push to origin:
  bookmark: push-vmunwxsksqvk [add to 9410db49f9ba]
$ jj log
@  vmunwxsk steve@steveklabnik.com 2024-03-02 08:27:30 push-vmunwxsksqvk 9410db49
│  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml
~

We’ve used jj git push to push code to a git remote before, but the -c flag is new: we’re asking it to create us a new branch, from the revision @. And so it did, and gave us the name push-vmunwxsksqvk. This is our change ID; it’s longer because change IDs are actually longer than what’s been displayed to us, there’s just never been a reason to show the whole ID, since any unique prefix works: vmunwxsk is just as much a unique prefix of vmunwxsksqvk as v is, it’s just not the shortest unique prefix.

We can now make a pull request out of this:

If you’d like to view this PR, you can find it here, though by the time you look at it, some changes will have been made! Even the smallest pull requests get feedback sometimes, and we’re gonna learn two ways of dealing with review comments in the next section.

Working with multiple remotes

By default, jj git push will push to origin, requiring --remote myfork to push to a fork.

If you wish to push to your fork by default, you can configure it. git.fetch takes several remotes, git.push takes one:

[git]
fetch = ["origin", "myfork"]
push = "myfork"

Use --repo to set this for one repository, or --user if you expect most of your repositories to have the same remotes. jj config path --repo will tell you where the repository’s own config file lives.

There’s more to say about two remotes, and we’ll say it in the chapter on working with a fork.

Responding to pull request feedback

Oh no! Someone has asked for changes to our pull request.

We have two ways of making our change, and it depends on what the project’s maintainers prefer. Due to the way that GitHub shows or minimizes comments on a pull request, some projects prefer that you never modify commits that you’ve pushed to a pull request, and only want you to add commits to fix problems. Other projects are okay with you modifying history, or even actively want one commit per pull request. We can do both, but the way we do it looks a little bit different.

Adding commits to a PR

Adding commits to a PR is easy, but works just differently enough in jj that it can be confusing at first. Let me explain.

Let’s create a new change:

$ jj new -m "respond to feedback"
Working copy  (@) now at: nzsvmmzl 3b663200 (empty) respond to feedback
Parent commit (@-)      : vmunwxsk 9410db49 push-vmunwxsksqvk | add a comment to main

And change the text of the comment in src/main.rs:

/// A "Hello, world!" program.
///
/// This is the best implementation of this program to ever exist.

/// The main function runs when our program starts
fn main() {
    print("Hello, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}

Our change is ready, but one thing is missing:

$ jj log
@  nzsvmmzl steve@steveklabnik.com 2024-03-02 09:22:40 ad6b9b14
│  respond to feedback
○  vmunwxsk steve@steveklabnik.com 2024-03-02 08:27:30 push-vmunwxsksqvk 9410db49
│  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml
~

Remember, jj new won’t move any branches, and so if we push, nothing happens:

$ jj git push
Warning: No bookmarks/tags found in the default push revset: remote_bookmarks(remote=origin)..@
Nothing changed.

First we have to update the branch to point at our new commit, and then push:

$ jj bookmark set push-vmunwxsksqvk
Moved 1 bookmarks to nzsvmmzl ad6b9b14 push-vmunwxsksqvk* | respond to feedback
$ jj git push
Changes to push to origin:
  bookmark: push-vmunwxsksqvk [move forward from 9410db49f9ba to ad6b9b149f88]

Since we added a commit, the review comment is still there, which is why people like this workflow.

Now, when people learn about this behavior for the first time, they’re often a little annoyed. It sounds like you have to do an extra step each time you change your pull request. But I’ve found that in practice, this overhead affects small PRs more than larger ones. The real mental change is to make sure that your branch is pointing where you want it to just before you push, not after each commit. In other words, the workflow is not:

  1. Make change
  2. Update branch
  3. Make change
  4. Update branch
  5. Push

It is:

  1. Make change
  2. Make change
  3. Update branch
  4. Push

Since I am always looking things over before I push them up, I’m already going to notice if my branch is out of date, and if you forget, the worst case is that nothing gets pushed, which is a reminder to go and update the branch anyway.

The next workflow eliminates this extra “update the branch” step, but comes with its own challenges. Let’s look at rebasing pull requests instead of adding new commits to them.

Rebasing a PR

First, we have to undo what we just did. Here’s where we are:

> jj log
@  nzsvmmzl steve@steveklabnik.com 2024-03-02 09:22:40 push-vmunwxsksqvk ad6b9b14
│  respond to feedback
○  vmunwxsk steve@steveklabnik.com 2024-03-02 08:27:30 9410db49
│  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml
~

So let’s move the branch backwards, then abandon our new change:

$ jj bookmark set push-vmunwxsksqvk -r @- --allow-backwards
Moved 1 bookmarks to vmunwxsk 9410db49 push-vmunwxsksqvk* | add a comment to main
$ jj edit vmunwxsk
Working copy  (@) now at: vmunwxsk 9410db49 push-vmunwxsksqvk* | add a comment to main
Parent commit (@-)      : ksrmwuon e202b67c trunk | Update Cargo.toml
Added 0 files, modified 1 files, removed 0 files
$ jj abandon nzsvmmzl
Abandoned 1 commits:
  nzsvmmzl ad6b9b14 push-vmunwxsksqvk@origin | respond to feedback
$ jj log
@  vmunwxsk steve@steveklabnik.com 2024-03-02 08:27:30 push-vmunwxsksqvk* 9410db49
│  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml
~

Nice. We can see that the branch name has an asterisk by it now; because we are tracking this branch to a remote, jj is noting that our understanding of this branch and the remote’s understanding are different.

Let’s make it even more different: let’s edit src/main.rs again:

/// A "Hello, world!" program.
///
/// This is the best implementation of this program to ever exist.

/// The main function runs when our program starts
fn main() {
    print("Hello, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}

Same as before. Since jj is tracking changes that we’ve made, our commit has already been “rebased” in a sense. So we can just push:

> jj git push
Changes to push to origin:
  bookmark: push-vmunwxsksqvk [move sideways from ad6b9b149f88 to 586ea9fd213f]

We can see that reflected on GitHub:

Only one commit, and it shows that our comment is on an outdated diff.

This is really “rewriting” more than “rebasing,” but rebasing also involves rewriting history and you may additionally want to rebase.

Rebasing with multiple changes

Sometimes you have pull requests with more than one commit, though. What if we had more than one change we wanted to make? Let’s undo our change. Because this is so small, I’m going to do it by hand, changing src/main.rs:

/// A "Hello, world!" program.
///
/// This is the best implementation of this program to ever exist.

/// add documentation for main
fn main() {
    print("Hello, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}

Let’s make a new commit for some other sort of change. First we need to jj new:

$  jj new -m "add a new function"
Working copy  (@) now at: msmntwvo baaa23e8 (empty) add a new function
Parent commit (@-)      : vmunwxsk 6da57c93 push-vmunwxsksqvk* | add a comment to main

And then add some new functionality:

/// A "Hello, world!" program.
///
/// This is the best implementation of this program to ever exist.

/// add documentation for main
fn main() {
    print("Hello, world!");
    print("Goodbye, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}

Let’s update our branch and push:

> jj bookmark set push-vmunwxsksqvk
Moved 1 bookmarks to msmntwvo 8f7dcd91 push-vmunwxsksqvk* | add a new function
> jj git push
Changes to push to origin:
  bookmark: push-vmunwxsksqvk [move sideways from 586ea9fd213f to 8f7dcd91ecbf]

We now have two changes again. So what happens when we address our review? Well, since we’re okay with rebasing, we can just edit that commit directly:

$ jj edit vmunwxsk
Working copy  (@) now at: vmunwxsk 6da57c93 add a comment to main
Parent commit (@-)      : ksrmwuon e202b67c trunk | Update Cargo.toml
Added 0 files, modified 1 files, removed 0 files

and update src/main.rs:

/// A "Hello, world!" program.
///
/// This is the best implementation of this program to ever exist.

/// The main function runs when our program starts
fn main() {
    print("Hello, world!");
}

// a function that prints a message
fn print(m: &str) {
    println!("{m}")
}

And check our work:

$ jj st
Rebased 1 descendant commits onto updated working copy.
Working copy changes:
M src/main.rs
Working copy  (@) : vmunwxsk f6f7dce9 add a comment to main
Parent commit (@-): ksrmwuon e202b67c trunk | Update Cargo.toml

There’s that automatic rebase again! We don’t need to do anything with our “add a new function” change, as it isn’t conflicted. So we can just go ahead and push:

$ jj log
○  msmntwvo steve@steveklabnik.com 2024-03-02 11:47:08 push-vmunwxsksqvk* 752534be
│  add a new function
@  vmunwxsk steve@steveklabnik.com 2024-03-02 11:47:08 f6f7dce9
│  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml
~
$ jj next --edit
Working copy  (@) now at: msmntwvo 752534be push-vmunwxsksqvk* | add a new function
Parent commit (@-)      : vmunwxsk f6f7dce9 add a comment to main
Added 0 files, modified 1 files, removed 0 files
$ jj git push
Changes to push to origin:
  bookmark: push-vmunwxsksqvk [move sideways from 8f7dcd91ecbf to 752534beb39f]

And now we’re good! Just that easy. If we didn’t want to move @, we could have done jj git push -b push-vmunwxsksqvk to push that specific branch, but I like not staying in the middle of a branch once I’m done with that work.

Recap

We learned two different ways of handling pull request feedback: adding more commits, and rewriting commits. While both of these ways work, they both have different drawbacks. In the next chapter, we’re going to explore some more advanced ways of working that allow us to mitigate some of the drawbacks, as well as talk about working with other tools that let us work with jj in a nicer way.

Tagging a release with jj tag

We’ve seen that bookmarks move. That’s the point of them: trunk follows our main line of development, and a pull request bookmark follows the tip of the pull request. Sometimes we want the opposite, though: a name for one exact commit. That’s a tag.

If you’ve used git tag, this will feel familiar. Let’s mark the commit that trunk currently points at:

$ jj tag set v1.0.0 -r trunk
Created 1 tags pointing to ksrmwuon e202b67c trunk | Update Cargo.toml

We can see our tags with jj tag list:

$ jj tag list
v1.0.0: ksrmwuon e202b67c Update Cargo.toml

And tags show up in jj log alongside bookmarks:

$ jj log --limit 3
@  msmntwvo steve@steveklabnik.com 2024-03-02 11:47:08 push-vmunwxsksqvk 752534be
│  add a new function
○  vmunwxsk steve@steveklabnik.com 2024-03-02 11:47:08 f6f7dce9
│  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk v1.0.0 e202b67c
│  Update Cargo.toml

Tags don’t move

Here’s the difference from a bookmark. Let’s try to point v1.0.0 somewhere else:

$ jj tag set v1.0.0 -r @
Error: Refusing to move tag: v1.0.0
Hint: Use --allow-move to update existing tags.

jj bookmark set moves a bookmark without complaint, because that’s what bookmarks are for. A tag is supposed to be a fixed point, so jj makes you say you meant it:

$ jj tag set v1.0.0 -r @ --allow-move
Moved 1 tags to msmntwvo 752534be add a new function

Most of the time, you shouldn’t need that flag. If you find yourself reaching for it a lot, you probably wanted a bookmark.

Tags make history immutable

Remember immutability from the last couple of chapters? Tags are part of the immutable set, right alongside trunk(). So tagging a commit freezes it and everything behind it:

$ jj tag set v2.0.0 -r @-
Created 1 tags pointing to vmunwxsk f6f7dce9 add a comment to main
$ jj describe @- -m "second thoughts about this one"
Error: Commit f6f7dce9d9a1 is immutable
Hint: Could not modify commit: vmunwxsk f6f7dce9 add a comment to main
Hint: Immutable commits are used to protect shared history.

This can be surprising. If we tag a commit in the middle of work we’re still rearranging, jj won’t let us rearrange it any more. That makes sense: we told jj this commit was a fixed point. If we tagged it by mistake, jj tag delete puts things back:

$ jj tag delete v2.0.0
Deleted 1 tags.

And if the commit you tag happens to be the one your working copy is sitting on, you’ll see the same shuffle we saw when we pushed trunk:

$ jj tag set v1.1.0 -r @
Created 1 tags pointing to msmntwvo 752534be add a new function
Warning: The working-copy commit became immutable; a new commit has been created on top of it.
Working copy  (@) now at: yqrwvxnn 90f315f0 (empty) (no description set)
Parent commit (@-)      : msmntwvo 752534be add a new function

Same reason as before: you can’t keep editing a commit you’re no longer allowed to edit, so jj moves you up to a fresh one.

Pushing tags

Tags are local until you push them, and this works exactly like bookmarks did: a tag the remote has never heard of doesn’t get created by a bare jj git push. You name it with -t, the way you named a new bookmark with -b:

$ jj git push -t v1.0.0
Changes to push to origin:
  tag: v1.0.0 [add to e202b67c1f0a]

Once the remote has it, though, a bare push will keep it up to date along with everything else:

$ jj git push
Changes to push to origin:
  bookmark: trunk [move forward from e202b67c1f0a to 752534beb39f]
  tag: v1.0.0 [move forward from e202b67c1f0a to 752534beb39f]

jj also distinguishes the local tag from the remote one, the same way it does for bookmarks. An asterisk means they’ve drifted apart, and jj tag list will tell you by how much:

$ jj tag list
v1.0.0: msmntwvo 752534be add a new function
  @origin (behind by 2 commits): ksrmwuon e202b67c Update Cargo.toml

There is one limitation worth knowing: jj tag set creates lightweight tags. jj can read the annotated tags that git tag -a produces, but it can’t create them. If your project needs an annotated or signed tag for a release, make it through your hosting service or with git directly.

Deleting works like bookmarks too: jj tag delete marks the deletion locally. We can push tracked tag deletions explicitly with jj git push --deleted.

Updating trunk from upstream

Everything so far has assumed the repository stands still while we work. In a real project, other people will merge things into main, and our work will eventually need to catch up. Let’s see how to do that.

Getting the new commits

jj git fetch pulls new commits and bookmark positions down from the remote:

$ jj git fetch
bookmark: main@origin [updated] tracked

Notice what it didn’t do: nothing in our working copy moved, and no merge happened. Where git pull combines fetching with an integration step, jj keeps those two jobs separate. We can fetch first, inspect the result, and then choose how to move our work.

Let’s look at where things sit afterwards:

$ jj log
@  nxoupqvp steve@steveklabnik.com 2024-03-22 10:12:04 60d3e181
│  more work
○  tqmvurqz steve@steveklabnik.com 2024-03-22 10:12:04 my-feature d04bd54d
│  my feature
│ ◆  ztzvtupk steve@steveklabnik.com 2024-03-22 10:14:31 main 2fd78044
├─╯  upstream commit
◆  qoypmutx steve@steveklabnik.com 2024-03-22 09:58:12 23a248a4
│  initial
~

There’s the drift, drawn out. main moved forward, our two changes are still sitting on the old commit, and the two lines have forked apart.

The local main bookmark moved on its own, because it tracks main@origin and we hadn’t touched it locally. This is the normal case, and it’s why you rarely think about the local copy of trunk at all.

Also notice the diamonds: main and the initial commit are immutable. The default immutable heads are trunk() | tags() | untracked_remote_bookmarks(), and those heads plus all their ancestors are immutable. A tracked remote bookmark for a pull request is deliberately not an immutable head, so pushed review branches can still be rewritten.

Moving your work onto it

Now let’s catch up. jj rebase -b @ -o trunk() takes the whole branch our working copy is on and puts it on trunk:

$ jj rebase -b @ -o 'trunk()'
Rebased 2 commits to destination.
Working copy  (@) now at: nxoupqvp 355a63e0 more work
Parent commit (@-)      : tqmvurqz 71ade80c my-feature | my feature
Added 1 files, modified 0 files, removed 0 files
$ jj log
@  nxoupqvp steve@steveklabnik.com 2024-03-22 10:16:02 355a63e0
│  more work
○  tqmvurqz steve@steveklabnik.com 2024-03-22 10:16:02 my-feature 71ade80c
│  my feature
◆  ztzvtupk steve@steveklabnik.com 2024-03-22 10:14:31 main 2fd78044
│  upstream commit
~

One line again. Both changes kept their change IDs — nxoupqvp and tqmvurqz are the same changes they were before — and got new commit IDs, because their contents now sit on a different parent. The bookmark came along for the ride.

jj rebase takes two halves: which revisions move, and where they land.

There are three flags we can use to choose what moves:

  • -b (branch) moves everything connected to the given revision that isn’t already an ancestor of the destination. This is the one you want for “catch my work up to trunk”.
  • -s (source) moves that revision and its descendants.
  • -r (revision) moves exactly one revision, and reparents its descendants onto its old parent.

-o (onto) chooses where they land, leaving the destination’s existing descendants where they are. Its two siblings splice into a stack instead: -A inserts after the target and replants the target’s descendants on top of the moved work, and -B inserts before it.

trunk() is a revset function resolving to the main bookmark. jj git clone sets it up for you — you’ll see revset-aliases."trunk()" = "main@origin" in the repository’s config — so it points at whatever the project actually calls its trunk, and you can write trunk() in scripts and aliases without caring.

If the rebase produces conflicts, they get recorded in the rebased commits and you carry on, exactly as in the conflicts chapter. Nothing stops halfway, and there’s no --continue to remember.

Seeing what’s yours

The revset trunk()..@ means “everything from trunk up to my working copy”, which is a precise way of saying “my work”:

$ jj log -r 'trunk()..@'
@  nxoupqvp steve@steveklabnik.com 2024-03-22 10:16:02 355a63e0
│  more work
○  tqmvurqz steve@steveklabnik.com 2024-03-22 10:16:02 my-feature 71ade80c
│  my feature
~

That one earns a place in your config as an alias. We’ll do that in the customization section.

When trunk has diverged

Sometimes we commit directly onto main locally, and upstream moves too. Now both ends of the bookmark have moved, and jj won’t guess which one we meant:

$ jj git fetch
bookmark: main@origin [updated] tracked

$ jj log
@  oqtwrqnv steve@steveklabnik.com 2024-03-22 10:20:11 main?? main@git 914cd522
│  (empty) local commit on main
│ ◆  quvlnmky steve@steveklabnik.com 2024-03-22 10:20:37 main?? main@origin edd53bde
├─╯  upstream commit 2

The ?? marks a conflicted bookmark: one name, two candidate positions. It’s not a file conflict, and it doesn’t block you — but a bookmark pointing at two commits can’t be pushed anywhere sensible.

jj bookmark list lays out the disagreement:

$ jj bookmark list
main (conflicted):
  - ztzvtupk 2fd78044 upstream commit 1
  + oqtwrqnv 914cd522 (empty) local commit on main
  + quvlnmky edd53bde upstream commit 2
  @git (behind by 1 commits): oqtwrqnv 914cd522 (empty) local commit on main
  @origin (behind by 1 commits): quvlnmky edd53bde upstream commit 2
my-feature: tqmvurqz 71ade80c my feature
Hint: Some bookmarks have conflicts. Use `jj bookmark set <name> -r <rev>` to resolve.

The - line is the common ancestor the two sides started from, and the + lines are the two positions. Fix it the same way you’d fix the drift: put your local commit on top of the remote one.

$ jj rebase -r oqtwrqnv -o main@origin
Rebased 1 commits to destination.
Working copy  (@) now at: oqtwrqnv 07effb17 main* | (empty) local commit on main
Parent commit (@-)      : quvlnmky edd53bde main@origin | upstream commit 2
$ jj bookmark list
main: oqtwrqnv 07effb17 (empty) local commit on main
  @origin (behind by 1 commits): quvlnmky edd53bde upstream commit 2

The ?? is gone. The bookmark followed the commit it was pointing at, and once that commit sat on top of main@origin there was nothing left to disagree about. The * in main* means the local bookmark is ahead of the remote — the push you’d expect to make next.

If you’d rather throw the local side away than keep it, jj bookmark set main -r main@origin names the winner directly. Your commit isn’t lost; it’s still in the log, just no longer wearing the bookmark.

The habit

I like to fetch and rebase onto trunk regularly. The rebase is cheap, it never interrupts us, and any conflicts are recorded rather than blocking the command. That keeps catching up a small part of the ordinary workflow.

Working with a fork

When we contribute to a project we can’t push to directly, we usually have two remotes: we fetch from the project’s repository and push to our own fork. jj handles this much like git, but there are a couple of details worth knowing.

Let’s add the project’s repository as our second remote:

$ jj git remote add upstream https://github.com/someone/theproject.git

$ jj git remote list
origin https://github.com/steveklabnik/theproject.git
upstream https://github.com/someone/theproject.git

By convention origin is your fork, the one you can push to, and upstream is the real project. jj attaches no meaning to either name.

Fetching from both

$ jj git fetch --remote upstream
bookmark: main@upstream [new] untracked

Notice the word untracked. A bookmark from a remote we added by hand doesn’t become one of our local bookmarks. We get main@upstream, which we can refer to, but no local main follows it around.

That’s usually right for a fork. You don’t want your local main chasing two different remotes. Refer to main@upstream explicitly when you want it:

$ jj rebase -b @ -o main@upstream

It also means the upstream bookmark is immutable, since the default immutable set includes untracked_remote_bookmarks(). You can’t accidentally rewrite the project’s history, which is a reasonable default when it isn’t yours.

If you do want a local bookmark following it:

$ jj bookmark track main@upstream
Started tracking 1 remote bookmarks.

and jj bookmark untrack main@upstream undoes that.

--all-remotes fetches from everything at once, and jj bookmark list --all-remotes shows you where each name sits on each remote:

$ jj bookmark list --all-remotes
main: oqtwrqnv 07effb17 (empty) local commit on main
  @git: oqtwrqnv 07effb17 (empty) local commit on main
  @origin (behind by 1 commits): quvlnmky edd53bde upstream commit 2
main@upstream: quvlnmky edd53bde upstream commit 2
my-feature: tqmvurqz 71ade80c my feature

Fetching from one, pushing to the other

Typing --remote every time gets old, so let’s set the defaults for this repository:

$ jj config set --repo git.fetch upstream
$ jj config set --repo git.push origin

Now a bare jj git fetch reads from the project and a bare jj git push writes to your fork, which is the fork workflow in two settings. Because we used --repo, this applies to this checkout only, and it lives in your own config directory rather than in the repository, so there’s nothing to accidentally commit.

The rest is what we already know. We can rebase onto main@upstream to catch up, push a bookmark to our fork, and open the pull request from there.

Using jj with Gerrit

Everything in this section so far has assumed GitHub, or something shaped like it: branches, pull requests, one review per branch. Gerrit is the other major shape of code review, and it turns out jj fits it remarkably well — arguably better than git does.

In Gerrit, the unit of review isn’t a branch, it’s a single commit, called a “change” (or a “CL”). You upload a commit, a reviewer comments on it, you rewrite the commit and upload it again, and Gerrit shows each uploaded version as a numbered “patch set” on the same change. If that sounds familiar, it should: it’s exactly how we’ve been treating jj changes all along. One jj change becomes one Gerrit change, and rewriting it — with jj squash, jj absorb, jj describe, whatever we like — produces its next patch set.

Change IDs, twice over

Gerrit needs a way to recognize that a rewritten commit is a new version of an existing change, and its answer predates jj: a Change-Id: trailer in the commit message. That’s the same problem jj’s change IDs solve, but they are two different identifiers. When we upload, jj adds the trailer for us, deriving it from the jj change ID, and leaves any existing trailer alone. We don’t need to manage it, but don’t be surprised to see it in our descriptions afterwards.

Uploading

In a traditional Gerrit workflow, you push with a magic refspec: git push origin HEAD:refs/for/main. jj wraps this up in a single command:

$ jj gerrit upload -r 'trunk()..@' --remote-branch main

The -r revset selects what to upload — here, our whole stack — and --remote-branch names the branch the changes are intended to land on. Each revision in the revset becomes its own Gerrit change, so uploading a stack of three commits opens three changes, each reviewable on its own, with their relationships intact. This is the “stacked” workflow that takes real effort on GitHub, and on Gerrit it’s just how things work.

Because uploading may rewrite our commits — adding missing Change-Id trailers — as well as push them, I like to dry-run a broad revset before running it for real:

$ jj gerrit upload -r 'trunk()..@' --remote-branch main --dry-run

Responding to review is the part that should feel comfortable by now: edit the change like any other — jj edit, or fix things in @ and jj squash or jj absorb them down — and upload again. The stable trailer tells Gerrit it’s the same change, and the reviewer sees a new patch set, with Gerrit’s UI able to diff one patch set against another. There’s no branch to force-push and no bookmark to move.

A default branch

Typing --remote-branch main every time gets old. We can set a default for the repository:

$ jj config set --repo gerrit.default-remote-branch main

After that, jj gerrit upload -r 'trunk()..@' is all we need. There’s a matching gerrit.default-remote setting for pointing at the Gerrit instance itself, if the remote isn’t where we usually push.

More advanced workflows

The workflows we’ve seen in jj are different from git in the best ways. As we get a bit more advanced, there are some very impressive possibilities yet to see. This section will also address an interesting aspect of interoperability between jj and git: what happens when you take the git repository out of sight.

Here’s what we’re going to learn:

  • Working on all your branches simultaneously
  • A pull request workflow called “Stacked PRs”
  • Copying a change to another branch with jj duplicate
  • Running formatters across a whole stack with jj fix
  • Running any command across a stack with jj run
  • Workspaces that let you have multiple local checkouts
  • The rules of working alongside git in the default colocated repository
  • Non-colocated repositories, and where the line between jj and git sits

Working on all of your branches simultaneously

I think this is one of the first things I saw about jj that made me go “wait, WHAT?!?” I was asking questions on the jj Discord, and one of the developers, Austin, mentioned that

Also. I can rebase all my branches simultaneously too.

This section is going to explain what he meant by that, and how he uses jj to manage multiple pull requests at the same time.

First, we’ll do some set up to have a few pull requests going on at the same time. Then we’ll show you how you can develop against all of them simultaneously, and when your upstream updates, you can rebase them all simultaneously.

The set up

Let’s check out our example project to make sure we’re on the same place:

> jj log
@  msmntwvo steve@steveklabnik.com 2024-03-02 11:47:08 push-vmunwxsksqvk 752534be
│  add a new function
○  vmunwxsk steve@steveklabnik.com 2024-03-02 11:47:08 f6f7dce9
│  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml

Right! We have an outstanding pull request from a previous section. If you have made some of your own changes, maybe do this next section an extra time, so that you have enough outstanding branches. Austin’s original example had five, so that’s how many I’m going to make. If you want to make fewer, two works too, or you can go a little harder and make even more branches. Up to you!

Let’s make some more branches! We want to start them all from trunk, not on each other:

> jj new trunk
Working copy  (@) now at: opwqpunl 7ede4eb9 (empty) (no description set)
Parent commit (@-)      : ksrmwuon e202b67c trunk | Update Cargo.toml
Added 0 files, modified 1 files, removed 0 files
~/Documents/GitHub/sample-jj-project/hello-world> jj log
@  opwqpunl steve@steveklabnik.com 2024-03-17 14:12:52 7ede4eb9
│  (empty) (no description set)
│ ○  msmntwvo steve@steveklabnik.com 2024-03-02 11:47:08 push-vmunwxsksqvk 752534be
│ │  add a new function
│ ○  vmunwxsk steve@steveklabnik.com 2024-03-02 11:47:08 f6f7dce9
├─╯  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml
~

We’ve now started a new branch from trunk. Let’s give it a description, add a second commit with its own description, and then make a pull request out of it. Feel free to make changes in the code in here if you’d like, it doesn’t matter for our purposes.

> jj describe -m "display the birthday date on the settings page"
Working copy  (@) now at: opwqpunl 1a66beb1 (empty) display the birthday date on the settings page
Parent commit (@-)      : ksrmwuon e202b67c trunk | Update Cargo.toml
> jj new -m "have galactus query eks with time range"
Working copy  (@) now at: yxxppztp 3d123151 (empty) have galactus query eks with time range
Parent commit (@-)      : opwqpunl 1a66beb1 (empty) display the birthday date on the settings page
> jj git push -c @
Creating bookmark push-yxxppztpoyqq for revision yxxppztpoyqq
Changes to push to origin:
  bookmark: push-yxxppztpoyqq [add to 3d1231518dbf]
> jj log
@  yxxppztp steve@steveklabnik.com 2024-03-17 14:18:00 push-yxxppztpoyqq 3d123151
│  (empty) have galactus query eks with time range
○  opwqpunl steve@steveklabnik.com 2024-03-17 14:15:58 1a66beb1
│  (empty) display the birthday date on the settings page
│ ○  msmntwvo steve@steveklabnik.com 2024-03-02 11:47:08 push-vmunwxsksqvk 752534be
│ │  add a new function
│ ○  vmunwxsk steve@steveklabnik.com 2024-03-02 11:47:08 f6f7dce9
├─╯  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml
~

Great! We have two pull requests. Do this again at least once, but maybe two or three times. It’s cooler with more branches, trust me.

> jj new trunk -m "prepare to deploy to the cloud"
Working copy  (@) now at: tmnmvxyy 105cf6b5 (empty) prepare to deploy to the cloud
Parent commit (@-)      : ksrmwuon e202b67c trunk | Update Cargo.toml
> jj new -m "various fixes"
Working copy  (@) now at: rxpztwms 902a6cd2 (empty) various fixes
Parent commit (@-)      : tmnmvxyy 105cf6b5 (empty) prepare to deploy to the cloud
> jj git push -c @
Creating bookmark push-rxpztwmsszvk for revision rxpztwmsszvk
Changes to push to origin:
  bookmark: push-rxpztwmsszvk [add to 902a6cd22f30]

Your jj log will look like this:

> jj log
@  ymvptyyq steve@steveklabnik.com 2024-03-17 14:25:31 push-ymvptyyqmyul 728dbb1e
│  (empty) fixing all the breakage from updating dependencies
○  xulymzyp steve@steveklabnik.com 2024-03-17 14:25:14 1f7c69a5
│  (empty) updating dependencies
│ ○  zxyukunn steve@steveklabnik.com 2024-03-17 14:24:56 push-zxyukunnwolo 30081a6b
│ │  (empty) first 80% done
│ ○  tzsloruo steve@steveklabnik.com 2024-03-17 14:24:21 7c02f6ce
├─╯  (empty) another feature
│ ○  rxpztwms steve@steveklabnik.com 2024-03-17 14:23:00 push-rxpztwmsszvk 902a6cd2
│ │  (empty) various fixes
│ ○  tmnmvxyy steve@steveklabnik.com 2024-03-17 14:22:15 105cf6b5
├─╯  (empty) prepare to deploy to the cloud
│ ○  yxxppztp steve@steveklabnik.com 2024-03-17 14:18:00 push-yxxppztpoyqq 3d123151
│ │  (empty) have galactus query eks with time range
│ ○  opwqpunl steve@steveklabnik.com 2024-03-17 14:15:58 1a66beb1
├─╯  (empty) display the birthday date on the settings page
│ ○  msmntwvo steve@steveklabnik.com 2024-03-02 11:47:08 push-vmunwxsksqvk 752534be
│ │  add a new function
│ ○  vmunwxsk steve@steveklabnik.com 2024-03-02 11:47:08 f6f7dce9
├─╯  add a comment to main
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml

That’s a lot of branches!

We could do what we do with git, and just work on each branch individually. But we can do more interesting things than that: let’s work on every branch at the same time.

Working on every branch

So, here’s how we can work on every branch at the same time: we create a merge with the parents of every branch we have. My command looks like this, but you’ll have to use the appropriate change IDs for what your repository looks like. I’m also using the short form of each name, which helps when you have five of them! Anyway:

> jj new ym z r yx m -m "merge: steve's branch"
Working copy  (@) now at: xnutwmso 695806ff (empty) merge: steve's branch
Parent commit (@-)      : ymvptyyq 728dbb1e push-ymvptyyqmyul | (empty) fixing all the breakage from updating dependencies
Parent commit (@-)      : zxyukunn 30081a6b push-zxyukunnwolo | (empty) first 80% done
Parent commit (@-)      : rxpztwms 902a6cd2 push-rxpztwmsszvk | (empty) various fixes
Parent commit (@-)      : yxxppztp 3d123151 push-yxxppztpoyqq | (empty) have galactus query eks with time range
Parent commit (@-)      : msmntwvo 752534be push-vmunwxsksqvk | add a new function
Added 0 files, modified 1 files, removed 0 files

Whew! What a change! Five parents. Let’s create an extra one so we can use our squash-style workflow more easily: we temporarily work on a @ change, and then jj squash diffs back into whichever parent makes the most sense.

This is also where jj absorb earns its keep. With five parents in reach, a working copy full of small fixes belongs in five different places, and absorb files each hunk into whichever of them last touched those lines:

$ jj absorb

We covered it back in the real-world workflows section. Everything it can’t place with certainty stays in @ for you to deal with.

> jj new
Working copy  (@) now at: nllzosqm 85324040 (empty) (no description set)
Parent commit (@-)      : xnutwmso 695806ff (empty) merge: steve's branch

Check out this jj log:

> jj log
@  nllzosqm steve@steveklabnik.com 2024-03-17 14:36:36 85324040
│  (empty) (no description set)
○          xnutwmso steve@steveklabnik.com 2024-03-17 14:30:52 695806ff
├─┬─┬─┬─╮  (empty) merge: steve's branch
│ │ │ │ ○  msmntwvo steve@steveklabnik.com 2024-03-02 11:47:08 push-vmunwxsksqvk 752534be
│ │ │ │ │  add a new function
│ │ │ │ ○  vmunwxsk steve@steveklabnik.com 2024-03-02 11:47:08 f6f7dce9
│ │ │ │ │  add a comment to main
│ │ │ ○ │  yxxppztp steve@steveklabnik.com 2024-03-17 14:18:00 push-yxxppztpoyqq 3d123151
│ │ │ │ │  (empty) have galactus query eks with time range
│ │ │ ○ │  opwqpunl steve@steveklabnik.com 2024-03-17 14:15:58 1a66beb1
│ │ │ ├─╯  (empty) display the birthday date on the settings page
│ │ ○ │  rxpztwms steve@steveklabnik.com 2024-03-17 14:23:00 push-rxpztwmsszvk 902a6cd2
│ │ │ │  (empty) various fixes
│ │ ○ │  tmnmvxyy steve@steveklabnik.com 2024-03-17 14:22:15 105cf6b5
│ │ ├─╯  (empty) prepare to deploy to the cloud
│ ○ │  zxyukunn steve@steveklabnik.com 2024-03-17 14:24:56 push-zxyukunnwolo 30081a6b
│ │ │  (empty) first 80% done
│ ○ │  tzsloruo steve@steveklabnik.com 2024-03-17 14:24:21 7c02f6ce
│ ├─╯  (empty) another feature
○ │  ymvptyyq steve@steveklabnik.com 2024-03-17 14:25:31 push-ymvptyyqmyul 728dbb1e
│ │  (empty) fixing all the breakage from updating dependencies
○ │  xulymzyp steve@steveklabnik.com 2024-03-17 14:25:14 1f7c69a5
├─╯  (empty) updating dependencies
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml

Glorious!

So now our working copy has all of our changes in it. We can make changes, and then jj squash them into the appropriate branch. If we decide we want a new change at the head of any of these branches, we’ll need to use a rebase, but it’s not too bad:

> jj new z -m "second 80% done"
Working copy  (@) now at: kvupxvpv 2ea49586 (empty) second 80% done
Parent commit (@-)      : zxyukunn 30081a6b push-zxyukunnwolo | (empty) first 80% done
Added 0 files, modified 1 files, removed 0 files
> jj log
    @  kvupxvpv steve@steveklabnik.com 2024-03-17 14:43:28 46cb6847
    | (empty) second 80% done
  ○ |      xnutwmso steve@steveklabnik.com 2024-03-17 14:30:52 695806ff
╭─┼─┬─┬─╮  (empty) merge: steve's branch
○ │ │ │ │  msmntwvo steve@steveklabnik.com 2024-03-02 11:47:08 push-vmunwxsksqvk 752534be
│ │ │ │ │  add a new function
○ │ │ │ │  vmunwxsk steve@steveklabnik.com 2024-03-02 11:47:08 f6f7dce9
│ │ │ │ │  add a comment to main
│ │ │ │ ○  yxxppztp steve@steveklabnik.com 2024-03-17 14:18:00 push-yxxppztpoyqq 3d123151
│ │ │ │ │  (empty) have galactus query eks with time range
│ │ │ │ ○  opwqpunl steve@steveklabnik.com 2024-03-17 14:15:58 1a66beb1
├───────╯  (empty) display the birthday date on the settings page
│ │ │ ○  rxpztwms steve@steveklabnik.com 2024-03-17 14:23:00 push-rxpztwmsszvk 902a6cd2
│ │ │ │  (empty) various fixes
│ │ │ ○  tmnmvxyy steve@steveklabnik.com 2024-03-17 14:22:15 105cf6b5
├─────╯  (empty) prepare to deploy to the cloud
│ │ ○  zxyukunn steve@steveklabnik.com 2024-03-17 14:24:56 push-zxyukunnwolo 30081a6b
│ │ │  (empty) first 80% done
│ │ ○  tzsloruo steve@steveklabnik.com 2024-03-17 14:24:21 7c02f6ce
├───╯  (empty) another feature
│ ○  ymvptyyq steve@steveklabnik.com 2024-03-17 14:25:31 push-ymvptyyqmyul 728dbb1e
│ │  (empty) fixing all the breakage from updating dependencies
│ ○  xulymzyp steve@steveklabnik.com 2024-03-17 14:25:14 1f7c69a5
├─╯  (empty) updating dependencies
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 trunk e202b67c
│  Update Cargo.toml

Yikes! Don’t worry, we can fix that with a rebase:

> jj rebase -r xn -o m -o ym -o yx -o r -o kv
Rebased 1 commits to destination.

We want to rebase the revision xn “onto” the following destination revisions: m, ym, yx, r, and kv. Since we have multiple parents, that’s what will happen:

> jj log
○          xnutwmso steve@steveklabnik.com 2024-03-17 15:16:36 da67dfe1
├─┬─┬─┬─╮  (empty) merge: steve's branch
│ │ │ │ @  kvupxvpv steve@steveklabnik.com 2024-03-17 15:15:20 2ea49586
│ │ │ │ │  (empty) second 80% done
│ │ │ │ ○  zxyukunn steve@steveklabnik.com 2024-03-17 14:24:56 push-zxyukunnwolo 30081a6b
│ │ │ │ │  (empty) first 80% done
│ │ │ │ ○  tzsloruo steve@steveklabnik.com 2024-03-17 14:24:21 7c02f6ce
│ │ │ │ │  (empty) another feature
│ │ │ ○ │  rxpztwms steve@steveklabnik.com 2024-03-17 14:23:00 push-rxpztwmsszvk 902a6cd2
│ │ │ │ │  (empty) various fixes
│ │ │ ○ │  tmnmvxyy steve@steveklabnik.com 2024-03-17 14:22:15 105cf6b5
│ │ │ ├─╯  (empty) prepare to deploy to the cloud
│ │ ○ │  yxxppztp steve@steveklabnik.com 2024-03-17 14:18:00 push-yxxppztpoyqq 3d123151
│ │ │ │  (empty) have galactus query eks with time range
<snip>
~

We’re back to our beautiful tree, though our @ working commit got lost in the shuffle. It was empty anyway! Let’s bring it back:

> jj new xn
Working copy  (@) now at: pptrunzw 06442487 (empty) (no description set)
Parent commit (@-)      : xnutwmso bcf8a74b (empty) merge: steve's branch
Added 0 files, modified 2 files, removed 0 files
> jj log
@  pptrunzw steve@steveklabnik.com 2024-03-17 14:55:59 06442487
│  (empty) (no description set)
○          xnutwmso steve@steveklabnik.com 2024-03-17 14:52:08 bcf8a74b
├─┬─┬─┬─╮  (empty) merge: steve's branch

<snip>

Excellent.

Rebasing on upstream changes

What happens when some changes land upstream, and we need to rebase our pull requests. If you’re like me, you often have multiple PRs going at any given time. Rebasing them all can be very tedious. Here’s how we can do that with this workflow.

First, let’s make a change upstream. I’m going to do this by merging my open PR from earlier in the tutorial:

After fetching changes, our log looks like this:

> jj git fetch
> jj log
@  xqkmpxlq steve@steveklabnik.com 2024-03-17 15:18:11 fccf0626
│  (empty) (no description set)
○          xnutwmso steve@steveklabnik.com 2024-03-17 15:16:36 da67dfe1
├─┬─┬─┬─╮  (empty) merge: steve's branch
│ │ │ │ ○  kvupxvpv steve@steveklabnik.com 2024-03-17 15:15:20 2ea49586
│ │ │ │ │  (empty) second 80% done
│ │ │ │ ○  zxyukunn steve@steveklabnik.com 2024-03-17 14:24:56 push-zxyukunnwolo 30081a6b
│ │ │ │ │  (empty) first 80% done
│ │ │ │ ○  tzsloruo steve@steveklabnik.com 2024-03-17 14:24:21 7c02f6ce
│ │ │ │ │  (empty) another feature
│ │ │ ○ │  rxpztwms steve@steveklabnik.com 2024-03-17 14:23:00 push-rxpztwmsszvk 902a6cd2
│ │ │ │ │  (empty) various fixes
│ │ │ ○ │  tmnmvxyy steve@steveklabnik.com 2024-03-17 14:22:15 105cf6b5
│ │ │ ├─╯  (empty) prepare to deploy to the cloud
│ │ ○ │  yxxppztp steve@steveklabnik.com 2024-03-17 14:18:00 push-yxxppztpoyqq 3d123151
│ │ │ │  (empty) have galactus query eks with time range
│ │ ○ │  opwqpunl steve@steveklabnik.com 2024-03-17 14:15:58 1a66beb1
│ │ ├─╯  (empty) display the birthday date on the settings page
│ ○ │  ymvptyyq steve@steveklabnik.com 2024-03-17 14:25:31 push-ymvptyyqmyul 728dbb1e
│ │ │  (empty) fixing all the breakage from updating dependencies
│ ○ │  xulymzyp steve@steveklabnik.com 2024-03-17 14:25:14 1f7c69a5
│ ├─╯  (empty) updating dependencies
○ │  msmntwvo/1 steve@steveklabnik.com 2024-03-17 14:45:41 push-vmunwxsksqvk* cdca9211 (divergent)
│ │  add a new function
○ │  vmunwxsk/1 steve@steveklabnik.com 2024-03-17 14:45:41 3a08be8a (divergent)
├─╯  add a comment to main
│ ◆  okyzuxnk steve@steveklabnik.com 2024-03-17 14:59:02 trunk b7f9d708
╭─╯  (empty) Merge pull request #1 from steveklabnik/push-vmunwxsksqvk
◆  ksrmwuon steve@steveklabnik.com 2024-03-01 23:10:35 e202b67c
│  Update Cargo.toml
~

Uh oh! That’s got some concerning stuff. Two of our changes are now marked (divergent), with those /1 suffixes on their change IDs. That’s because the merge that just landed on trunk contains the pushed version of our PR’s commits, while our local copies have been rewritten since — so each of those change IDs now names two visible commits. We’ll deal with them in a moment, and there’s a whole chapter on divergent changes if you want the full story. There’s also the new merge commit that trunk is set up to. Since all of our branches were off of the change where trunk used to be, we should rebase them on top of the new trunk. First, we want to make sure that @ is at the empty change on top of our merge commit. You can see from the jj log just above that that is true.

We can rebase all of our PRs with one command:

> jj rebase -s 'roots(trunk..@)' -o trunk
Rebased 14 commits to destination.
Working copy  (@) now at: ltupzukw 9a496ef6 (empty) (no description set)
Parent commit (@-)      : xnutwmso 6be25a32 (empty) merge: steve's branch

This is using some revset stuff we haven’t seen before! Let’s break it down:

This is using jj rebase -s rather than -r, like we’ve been doing before. Here’s the description from jj rebase --help:

  -s, --source <REVSETS>
          Rebase specified revision(s) together with their trees of descendants (can be repeated)

That’s a little rough. There are some helpful diagrams in jj rebase --help that got me to understand the differences between -r, -s, and -b, but the short of it is that -r will sort of “rip out” a change and move it somewhere else. -s does that, but also moves descendants. In other words, imagine that we have a history like this:

A - B - C - D 

If we jj rebase -r C to somewhere else, it will only move that revision, and so you end up with

A - B - D

Whereas -s operates more like I’d be used to with git: it takes the children too, so after jj rebase -s C, you’d have:

A - B

as both C and D are somewhere else now. -b works to rebase a branch.

Note that -s takes a revset that may resolve to many revisions, and every one of them gets rebased. Older versions of jj made you write an all: prefix ('all:roots(trunk..@)') to confirm you meant more than one revision. That prefix has been removed; passing it now is a parse error.

Finally, trunk..@ is being passed to the roots() function. trunk..@ is a range, so it will give every change between where trunk is and @. The roots() function gives back the roots of the changes provided to it, so in this case, commits that have children but no parents. To which you may say, “but don’t those commits have parents?” In general, sure, but because we are only returning the set of those commits, their parents aren’t in the set, and therefore don’t exist for the purposes of a function like this. This means we end up with the commits that have trunk as parents, and have children themselves. The roots of the tree.

Let’s put it all together:

$ jj rebase -s 'roots(trunk..@)' -o trunk

We’re rebasing all of the root changes from trunk to @ onto trunk.

Kind of a mouthful, but not too bad to understand!

Here’s what our jj log looks like:

> jj log
○  xnutwmso steve@steveklabnik.com 2024-03-17 16:01:56 ce833ae7
│  (empty) merge: steve's branch
○          xqkmpxlq steve@steveklabnik.com 2024-03-17 16:01:56 5dc292c2
├─┬─┬─┬─╮  (empty) (no description set)
│ │ │ │ │ @  ltupzukw steve@steveklabnik.com 2024-03-17 16:01:56 edf9cd58
╭─┬─┬─┬─┬─╯  (empty) (no description set)
│ │ │ │ ○  yxxppztp steve@steveklabnik.com 2024-03-17 16:01:56 push-yxxppztpoyqq* b3db74d3
│ │ │ │ │  (empty) have galactus query eks with time range
│ │ │ │ ○  opwqpunl steve@steveklabnik.com 2024-03-17 16:01:56 713c692d
│ │ │ │ │  (empty) display the birthday date on the settings page
│ │ │ ○ │  rxpztwms steve@steveklabnik.com 2024-03-17 16:01:56 push-rxpztwmsszvk* 76dbbcd1
│ │ │ │ │  (empty) various fixes
│ │ │ ○ │  tmnmvxyy steve@steveklabnik.com 2024-03-17 16:01:56 d0f7f627
│ │ │ ├─╯  (empty) prepare to deploy to the cloud
│ │ ○ │  ymvptyyq steve@steveklabnik.com 2024-03-17 16:01:56 push-ymvptyyqmyul* f448e93a
│ │ │ │  (empty) fixing all the breakage from updating dependencies
│ │ ○ │  xulymzyp steve@steveklabnik.com 2024-03-17 16:01:56 d608ebd3
│ │ ├─╯  (empty) updating dependencies
│ ○ │  msmntwvo/1 steve@steveklabnik.com 2024-03-17 16:01:56 push-vmunwxsksqvk* 21569f7a (divergent)
│ │ │  (empty) add a new function
│ ○ │  vmunwxsk/1 steve@steveklabnik.com 2024-03-17 16:01:56 9a050939 (divergent)
│ ├─╯  (empty) add a comment to main
○ │  kvupxvpv steve@steveklabnik.com 2024-03-17 16:01:56 3b0a722a
│ │  (empty) second 80% done
○ │  zxyukunn steve@steveklabnik.com 2024-03-17 16:01:56 push-zxyukunnwolo* 7e826ad4
│ │  (empty) first 80% done
○ │  tzsloruo steve@steveklabnik.com 2024-03-17 16:01:56 792cc601
├─╯  (empty) another feature
◆  okyzuxnk steve@steveklabnik.com 2024-03-17 14:59:02 trunk b7f9d708
│  (empty) Merge pull request #1 from steveklabnik/push-vmunwxsksqvk
~

Let’s move @ back to where we want it:

> jj new xn
Working copy  (@) now at: vvvouunp 78919d69 (empty) (no description set)
Parent commit (@-)      : xnutwmso ce833ae7 (empty) merge: steve's branch
> jj abandon l
Abandoned 1 commits:
  ltupzukw edf9cd58 (empty) (no description set)
> jj log
@  vvvouunp steve@steveklabnik.com 2024-03-17 16:02:39 78919d69
│  (empty) (no description set)
○  xnutwmso steve@steveklabnik.com 2024-03-17 16:01:56 ce833ae7
│  (empty) merge: steve's branch
○          xqkmpxlq steve@steveklabnik.com 2024-03-17 16:01:56 5dc292c2
├─┬─┬─┬─╮  (empty) (no description set)
│ │ │ │ ○  yxxppztp steve@steveklabnik.com 2024-03-17 16:01:56 push-yxxppztpoyqq* b3db74d3
│ │ │ │ │  (empty) have galactus query eks with time range
│ │ │ │ ○  opwqpunl steve@steveklabnik.com 2024-03-17 16:01:56 713c692d
│ │ │ │ │  (empty) display the birthday date on the settings page
│ │ │ ○ │  rxpztwms steve@steveklabnik.com 2024-03-17 16:01:56 push-rxpztwmsszvk* 76dbbcd1
│ │ │ │ │  (empty) various fixes
│ │ │ ○ │  tmnmvxyy steve@steveklabnik.com 2024-03-17 16:01:56 d0f7f627
│ │ │ ├─╯  (empty) prepare to deploy to the cloud
│ │ ○ │  ymvptyyq steve@steveklabnik.com 2024-03-17 16:01:56 push-ymvptyyqmyul* f448e93a
│ │ │ │  (empty) fixing all the breakage from updating dependencies
│ │ ○ │  xulymzyp steve@steveklabnik.com 2024-03-17 16:01:56 d608ebd3
│ │ ├─╯  (empty) updating dependencies
│ ○ │  msmntwvo/1 steve@steveklabnik.com 2024-03-17 16:01:56 push-vmunwxsksqvk* 21569f7a (divergent)
│ │ │  (empty) add a new function
│ ○ │  vmunwxsk/1 steve@steveklabnik.com 2024-03-17 16:01:56 9a050939 (divergent)
│ ├─╯  (empty) add a comment to main
○ │  kvupxvpv steve@steveklabnik.com 2024-03-17 16:01:56 3b0a722a
│ │  (empty) second 80% done
○ │  zxyukunn steve@steveklabnik.com 2024-03-17 16:01:56 push-zxyukunnwolo* 7e826ad4
│ │  (empty) first 80% done
○ │  tzsloruo steve@steveklabnik.com 2024-03-17 16:01:56 792cc601
├─╯  (empty) another feature
◆  okyzuxnk steve@steveklabnik.com 2024-03-17 14:59:02 trunk b7f9d708
│  (empty) Merge pull request #1 from steveklabnik/push-vmunwxsksqvk
~

Looking pretty good! One last thing: what’s up with those divergent commits? Those are our local copies of the two changes that already landed on trunk through the merged PR. The versions inside trunk are the ones that matter now, so we can abandon the whole local line. We use commit IDs rather than the change IDs — the change IDs are exactly what’s ambiguous when a change is divergent:

> jj abandon '9a050939::21569f7a'
Abandoned 2 commits:
  msmntwvo/1 21569f7a push-vmunwxsksqvk* | (divergent) (empty) add a new function
  vmunwxsk/1 9a050939 (divergent) (empty) add a comment to main
Deleted bookmarks: push-vmunwxsksqvk
Rebased 3 descendant commits onto parents of abandoned commits
Working copy  (@) now at: vvvouunp e3f9254f (empty) (no description set)
Parent commit (@-)      : xnutwmso 0459bd1c (empty) merge: steve's branch

Notice that jj deleted the push-vmunwxsksqvk bookmark for us as part of the abandon, which suits us fine: the pull request is merged, and jj git push --deleted will clean the branch up on GitHub too.

And now our log is clean:

> jj log
@  vvvouunp steve@steveklabnik.com 2024-03-17 16:06:15 e3f9254f
│  (empty) (no description set)
○  xnutwmso steve@steveklabnik.com 2024-03-17 16:06:15 0459bd1c
│  (empty) merge: steve's branch
○        xqkmpxlq steve@steveklabnik.com 2024-03-17 16:06:15 c9e5fa35
├─┬─┬─╮  (empty) (no description set)
│ │ │ ○  yxxppztp steve@steveklabnik.com 2024-03-17 16:01:56 push-yxxppztpoyqq* b3db74d3
│ │ │ │  (empty) have galactus query eks with time range
│ │ │ ○  opwqpunl steve@steveklabnik.com 2024-03-17 16:01:56 713c692d
│ │ │ │  (empty) display the birthday date on the settings page
│ │ ○ │  rxpztwms steve@steveklabnik.com 2024-03-17 16:01:56 push-rxpztwmsszvk* 76dbbcd1
│ │ │ │  (empty) various fixes
│ │ ○ │  tmnmvxyy steve@steveklabnik.com 2024-03-17 16:01:56 d0f7f627
│ │ ├─╯  (empty) prepare to deploy to the cloud
│ ○ │  ymvptyyq steve@steveklabnik.com 2024-03-17 16:01:56 push-ymvptyyqmyul* f448e93a
│ │ │  (empty) fixing all the breakage from updating dependencies
│ ○ │  xulymzyp steve@steveklabnik.com 2024-03-17 16:01:56 d608ebd3
│ ├─╯  (empty) updating dependencies
○ │  kvupxvpv steve@steveklabnik.com 2024-03-17 16:01:56 3b0a722a
│ │  (empty) second 80% done
○ │  zxyukunn steve@steveklabnik.com 2024-03-17 16:01:56 push-zxyukunnwolo* 7e826ad4
│ │  (empty) first 80% done
○ │  tzsloruo steve@steveklabnik.com 2024-03-17 16:01:56 792cc601
├─╯  (empty) another feature
◆  okyzuxnk steve@steveklabnik.com 2024-03-17 14:59:02 trunk b7f9d708
│  (empty) Merge pull request #1 from steveklabnik/push-vmunwxsksqvk
~

And there we go!

Conclusion

This workflow may not make sense to you, but it is a very neat example of using the tools you already know to do something completely different! We’re going to talk about a slightly different workflow next: “stacked pull requests”. They’re similar in some ways to this approach, but there’s also some differences too.

Stacked pull requests

Back in the conflicts chapter, I said that recording conflicts and rebasing automatically were the ingredients for a useful workflow. It’s time to see what I meant.

Let’s say we’re building something that’s too big for one pull request. We could open one very large pull request, or open a small one and wait for it to merge before starting the next piece. Neither option lets us keep moving while still giving the reviewer small changes.

The third option is a stack: a chain of small pull requests, each based on the one below, reviewed and merged from the bottom up. This is possible in git, but maintaining the stack takes work. Every time the bottom changes during review, every branch above it needs to be rebased.

jj has been doing those rebases automatically since chapter one. That makes the workflow much easier to maintain.

Building the stack

Let’s build a feature in three pieces, each on top of the last:

$ jj new trunk -m "add the parser"
$ jj new -m "add the evaluator"
$ jj new -m "wire it up to the CLI"
$ jj log
@  nvpxkvxo steve@steveklabnik.com 2024-03-21 09:45:24 6850afd9
│  wire it up to the CLI
○  mwzzqwkt steve@steveklabnik.com 2024-03-21 09:45:24 716bd51d
│  add the evaluator
○  rtotzlru steve@steveklabnik.com 2024-03-21 09:45:24 904decf4
│  add the parser
◆  mnysvqxx steve@steveklabnik.com 2024-03-21 09:45:23 trunk eecde2ae
│  Update Cargo.toml

Nothing new so far — that’s just three changes in a row. GitHub needs names, though, so let’s give each one a bookmark:

$ jj bookmark create parser -r rtotzlru
Created 1 bookmarks pointing to rtotzlru 904decf4 parser | add the parser
$ jj bookmark create evaluator -r mwzzqwkt
Created 1 bookmarks pointing to mwzzqwkt 716bd51d evaluator | add the evaluator
$ jj bookmark create cli -r nvpxkvxo
Created 1 bookmarks pointing to nvpxkvxo 6850afd9 cli | wire it up to the CLI

jj git push takes -b more than once:

$ jj git push -b parser -b evaluator -b cli
Changes to push to origin:
  bookmark: cli [add to 6850afd9040b]
  bookmark: evaluator [add to 716bd51df2e4]
  bookmark: parser [add to 904decf432ba]

Now open three PRs, each targeting the one below: parser into trunk, evaluator into parser, cli into evaluator. GitHub will show each PR containing only its own commit, which is the point — your reviewer sees three small diffs instead of one big one.

Updating the stack

Now let’s say someone reviews the bottom pull request and asks for a change. In git, we’d fix the parser commit, rebase evaluator onto it, and then rebase cli onto that. In jj, we can edit the commit directly:

$ jj edit parser
Working copy  (@) now at: rtotzlru 904decf4 parser | add the parser
Parent commit (@-)      : mnysvqxx eecde2ae trunk | Update Cargo.toml

Make the fix the reviewer asked for, and look at the log:

$ jj log
Rebased 2 descendant commits onto updated working copy.
○  nvpxkvxo steve@steveklabnik.com 2024-03-21 09:45:41 cli* c3b1a9f6
│  wire it up to the CLI
○  mwzzqwkt steve@steveklabnik.com 2024-03-21 09:45:41 evaluator* 5a6f2c6a
│  add the evaluator
@  rtotzlru steve@steveklabnik.com 2024-03-21 09:45:41 parser* 3b550959
│  add the parser
◆  mnysvqxx steve@steveklabnik.com 2024-03-21 09:45:23 trunk eecde2ae
│  Update Cargo.toml

There’s a lot in this one screen. jj rebased both descendants automatically, so every commit ID changed. The important bit is that all three bookmarks are still attached to their commits. They each grew an asterisk, meaning they’ve drifted from what the remote has, but none of them got left behind.

That’s the payoff for bookmarks following rewrites, which we covered a couple of chapters ago. Rewriting a commit carries its bookmark along, and an automatic rebase is a rewrite. So the entire stack re-pointed itself.

One push updates all three pull requests:

$ jj git push --all
Changes to push to origin:
  bookmark: cli [move sideways from 6850afd9040b to c3b1a9f61375]
  bookmark: evaluator [move sideways from 716bd51df2e4 to 5a6f2c6aa13c]
  bookmark: parser [move sideways from 904decf432ba to 3b550959d7d9]

That’s the maintenance work we would otherwise have done by hand.

When the bottom merges

Eventually the parser PR gets approved and merged. Fetch, and trunk has moved out from under your stack:

$ jj git fetch
bookmark: trunk@origin [updated] tracked
$ jj log
@  nvpxkvxo steve@steveklabnik.com 2024-03-21 09:45:41 cli c3b1a9f6
│  wire it up to the CLI
○  mwzzqwkt steve@steveklabnik.com 2024-03-21 09:45:41 evaluator 5a6f2c6a
│  add the evaluator
│ ◆  yzkopsqy maintainer@example.com 2024-03-21 09:46:07 trunk c5dd335b
╭─┤  (empty) Merge pull request #1 from steve/parser
│ │
│ ~
│
◆  rtotzlru steve@steveklabnik.com 2024-03-21 09:45:41 parser 3b550959
│  add the parser
~

Two things changed shape here, and both are worth a look.

trunk is a now, which we’d expect — it’s the trunk, it’s shared, it’s immutable. But so is parser. Your parser commit got merged into trunk, which makes it an ancestor of trunk, which makes it immutable too. That work is shared now, so jj has stopped letting us rewrite it.

The other thing: evaluator and cli are still sitting on the old parser commit, off to the side, while trunk has moved on. Move them across:

$ jj rebase -s evaluator -o trunk
Rebased 2 commits to destination.
Working copy  (@) now at: nvpxkvxo a0bc5b05 cli* | wire it up to the CLI
Parent commit (@-)      : mwzzqwkt 263f373c evaluator* | add the evaluator

-s takes the change and all its descendants, so evaluator and cli moved together, and their bookmarks came along as before:

$ jj log
@  nvpxkvxo steve@steveklabnik.com 2024-03-21 09:47:27 cli* a0bc5b05
│  wire it up to the CLI
○  mwzzqwkt steve@steveklabnik.com 2024-03-21 09:47:27 evaluator* 263f373c
│  add the evaluator
◆  yzkopsqy maintainer@example.com 2024-03-21 09:46:07 trunk c5dd335b
│  (empty) Merge pull request #1 from steve/parser
~

A clean stack again, one PR shorter. Push it, and retarget the evaluator PR at trunk in the GitHub UI, since the branch it used to be based on is about to disappear:

$ jj git push -b evaluator -b cli
Changes to push to origin:
  bookmark: cli [move sideways from c3b1a9f61375 to a0bc5b0507af]
  bookmark: evaluator [move sideways from 5a6f2c6aa13c to 263f373c557b]

Then tidy up the bookmark for the PR that merged. jj bookmark delete marks it, and the push carries the deletion to the remote:

$ jj bookmark delete parser
Deleted 1 bookmarks.
$ jj git push --deleted
Changes to push to origin:
  bookmark: parser [delete from 3b550959d7d9]

Is this worth it?

For a two-part change, I would probably open one pull request. A stack becomes more useful when we have several dependent pieces and a reviewer who would rather look at them one at a time.

It’s also worth noticing that we didn’t need a command specifically for stacked pull requests. We used jj new, jj edit, jj bookmark, jj rebase, and jj git push, just as we did in the earlier chapters. The workflow comes from combining those familiar commands in a different way.

Copying a change with jj duplicate

Every history command we’ve used so far has moved work. jj rebase moves a change to a new parent, jj squash moves it into another commit, and jj absorb moves hunks around. Sometimes we want to copy a change instead. That’s what jj duplicate does: the original stays where it is, and a second, independent change appears somewhere else.

This is the equivalent of git cherry-pick. A common example is a fix that landed on our main line of work and is also needed on a release branch:

$ jj log
@  qrmutqws steve@steveklabnik.com 2024-03-27 10:30:47 5e653078
│  fix a bug
│ ○  myvqszlv steve@steveklabnik.com 2024-03-27 10:30:47 c25044fc
├─╯  release prep
○  unrvswsv steve@steveklabnik.com 2024-03-27 10:30:47 ecc089fe
│  initial
◆  zzzzzzzz root() 00000000
$ jj duplicate qrmutqws --onto myvqszlv
Duplicated 5e653078ccee as mzrozlul 5b488518 fix a bug
$ jj log
@  qrmutqws steve@steveklabnik.com 2024-03-27 10:30:47 5e653078
│  fix a bug
│ ○  mzrozlul steve@steveklabnik.com 2024-03-27 10:30:47 5b488518
│ │  fix a bug
│ ○  myvqszlv steve@steveklabnik.com 2024-03-27 10:30:47 c25044fc
├─╯  release prep
○  unrvswsv steve@steveklabnik.com 2024-03-27 10:30:47 ecc089fe
│  initial
◆  zzzzzzzz root() 00000000

Excellent. The fix is now in both places, and our working copy never moved.

The copy is a different change

Let’s look closely at the two IDs. The original is qrmutqws; the copy is mzrozlul. Same description, same contents, different change.

This is the important distinction between duplicate and rebase. A rebased commit keeps its change ID because it’s still the same change in a new place. A duplicated commit gets a new one because there are now two independent changes. If we edit one, the other doesn’t follow.

It also means the two copies will be pushed as separate commits and reviewed separately, which is what you want for a backport, and is exactly what a git cherry-pick does.

Where the copy lands

We used --onto to put the copy on a destination. The same -A and -B flags we saw with jj rebase work here too. They let us insert the copy into the middle of a stack rather than on top of something:

$ jj duplicate qrmutqws -A myvqszlv

With no destination at all, the copy becomes a sibling of the original and shares its parents. We can use that form when we want to try two versions of the same idea side by side and keep both.

You can duplicate several changes at once by naming a revset, which is how you backport a whole feature rather than one commit.

When not to

If we find ourselves duplicating the same fix onto several release branches every time, a merge may be a better fit. Duplicating makes sense when the destination genuinely needs its own version of the change, such as a backport that will be maintained separately or an experiment we want two versions of. If we only want to move a change, jj rebase leaves us with one copy to keep track of.

Running formatters with jj fix

In git, we may reach for a pre-commit hook to run a formatter. jj doesn’t run git hooks, but it does have a different approach: jj fix can run a formatter over a whole stack of commits.

We configure the command to run and the files it applies to:

[fix.tools.rustfmt]
command = ["rustfmt", "--emit", "stdout"]
patterns = ["glob:**/*.rs"]

The contract is simple: our tool reads a file on stdin, writes the fixed version to stdout, and jj puts the result back. Any formatter that can do that works, and we can configure as many as we like. Each [fix.tools.<name>] section gets its own patterns.

Then:

$ jj fix
Fixed 2 commits of 4 checked.
Working copy  (@) now at: kvrxwpqm 1a7d451e (empty) add docs
Parent commit (@-)      : plzxxqzu 55ac427d (no description set)

jj examined four commits and found two that actually needed changing. It rewrote both in place and rebased everything downstream, just like the other history-editing commands we’ve used.

What gets fixed

With no arguments, jj fix covers reachable(@, mutable()): our working copy and every mutable commit connected to it. Remember, “mutable” means jj permits us to rewrite the commit; it doesn’t mean the commit is unpushed. In particular, commits on a tracked remote bookmark stay mutable by default so that pull request stacks can be rewritten. Bare jj fix can therefore touch work that’s already under review, but it won’t touch immutable commits such as trunk.

This is what makes it different from a hook. A formatter run as a pre-commit hook leaves every earlier commit in the stack unformatted, so a stack of five commits gets five rounds of “fix formatting” noise, or one formatting commit at the end that touches files the other four introduced. jj fix reformats all five as if you’d had the formatter on the whole time.

We can use -s to narrow or widen that set:

$ jj fix -s @
$ jj fix -s 'mutable()'

Naming a revision fixes that revision and its descendants, because changing a file in the middle of a stack means everything above needs rebasing anyway. And the immutable rule still applies — asking for all() gets you a refusal rather than a rewrite of trunk:

$ jj fix -s 'all()'
Error: The root commit 000000000000 is immutable

That’s a purely local repository talking; in a repository with a pushed trunk, the refusal names the trunk commit instead. Either way, nothing shared gets rewritten.

If you’d rather it defaulted to something else, revsets.fix in your config sets the default scope.

When to run it

jj fix rewrites commits, so every commit it touches gets a new commit ID. For work we haven’t pushed, that doesn’t matter. On a branch someone is reviewing, it means the next push will rewrite the remote commits, and the review tool may show the formatting changes again. I prefer to run it before pushing when I can.

It may run your formatter once for every distinct version of a file in the stack, which on a long stack is not free. Identical file contents are deduplicated, but formatting history still costs more than formatting only the tip. That’s the price of the history coming out clean.

The other use

Formatting is the obvious case, but we can use the same mechanism for any tool that reads and writes a file this way. A sed script or a purpose-built codemod works too. jj fix lets us apply it consistently across a stack rather than only at the tip.

Running a command across revisions with jj run

jj fix pipes each file through a formatter. Sometimes we want to run a more general command against several revisions. jj run checks out every revision we name in an isolated working copy, runs a command there, and, by default, amends the revision with whatever the command changed. Descendants rebase as usual, so we can apply one command to a whole stack.

There are two jobs I find useful here, and they want opposite flags.

Testing a whole stack

First, we can run the tests against every commit in a stack and find out which one is broken. We don’t want a test to rewrite anything, so we’ll pass --ignore-changes. This throws away changes made inside the isolated checkout, which means no revision gets rewritten and immutable commits are fair game too. It is not a sandbox, though: side effects elsewhere, such as network requests or writes outside the checkout, still happen.

$ jj run -r 'trunk()..@' --ignore-changes -- cargo test

When everything passes, jj says so and leaves the stack untouched:

$ jj run -r 'description(glob:"step*")' --ignore-changes -- sh -c 'test $(cat n.txt) -gt 3'
Nothing changed.

When something fails, jj stops and names the offending revision:

Error: the command 'sh -c test $(cat n.txt) -gt 3' failed with exit status: 1
Hint: Failed revision: nwwnpuuz 7f1e1a07 step one

This is useful because a pre-commit hook checks the tip, while jj run --ignore-changes checks every commit in the stack independently. We can catch a stack that passes at the top but is broken three commits down. It’s the same idea as jj bisect, run exhaustively instead of by binary search.

Each command runs with JJ_CHANGE_ID, JJ_COMMIT_ID, and JJ_WORKSPACE_ROOT in its environment, and -j runs several revisions in parallel:

$ jj run -r 'trunk()..@' --ignore-changes -j 4 -- cargo test

Rewriting a whole stack

For the second job, let’s leave off --ignore-changes. Now the filesystem changes stick, and each revision is amended with the command’s output. This is how we can apply a codemod across history instead of applying it to the tip and patching up the earlier commits afterwards.

$ jj run -r 'description(glob:"call foo*")' -- sh -c "find . -name '*.rs' -exec sed -i '' 's/foo/bar/g' {} +"
Rewrote 2 commits.

Every commit that called foo now calls bar, as if you’d made the rename from the start. jj fix covers the formatting case with less ceremony; reach for jj run when the transformation is a real command, not a stdin-to-stdout filter.

Commands that are safe to repeat

There’s one important rule: our command has to be idempotent across the stack, because jj runs it once per revision and then rebases each result onto the last. A rename is fine; if we apply it twice, the second pass is a no-op. A blind echo X >> file is not. Every commit appends its own X, the rebased contents no longer agree, and we get conflicts instead of a clean rewrite. If the command’s output depends on what came before, we should use a different approach.

For anything read-only — tests, linters, a build check — pass --ignore-changes and the question doesn’t arise.

Workspaces

Two chapters ago, we worked on several branches at once without leaving our working copy, by editing commits directly. That covers most of what people use git branch switching for. Sometimes, though, we really do want two directories on disk at the same time.

For example, we may want a long build or test run in one tree while we keep editing in another. We may also want two versions of the code open side by side, or a scratch checkout for a tool that does a lot of work whenever files change.

git calls these worktrees. jj calls them workspaces.

Making one

$ jj workspace add ../feature-b
Created workspace in "../feature-b"
Working copy  (@) now at: omvuuyon b1c8e7a0 (empty) (no description set)
Parent commit (@-)      : posvwlwz 6c45c26f initial
Added 1 files, modified 0 files, removed 0 files

There’s now a second working directory at ../feature-b, backed by the same repository. It got its own fresh empty change, omvuuyon. This is the important part: each workspace has its own @.

$ jj workspace list
default: . vtmvqlrp 825c9b05 feature a
feature-b: ../feature-b omvuuyon b1c8e7a0 (empty) (no description set)

The workspace we started in is called default. Our new one took its name from the directory. We can use --name to pick a different name, and -r to start at a revision other than the default one.

The log shows both, marked with @ suffixed by the workspace name:

$ jj log
@  vtmvqlrp steve@steveklabnik.com 2024-03-23 11:04:16 default@ 825c9b05
│  feature a
│ ○  omvuuyon steve@steveklabnik.com 2024-03-23 11:04:16 feature-b@ b1c8e7a0
├─╯  (empty) (no description set)
○  posvwlwz steve@steveklabnik.com 2024-03-23 11:03:52 6c45c26f
│  initial
◆  zzzzzzzz root() 00000000

default@ is our working copy; feature-b@ is the other workspace’s. Two anonymous branches, two directories, one repository.

Now let’s cd ../feature-b. It behaves like any other jj repository, with the same history, bookmarks, and operation log. Work we do here is visible from the other workspace immediately, because there’s only one repository underneath.

$ jj st
Working copy changes:
A b.txt
Working copy  (@) : omvuuyon 66ff7d1e feature b
Parent commit (@-): posvwlwz 6c45c26f initial

What it isn’t

git worktree makes you assign a branch to each tree, and refuses to check out a branch that’s already checked out somewhere else. None of that applies here. A workspace just points its @ at a revision. There’s no branch to reserve and nothing to be exclusive about.

There is one rule to remember: we shouldn’t edit the same commit from two workspaces. jj doesn’t forbid it, but both directories would then hold files claiming to be the same commit, and whichever one snapshots last would win.

Stale working copies

Workspaces share history, so a rewrite in one can affect another workspace’s @. Let’s say we rebase or abandon a commit that another workspace is sitting on. That workspace will fall behind:

$ jj st
Error: The working copy is stale (not updated since operation 8f799e48797c).
Hint: Run `jj workspace update-stale` to update it.

Nothing has been damaged. The repository knows exactly where that commit went; the files on disk just haven’t caught up yet. We can update them as the hint suggests:

$ jj workspace update-stale
Working copy  (@) now at: omvuuyon 08f28542 renamed from elsewhere
Parent commit (@-)      : vtmvqlrp 825c9b05 feature a
Added 1 files, modified 0 files, removed 0 files
Updated working copy to fresh commit 08f28542e3a9

The change ID is unchanged — omvuuyon is the same change it always was — and any uncommitted edits in that directory are still there. It’s the same change, in its new position.

Not every rewrite makes a workspace stale. If we change a description from somewhere else, we haven’t touched any files, so the next command in the other workspace absorbs it without comment. Staleness is about the files on disk disagreeing with the commit they represent. If we’d rather update them automatically, we can set snapshot.auto-update-stale = true in our config.

Cleaning up

Deleting the directory isn’t enough, because the repository still has the workspace registered. Cleanup takes two steps, in either order:

$ jj workspace forget feature-b
$ rm -rf ../feature-b

forget unregisters the workspace and leaves the directory alone. The commits that workspace made are ordinary commits and stay in the repository; only the feature-b@ marker goes away.

One more command worth knowing, mostly for scripts:

$ jj workspace root
/home/steve/src/my-project

That’s the top of the current workspace, wherever in the tree you happen to be standing.

When to reach for one

Workspaces cost a full checkout on disk and a little bookkeeping. If we only need to move between pieces of work, editing commits in one directory is simpler. I reach for a workspace when I genuinely need two trees at once, such as something long-running in one while I keep working in the other.

Working alongside git

Every repository in this book is colocated: a .jj directory and a real .git directory over one working copy. This is the default because it lets the rest of our toolchain keep working. gh, our editor, CI, and plain git all see an ordinary git repository. Let’s look at the rules that keep the two views in sync.

Everything syncs on every command

jj imports from git and exports to it at the start and end of nearly every command. We don’t need to run jj git import or jj git export by hand in a colocated repository; it happens for us. That means git log shows the commits we made with jj:

$ git log --oneline
c4b8fac hello world

And a jj bookmark is a git branch. Move a bookmark in jj and the branch moves when the command exports it. Create a branch with git and a bookmark of the same name appears the next time jj imports it. They are two names for one ref.

git HEAD points at a parent of @

git has a current branch and a HEAD. jj has neither. To bridge the gap, jj keeps git’s HEAD detached, pointing at a parent of our working-copy commit @. Most of the time this is invisible. It stops being invisible after jj edit on a commit that already has content:

$ jj edit 'description(glob:"add feature*")'
$ git status --short
 M src/feature.rs

git reports src/feature.rs as modified. It is not uncommitted work. @ is a finished commit whose files are checked out, HEAD points at its parent, and git is honestly reporting the difference between the two. jj already has the change recorded.

When we’re done editing that commit, we can move @ back on top:

$ jj new

Now @ is a fresh empty change, HEAD sits at its parent, and git status is clean again without moving any bookmark or touching pushed history. The same thing happens with jj commit, which closes @ and opens a new empty one for us. If we leave a non-empty change in @, git-facing tools will report the working tree as dirty. Finishing with jj new or jj commit makes it clean.

Let jj do the mutating

Both tools write to the same object store. If they write at once, their import and export steps can race. There are two rules I follow:

  • Don’t run git and jj at the same time against one repository. Run one, then the other.
  • Prefer jj for anything that changes history, such as committing, merging, or rebasing. jj ignores git’s staging area and can’t model a half-finished git rebase or an unresolved git index conflict. A git mutation that completes cleanly can usually be imported afterwards, but never hand jj an operation git left in progress.

Reading with git is safe. We can use git log, git show, git status, or git diff whenever they are useful.

What happens if you mutate with git anyway

This doesn’t mean that every git mutation corrupts the repository. Completed operations can often be imported successfully, but they make the interaction more complicated. Let’s see what actually happens.

A git commit that finishes is imported on the next jj command. git keeps HEAD detached; attach it to a branch first if you want the commit to have a bookmark when jj imports it:

$ git checkout -b gitwork
$ echo change > f.txt && git add f.txt && git commit -m "made by raw git"
$ jj log
Reset the working copy parent to the new Git HEAD.
Done importing changes from the underlying Git repo.

jj picked up the commit as an ordinary change, with gitwork as a bookmark, and put a fresh empty @ on top. Excellent! In this case, everything came across cleanly.

Now the sharper edge. A git reset --hard that moves the branch backwards is imported too:

$ git reset --hard <earlier-commit>
$ jj log
Reset the working copy parent to the new Git HEAD.
Done importing changes from the underlying Git repo.

The Git branch and @ now follow the earlier commit, but commits that jj already knew about remain as anonymous heads. We can find them with jj log, or explicitly with jj log -r 'heads(all())' if our log revset hides them, and point the bookmark back at the change we want:

$ jj bookmark set gitwork -r <discarded-change>

jj undo and jj op restore can also reverse imported Git movements. Inspect jj op log first: importing Git’s HEAD and refs can produce separate operations, so the operation before the reset is a more reliable target than assuming one jj undo is enough. Data that jj never snapshotted is a different matter: git reset --hard can still destroy uncommitted working-tree changes.

The failure mode the jj docs warn about is subtler than lost data: interleaving git and jj mutations can leave a bookmark whose git position and jj position disagree. jj then shows the Git-side position separately as name@git. The conflict itself never loses data, but untangling it is a chore, and an editor’s automatic git fetch can trigger it without you typing anything. The cure is the same: prefer jj for the mutation, and reach for jj undo when git got there first.

Two cases to avoid

Most completed git mutations import cleanly, as above. Two cases don’t, because jj snapshots the working copy without understanding the state that an unfinished Git operation keeps in .git.

First, never hand jj a git operation that’s still in progress. A git rebase, git merge, or git cherry-pick that hits a conflict stops and waits, leaving conflict markers in your files and its own state under .git. jj knows none of that. The next jj command snapshots those files as they sit — and the raw markers become plain text inside @, not a jj conflict:

$ git rebase B          # conflicts, and stops half-done
$ jj st
Working copy changes:
M f.txt
$ jj file show -r @ f.txt
<<<<<<< HEAD
BBB
=======
AAA
>>>>>>> 1ce7f83 (A change)

Those <<<<<<< lines are committed content now, jj sees an ordinary edit rather than a conflict, and git still thinks the rebase is running. Finish the git operation or git rebase --abort it before you run a single jj command. Otherwise, jj will record the conflict markers as ordinary file contents rather than as a conflict.

Second, the git index is invisible to jj. jj has no staging area and ignores git’s. git add, and especially git add -p to stage part of a file, do nothing jj respects — jj snapshots the whole working tree no matter what’s staged. Use jj split or jj squash -i to pick apart a change instead.

For the record, one thing that isn’t a hazard: jj keeps .jj out of git with its own .gitignore inside that directory, so a stray git add -A can’t swallow jj’s internal state.

Adopting an existing git repository

Everything in this book started life as a jj repository, but the other direction works too: run jj git init inside an existing git checkout, and jj adopts it in place:

$ jj git init
Done importing changes from the underlying Git repo.
Initialized repo in "."
$ jj log
@  syomluky steve@steveklabnik.com 2024-03-24 10:12:08 c23e3885
│  (empty) (no description set)
○  ypzkvlqw steve@steveklabnik.com 2024-03-24 10:11:52 main 9283911f
│  existing history
◆  zzzzzzzz root() 00000000

The history comes across, branches become bookmarks, and a fresh empty @ lands on top of wherever HEAD was. Nothing about the git repository is changed except the new .jj directory next to it, so if jj turns out not to be for you, deleting .jj puts everything back.

One gotcha to check first: git has two formats for storing refs, and jj 0.45 can only read the older files format. On a repository using the newer reftable format, jj git init appears to succeed but imports nothing — no bookmarks, and a working copy that claims every file is newly added. If you see that, don’t panic, and don’t commit anything: delete the freshly created .jj directory, migrate the refs, and initialize again:

$ git rev-parse --show-ref-format
reftable
$ git refs migrate --ref-format=files

Most repositories are files and adopt cleanly.

If you’d rather fence git off

Colocation trades a small cost — the import/export on every command, and the odd confusing intermediate state above — for zero-friction interop. If you’d rather git couldn’t interfere at all, you can hide it entirely. That’s the subject of Non-colocated repositories.

Non-colocated repositories

Every repository in this book has been a colocated one. As we saw in chapter one, jj git init gives us a .jj directory and a real .git directory over the same working copy. git can see everything we do, and tools such as gh and our editor can work without knowing that jj exists.

There’s a second arrangement. Even if we never switch to it, looking at it will help us understand where the line between jj and git actually sits. This is a specialized alternative rather than another default: we should start with a colocated repository unless keeping Git-aware tools out is important to our workflow.

$ jj git init --no-colocate
Initialized repo in "."

$ ls -a
.
..
.jj

Now we have one directory and no .git:

$ git status
fatal: not a git repository (or any of the parent directories): .git

The git repository is still there

We are still using the git backend. Its store has moved inside .jj, where other tools won’t find it by accident:

$ jj git colocation status
Workspace is currently not colocated with Git.
Last imported/exported Git HEAD: (none)
Hint: To enable colocation, run: `jj git colocation enable`

$ jj git root
/home/steve/src/my-project/.jj/repo/store/git

If we point git at that path explicitly, it works normally:

$ GIT_DIR=$(jj git root) GIT_WORK_TREE=$(jj workspace root) git status

gh is the same story — GIT_DIR=$(jj git root) gh pr list. Nothing is unavailable. It just has to be asked for.

Remotes are entirely unaffected. We can use jj git fetch, jj git push, and jj git clone --no-colocate exactly as we did in a colocated repository. These commands talk to the store directly, so they never needed a top-level .git in the first place.

Exports stop being automatic

Here’s the difference that matters in practice. Our colocated repositories imported git state and exported their own on every command. That’s what kept the two views in step. Without colocation, there is no top-level git view to keep in step, so jj doesn’t do that automatically:

$ jj bookmark create second -r @-
Created 1 bookmarks pointing to okxrzpsx 66ac5baa second | first

$ GIT_DIR=$(jj git root) git branch
  feature

The bookmark exists in jj and the git store hasn’t heard about it. Run more jj commands and it still hasn’t. You have to say so:

$ jj git export

$ GIT_DIR=$(jj git root) git branch
  feature
  second

jj git import goes the other way. In a colocated repository, we rarely need either command. Here, they are how we update the git view at all. If we find ourselves exporting every few minutes, that’s a good sign that colocation would fit our workflow better.

Keeping git out of the working copy

This also changes what git-based tools see.

In a colocated repository, git’s HEAD is detached and points at a parent of @, because the working copy being a commit is an idea git can’t state. So after jj edit on a finished commit, git status reports its already recorded patch as an uncommitted modification:

$ git status --short
 M f.txt

That’s git accurately describing the gap between HEAD and the files, and it’s harmless — but it surprises people, and some editors and pre-commit tooling behave oddly around a tree they think is dirty. In a non-colocated repository nothing is looking, so nothing is confused.

Choosing between them

Colocation gives us access to the git ecosystem, with one important rule: let jj make the changes. Read-only git commands are fine and are often the quickest way to inspect something. Mutating commands such as git commit and git rebase can be imported afterwards, but they can race jj’s own import and export if run concurrently. jj also can’t take over one of these operations halfway through, and moving a bookmark in both tools can produce the name?? conflict from an earlier chapter.

Non-colocation enforces that discipline instead of asking for it. There’s no .git for an editor plugin to act on, no git commit typed out of habit, no script that discovers a repository you didn’t mean it to find. Some people want that, particularly on a machine running tooling they don’t control.

I think the default is right for most people. If we work on GitHub and use gh, colocation lets those tools work normally. We aren’t locked into either choice:

$ jj git colocation disable
Workspace successfully converted into a non-colocated Jujutsu/Git workspace.

jj git colocation enable converts back, and git.colocate = false in your config makes --no-colocate the default for new repositories.

Native repositories

Both arrangements keep our commits in a git repository. The only difference is where it sits and who else can see it. We can confirm that in any repository:

$ jj util backend name
git

jj does have a storage format of its own, and chapter one mentioned it in passing. Its SimpleBackend is a proof of concept, and jj 0.45.1 does not provide a command for creating a repository that uses it. It isn’t a third everyday choice for us to consider. For normal use, both arrangements in this chapter use the git backend; colocation only decides whether the Git repository is exposed at the top of our working copy.

Unsupported git features

A few git features aren’t implemented by jj, whether or not the repository is colocated:

  • Submodules are not materialized in the working copy. They survive, but jj won’t check them out or update them.
  • Git LFS is unsupported. Pointer files are ordinary files.
  • git hooks don’t run. No pre-commit, no pre-push. jj fix, which we covered a couple of chapters ago, replaces the formatting ones; CI has to cover the rest.
  • .gitattributes is ignored — no clean/smudge filters, no line-ending normalization.
  • Annotated tags can be read and checked out, but not created.
  • git config is mostly ignored, except remote configuration and core.excludesFile.
  • Shallow clones work, but deepening one doesn’t; partial clone is unsupported.

In a colocated repository, jj also puts an ignore file inside .jj so a git add -A does not add its internal state. We don’t need to add .jj/ to the project’s own .gitignore.

Fixing Problems

At some point, we’re going to make a mistake. We may abandon the wrong change, squash something into the wrong parent, or resolve a conflict and then realize we preferred the other version.

If you’ve used git for a while, you may have reached for git reflog, found a hash that looked about right, and tried to put things back together. This often works, but it can feel a bit like emergency recovery.

Recovery works differently in jj. Every command we run is recorded, the old state is kept, and going back is a normal part of the tool rather than a separate emergency procedure.

Here’s what we’re going to learn:

  • Undoing your last command with jj undo
  • Reading the history of your repository’s states with jj op log, and jumping back to one
  • Watching a single change evolve over time with jj evolog
  • Recognizing and untangling divergent changes, where two visible commits claim the same change ID
  • Throwing away changes on purpose, with jj restore and jj revert
  • Hunting down the change that broke something, with jj bisect

You’ve already used jj undo a couple of times in this book, on the promise that I’d explain it later. It’s later.

Undoing mistakes with jj undo

Let’s make a mess on purpose. Here’s a small repository with three changes:

$ jj log
@  ozylkotm steve@steveklabnik.com 2024-03-20 09:14:31 3c267bd1
│  add goodbye
○  rxwzpxqt steve@steveklabnik.com 2024-03-20 09:14:20 0570dd0d
│  add a comment
○  zzzzmznr steve@steveklabnik.com 2024-03-20 09:14:20 3e44726c
│  hello world
◆  zzzzzzzz root() 00000000

Now let’s do something we regret. We meant to abandon some other change, but we typed the wrong one and threw away “add a comment”:

$ jj abandon rxwzpxqt
Abandoned 1 commits:
  rxwzpxqt 0570dd0d add a comment
Rebased 1 descendant commits onto parents of abandoned commits.
Working copy  (@) now at: ozylkotm 404f6b4e add goodbye
Parent commit (@-)      : zzzzmznr 3e44726c hello world
Added 0 files, modified 1 files, removed 0 files

It’s gone, and jj helpfully rebased “add goodbye” onto “hello world” for us, so our work is now sitting on top of a commit that doesn’t have the comment in it:

$ jj log
@  ozylkotm steve@steveklabnik.com 2024-03-20 09:14:31 404f6b4e
│  add goodbye
○  zzzzmznr steve@steveklabnik.com 2024-03-20 09:14:20 3e44726c
│  hello world
◆  zzzzzzzz root() 00000000

In git, recovering this change would take a few steps. In jj, we can run one command:

$ jj undo
Undid operation: 897f241ce8c8 (2024-03-20 09:14:31) abandon commit 0570dd0df085
Restored to operation: 4079800aed31 (2024-03-20 09:14:20) snapshot working copy
Working copy  (@) now at: ozylkotm 3c267bd1 add goodbye
Parent commit (@-)      : rxwzpxqt 0570dd0d add a comment
Added 0 files, modified 1 files, removed 0 files

And we’re back:

$ jj log
@  ozylkotm steve@steveklabnik.com 2024-03-20 09:14:20 3c267bd1
│  add goodbye
○  rxwzpxqt steve@steveklabnik.com 2024-03-20 09:14:20 0570dd0d
│  add a comment
○  zzzzmznr steve@steveklabnik.com 2024-03-20 09:14:20 3e44726c
│  hello world
◆  zzzzzzzz root() 00000000

Notice that rxwzpxqt came back with the same change ID and the same commit ID, 0570dd0d. This isn’t a fresh commit that happens to have the same contents. It’s the same commit; it was never really destroyed, just made invisible.

What jj undo actually undoes

Here’s the important bit: jj undo undoes the last operation, not the last commit. This is what makes it different from the closest commands in git.

An operation is a recorded transition between repository states. Commands such as jj abandon, jj describe, jj rebase, and jj new create operations. Read-only commands don’t. And — this surprises people — the automatic snapshot jj takes of your working copy can be a separate operation from the command that triggered it. Each operation records the complete state of the repository afterwards.

So jj undo doesn’t reason about what your command meant, or try to construct an inverse of it. It puts the repository back the way it was. That’s why it works uniformly on every command, including ones that touched a dozen commits at once, and why the output tells you which operation it undid and which one it went back to.

Running it more than once

There’s one detail that can be surprising. If we run jj undo a second time, it does not undo the first undo:

$ jj undo
Undid operation: 4079800aed31 (2024-03-20 09:14:20) snapshot working copy
Restored to operation: 84248e9a9cb9 (2024-03-20 09:14:20) new empty commit

Look at what it undid: the working copy snapshot from before the abandon. Repeated jj undo walks further and further back into the past, one operation at a time. It’s a rewind button, not a toggle.

If we do want to go the other way, there’s jj redo:

$ jj redo
Restored to operation: c8fa9084a76c (2024-03-20 09:14:20) undo: restore to operation 4079800aed31
Working copy  (@) now at: ozylkotm 3c267bd1 add goodbye
Parent commit (@-)      : rxwzpxqt 0570dd0d add a comment

jj redo moves in the direction of the future, so between the two of them you can scrub back and forth until you find the state you wanted.

Undoing something other than the last thing

Rewinding one step at a time is fine when you’ve just made one mistake. When you’ve made four, and you only want to reach past three of them, you want to name the state you’re going back to directly. jj undo won’t help there — it takes no arguments, and only ever walks backwards from where you are.

For that you need to see the operations and refer to them by name, and that’s the operation log. Let’s look at it.

The operation log

We now know that every repository change jj makes is recorded as an operation. A single command can create more than one operation, while a read-only command creates none. Let’s look at that record with jj op log:

$ jj op log
@  4079800aed31 steve@steves-laptop default@ now, lasted 12 milliseconds
│  snapshot working copy
│  args: jj op log
○  1775539db729 steve@steves-laptop default@ now, lasted 12 milliseconds
│  new empty commit
│  args: jj new -m 'add goodbye'
○  ecbf2e1106cf steve@steves-laptop default@ now, lasted 11 milliseconds
│  snapshot working copy
│  args: jj new -m 'add goodbye'
○  cac23ddcd31f steve@steves-laptop default@ now, lasted 12 milliseconds
│  new empty commit
│  args: jj new -m 'add a comment'
○  58e635d3bf7d steve@steves-laptop default@ now, lasted 10 milliseconds
│  describe commit 0439edc8c87e
│  args: jj describe -m 'hello world'
○  eab1b981cdcd steve@steves-laptop default@ now, lasted 11 milliseconds
│  snapshot working copy
│  args: jj describe -m 'hello world'
○  194587de219c steve@steves-laptop now, lasted 18 milliseconds
│  add workspace 'default'
○  000000000000 root()

This looks a bit like jj log, and that’s on purpose, but don’t confuse the two. jj log shows you commits. jj op log shows you states of the whole repository. Each of those IDs on the left names one complete snapshot of everything: every commit, every bookmark, where @ was pointing, the lot.

A few things to notice.

Each entry records the command that caused it, on the args: line. That’s often enough on its own to work out where things went wrong.

There are more entries than commands you ran. jj new -m 'add goodbye' produced two: a “snapshot working copy” and then a “new empty commit”. Remember from way back in the jj st chapter that jj snapshots your working copy before doing anything else. That snapshot is an operation like any other, which is why you can undo your way back to a state you never explicitly committed.

Right at the bottom is add workspace 'default' — that’s jj git init — and below that, an operation root, in the same spirit as the commit root.

The name and hostname come from your OS account, not from the user.name and user.email you set for commits. They’re separate settings, operation.username and operation.hostname, so anonymizing your commit identity doesn’t anonymize this. This is worth knowing if you care about that sort of thing.

Going back to an old state

Say we’ve had a bad afternoon:

$ jj log
@  toumkvst steve@steveklabnik.com 2024-03-20 09:14:40 bc36132e
│  (empty) even more mess
○  ozylkotm steve@steveklabnik.com 2024-03-20 09:14:40 215023b0
│  a description I regret
○  zzzzmznr steve@steveklabnik.com 2024-03-20 09:14:20 3e44726c
│  hello world
◆  zzzzzzzz root() 00000000

We abandoned a change we wanted, wrote a description we didn’t, and piled another commit on top. We could jj undo three times. But we know roughly where the good state was, so let’s just go there. Find it in the log:

$ jj op log --limit 6
@  7fde600ecf01 steve@steves-laptop default@ now, lasted 9 milliseconds
│  new empty commit
│  args: jj new -m 'even more mess'
○  1e8a46d3d6ab steve@steves-laptop default@ now, lasted 8 milliseconds
│  describe commit 08a853a9e2b7
│  args: jj describe -m 'a description I regret'
○  a3ac895c94f3 steve@steves-laptop default@ now, lasted 11 milliseconds
│  abandon commit 0570dd0df085
│  args: jj abandon rxwzpxqt
○  8112955b56b7 steve@steves-laptop default@ now, lasted 7 milliseconds
│  undo: restore to operation 4079800aed31
│  args: jj undo
○  897f241ce8c8 steve@steves-laptop default@ now, lasted 10 milliseconds
│  abandon commit 0570dd0df085
│  args: jj abandon rxwzpxqt
○  cac23ddcd31f steve@steves-laptop default@ now, lasted 12 milliseconds
│  new empty commit
│  args: jj new -m 'add a comment'

cac23ddcd31f is the last state before it all went wrong. jj op restore takes us there:

$ jj op restore cac23ddcd31f
Restored to operation: cac23ddcd31f (2024-03-20 09:14:20) new empty commit
Working copy  (@) now at: rxwzpxqt 1e939d6c (empty) add a comment
Parent commit (@-)      : zzzzmznr 3e44726c hello world
Added 0 files, modified 1 files, removed 0 files

It’s worth being clear about what happened here: jj restored the whole repository to that state. Anything that happened afterwards is no longer visible, including work we may have wanted to keep. jj undo steps back one operation at a time, while jj op restore goes straight to the state we name. Both are useful, but I pause for a moment before using jj op restore to make sure I’ve picked the right operation.

And because jj op restore is itself an operation, it goes in the log too. If we restore to the wrong place, jj undo takes us back to the state before the restore. Let’s look at one of those operations more closely.

Looking at a single operation

jj op show tells you what one operation actually did. Let’s look at that abandon from earlier, the one that started all this:

$ jj op show a3ac895c94f3
a3ac895c94f3 steve@steves-laptop default@ now, lasted 12 milliseconds
abandon commit 0570dd0df0853eecc772025ef84a6b913243b1c7
args: jj abandon rxwzpxqt

Changed commits:
○  + ozylkotm 9921e522 add goodbye
   - ozylkotm/1 afc85298 (hidden) add goodbye
○  - rxwzpxqt/0 0570dd0d (hidden) add a comment

Changed working copy default@:
+ ozylkotm 9921e522 add goodbye
- ozylkotm/1 afc85298 (hidden) add goodbye

Minus signs are commits that stopped being visible, plus signs are ones that started. You can read the whole mistake off this: add a comment went away, and add goodbye was replaced by a new version of itself — that’s the automatic rebase onto the new parent.

Note that word: hidden, not deleted. Those commits are still in the repository, which is exactly why undo works. Add -p if you want the file diffs as well.

There’s also jj op diff, which compares two operations rather than showing one, when you want to know what changed between two points in the afternoon.

Does this grow forever?

You may be wondering: if every command is recorded, and hidden commits are kept around, doesn’t the repository just grow without bound? Yes, it does — nothing prunes any of this automatically. In practice it takes a long time to matter, since operations and hidden commits are small. If a busy repository does get bulky, jj op abandon ..<old-op-id> marks old operations as disposable, and jj util gc then reclaims whatever nothing refers to any more. The price is exactly what you’d expect: you can no longer undo your way back past the operations you abandoned. I’ve never needed to do this, but it’s good to know the ratchet has a release.

Operations cover the repository. Sometimes the question is narrower than that: not “what did I do to this repo?” but “what happened to this one change?” That’s the next chapter.

Watching a change evolve with jj evolog

We’ve said a few times now that a change ID is stable while the commit ID underneath it changes. Every time you describe a change, edit its files, squash something into it, or let it get automatically rebased, you get a new commit ID and the same change ID.

jj evolog shows you that history. Here’s a change that’s been through a few rounds:

$ jj evolog -r usvzmupx
○  usvzmupx steve@steveklabnik.com 2024-03-20 10:02:11 c39e9f3f
│  add a better feature
│  -- operation c4e5378aa59c snapshot working copy
○  usvzmupx/1 steve@steveklabnik.com 2024-03-20 10:01:58 0ea8e5fc (hidden)
│  add a better feature
│  -- operation 3286efef174e describe commit e7b530dc15e0
○  usvzmupx/2 steve@steveklabnik.com 2024-03-20 10:01:58 e7b530dc (hidden)
│  add a feature
│  -- operation 685d3932d80a snapshot working copy
○  usvzmupx/3 steve@steveklabnik.com 2024-03-20 10:01:40 6d256008 (hidden)
│  add a feature
│  -- operation 91adfe1a92ae describe commit 39f08e04f359
○  usvzmupx/4 steve@steveklabnik.com 2024-03-20 10:01:40 39f08e04 (hidden)
│  (no description set)
│  -- operation cbf76d26122b snapshot working copy
○  usvzmupx/5 steve@steveklabnik.com 2024-03-20 10:01:22 6161a9a2 (hidden)
   (empty) (no description set)
   -- operation 4261b1b2ee68 add workspace 'default'

If we read it from bottom to top, we can watch the change evolve. It starts as the empty commit jj git init made. It gets some content. It gets described as “add a feature”. More content. Then a better description. Every one of those steps left a commit behind, and they’re all still here.

That usvzmupx/1 notation is how jj refers to older versions of a change: the change ID, then which step back you mean. And (hidden) means what it did in jj op show — the commit exists, it’s just not part of the visible history any more.

Each entry also names the operation that produced it, on the -- operation line, which is the bridge back to the last chapter. If you spot the version you want here, you can find the operation that came after it and jj op restore to just before the step you regret.

If we pass -p, we get the diff at each step. This is the version I usually reach for, because it shows us what the change contained over time, not just what it was called.

When two versions stay visible

Normally, only the newest entry in an evolog is visible; everything older is (hidden). But hidden commits can come back. Put a bookmark on one, build a new commit on top of one, or fetch history that descends from one, and it becomes visible again — alongside its newer self. At that point a single change ID names two visible commits, and jj marks both (divergent).

That situation deserves a proper walkthrough of its own, and it gets one in the next chapter. For now, just file away the connection: the old versions the evolog shows you are real commits, and “divergent” is what it’s called when one of them escapes.

evolog versus op log

These two commands can be easy to confuse, so let’s put the distinction in one line each.

jj op log is a history of your repository: what you did, in order, to everything.

jj evolog is a history of one change: every version that one logical piece of work has ever had.

They’re two different views of the same data. When we know something went wrong but not which change it affected, we can use jj op log. When we know which change went wrong but not when, we can use jj evolog.

One last note: jj evolog used to be called jj obslog, for “obsolete log”. That name still works if you type it, and you’ll see it in older blog posts and Discord messages, but evolog — for “evolution” — is what it’s called now, and it’s a much better description of what you’re looking at.

Untangling divergent changes

We’ve leaned hard on one promise throughout this book: a change ID names one change, and it survives every rewrite. There is a situation where that promise bends. It’s called a divergent change, and it’s worth meeting in a repository we don’t care about before it happens in one we do, because the first time you see it, it looks alarming.

Here’s the short version: a change is divergent when two visible commits claim the same change ID. jj marks them (divergent) in the log, and refuses to let the bare change ID pick between them.

Making one on purpose

Let’s set up a little repository with a two-change stack and a bookmark on top:

$ jj log
@  znquvmmz steve@steveklabnik.com 2024-03-20 11:02:31 feature 6f590d66
│  add an evaluator
○  zluwzunl steve@steveklabnik.com 2024-03-20 11:02:14 511b69ca
│  add a parser
◆  zzzzzzzz root() 00000000

Now we’ll amend the parser change — jj edit zluwzunl, fix a file, and come back up with jj new znquvmmz. We know what happens: both changes get new commit IDs, the evaluator gets rebased, and the old versions go into hiding. jj evolog can show them to us:

$ jj evolog -r znquvmmz --no-graph
znquvmmz steve@steveklabnik.com 2024-03-20 11:03:44 feature cfffda13
add an evaluator
-- operation 7a0508060358 snapshot working copy
znquvmmz/1 steve@steveklabnik.com 2024-03-20 11:02:31 6f590d66 (hidden)
add an evaluator
-- operation a18d13686e71 describe commit 8c764b87308646d95ab0884ffb35e5d3ee20dbfd

So far, so normal. Now the mistake. A while later, we’re rooting around in the evolog for something we lost, we find that old commit 6f590d66, and — maybe thinking it’s the current version — we point our bookmark at it:

$ jj bookmark set feature -r 6f590d66 --allow-backwards
Moved 1 bookmarks to znquvmmz/1 6f590d66 feature* | (divergent) add an evaluator

There’s the word already. A bookmark keeps a commit visible, and so it keeps that commit’s ancestors visible too. We’ve just resurrected the entire old version of the stack:

$ jj log
@  tkvuwxwx steve@steveklabnik.com 2024-03-20 11:03:44 c5324b95
│  (empty) (no description set)
○  znquvmmz/0 steve@steveklabnik.com 2024-03-20 11:03:44 cfffda13 (divergent)
│  add an evaluator
○  zluwzunl/0 steve@steveklabnik.com 2024-03-20 11:03:44 c2e9dfd1 (divergent)
│  add a parser
│ ○  znquvmmz/1 steve@steveklabnik.com 2024-03-20 11:02:31 feature 6f590d66 (divergent)
│ │  add an evaluator
│ ○  zluwzunl/1 steve@steveklabnik.com 2024-03-20 11:02:14 511b69ca (divergent)
├─╯  add a parser
◆  zzzzzzzz root() 00000000

Both versions of both changes, all four marked (divergent). Note that a whole stack went divergent from one bookmark move. When people hit this in real life, it’s rarely one commit — it’s twenty, and it looks like the repository exploded. It didn’t. It’s the same two lines of history you can see right here, just longer.

Moving a bookmark isn’t the only way in. Fetching can do it too, if the remote has commits descended from a version you’ve since rewritten — a common one is someone (or CI) building on your branch right before you force-push over it. Two machines, or two workspaces, rewriting the same change also gets you here. However you arrive, the shape is the same: an old line and a new line, both visible.

Reading the display

Those /0 and /1 suffixes are how jj names the versions of a change. You’ve seen them in jj evolog output already, counting back through hidden predecessors. Now that two versions are visible, the suffixes are how we tell them apart, and the bare change ID stops working:

$ jj show znquvmmz
Error: Change ID `znquvmmz` is divergent
Hint: Use change offset to select single revision: znquvmmz/0, znquvmmz/1
Hint: Use `change_id(znquvmmz)` to select all revisions
Hint: To abandon unneeded revisions, run `jj abandon <commit_id>`

This error is usually the moment people discover divergence exists, so it’s nice that the hints tell you everything: pick a side with an offset, or take all sides with change_id().

One caution about the offsets: they’re assigned by sorting the visible commits, and they get reassigned as commits appear and disappear. For looking around, znquvmmz/1 is fine. For a multi-step cleanup, use commit IDs — 6f590d66 means the same commit tomorrow, and /1 may not.

jj log -r 'divergent()' shows every divergent commit in the repository, which is the quickest way to see the full extent of the problem.

Cleaning it up

Our repository has a new line we want and an old line we don’t. The plan: make sure nothing we care about is attached to the old line, then abandon the old line, all of it.

First, the bookmark. It’s sitting on the old tip, which is both why the old line is visible and not where we want it. Let’s put it back on the current version:

$ jj bookmark set feature -r cfffda13 --allow-backwards
Moved 1 bookmarks to znquvmmz/0 cfffda13 feature* | (divergent) add an evaluator

Now let’s check the old line is really abandoned-safe: no bookmarks left on it, and nothing built on top of its tip. We use commit IDs for the range, since the change IDs are exactly what’s ambiguous right now:

$ jj log -r '511b69ca::6f590d66 & (bookmarks() | remote_bookmarks())'
$ jj log -r 'children(6f590d66)'

Both empty. If they weren’t — if a bookmark or some new work were attached to the old line — we’d stop here and move or rebase those first.

Now we can abandon the old line. This is the same inclusive-range trick from the revsets chapter, from the old line’s first commit through its tip:

$ jj abandon '511b69ca::6f590d66'
Abandoned 2 commits:
  znquvmmz/1 6f590d66 (divergent) add an evaluator
  zluwzunl/1 511b69ca (divergent) add a parser

The range matters. If we’d abandoned only the tip, 511b69ca would have become the old line’s new head — still visible, still divergent, and we’d be playing whack-a-mole down the whole stack. Abandon the line, not the commit.

And we’re back to normal:

$ jj log -r 'divergent()'
$ jj log
@  tkvuwxwx steve@steveklabnik.com 2024-03-20 11:03:44 c5324b95
│  (empty) (no description set)
○  znquvmmz steve@steveklabnik.com 2024-03-20 11:03:44 feature cfffda13
│  add an evaluator
○  zluwzunl steve@steveklabnik.com 2024-03-20 11:03:44 c2e9dfd1
│  add a parser
◆  zzzzzzzz root() 00000000

No offsets, no (divergent), one version of each change.

Two more things worth knowing. If the operation that caused the divergence was the last thing you did, jj undo fixes it in one step, as usual. And nothing here was ever dangerous: a divergent change is two visible commits, both intact, both recoverable. The only real hazard is absent-mindedly continuing to work on top of the side you meant to throw away — which is why it’s worth cleaning up promptly rather than living with the offsets.

When both sides have work: jj converge

Here the old line was a duplicate we wanted gone, so jj abandon was the tool. The other case is two versions that each carry real, different work — two machines that both edited the same change, say — where you want to keep both sets of edits rather than throw one away. jj 0.45 added jj converge for that:

$ jj converge -r 'divergent()'
Found 1 divergent change(s) in the specified revset:
- Change: znquvmmzlxqr with 2 commits:
    znquvmmz/0 cfffda13 (divergent) add an evaluator
    znquvmmz/1 6f590d66 feature | (divergent) add an evaluator

Attempting to converge change znquvmmzlxqr...

Successfully converged change: created commit 41e0c3d8b2ef.

It groups the divergent commits by change ID and tries to replace each group with a single commit, then rebases descendants and moves bookmarks to follow. The replacement is built from heuristics, and where they can’t decide it prompts you — to merge two descriptions, to pick parents, and, rarely, to choose an author. --no-interactive prints a warning and stops instead, which is what a script wants. As always, jj undo reverses the whole thing if the result isn’t what you hoped, and jj op show -p shows exactly what it did.

The official guide to divergent changes covers a few rarer ways in and out, if you’d like more.

Throwing work away on purpose

Everything so far has been about getting work back. Sometimes we decide that some work should stay out of the project instead. jj has a few different tools for that, depending on exactly what we want to remove.

Discarding edits in the working copy

Let’s say we’ve been working on something, it hasn’t worked out, and we want the files back the way they were. That’s what jj restore does:

$ jj st
Working copy changes:
M f.txt
Working copy  (@) : wtylzslq 4528b111 next
Parent commit (@-): usvzmupx c39e9f3f add a better feature
$ jj restore
Working copy  (@) now at: wtylzslq 4f4e99e0 (empty) next
Parent commit (@-)      : usvzmupx c39e9f3f add a better feature
Added 0 files, modified 1 files, removed 0 files

Our change is empty again. This is the rough equivalent of git restore or the old git checkout -- ., and by default it restores everything in @ from its parent. Name paths if you only want some of them back, or use --from to pull the contents from somewhere other than the parent.

Nothing is really lost here either, of course — the pre-restore state is an operation, so jj undo brings your edits back.

Getting rid of a whole change

We’ve been using jj abandon throughout the book for this. It makes the change go away and rebases anything that was sitting on top of it:

$ jj abandon mlzwmxzs
Abandoned 1 commits:
  mlzwmxzs 9a4ad229 (empty) (no description set)

Use it for work you never want to see again — a scratch change, a dead end, an empty commit you accidentally created.

Undoing a change that’s already shared

jj abandon rewrites history: the change simply stops existing. That’s fine while the work is ours, but it can cause problems after other people have pulled it. Pushing alone doesn’t necessarily make a commit immutable: commits published through tracked feature bookmarks remain mutable by default. Don’t rely on the immutability guard to decide whether a rewrite is safe to share.

Instead, we want a new commit that undoes the old one. We can make one with jj revert:

$ jj revert -r usvzmupx -o @
Reverted 1 commits as follows:
  uykkwoyr 1ce4fe35 Revert "add a better feature"
$ jj log --limit 3
○  uykkwoyr steve@steveklabnik.com 2024-03-20 10:04:14 1ce4fe35
│  Revert "add a better feature"
@  wtylzslq steve@steveklabnik.com 2024-03-20 10:04:14 4f4e99e0
│  (empty) next
○  usvzmupx steve@steveklabnik.com 2024-03-20 10:02:11 c39e9f3f
│  add a better feature

add a better feature is still there, untouched, and there’s now a commit after it that applies its diff backwards. History is intact and the effect is gone, which is what you want on a shared branch. -r says what to revert and -o says where to put the result, the same way -o works for jj rebase.

Which one do I want?

  • If the files in @ are wrong, use jj restore.
  • If a whole change is unwanted and still local, use jj abandon.
  • If a whole change is unwanted and already pushed, use jj revert.

If we pick the wrong one, jj undo gives us a way back.

Finding the commit that broke it with jj bisect

Everything else in this section has been about undoing something we did. This chapter is about finding out which past change caused a problem in the first place.

The idea is the same as git bisect: we know something worked before and doesn’t work now, so we can use a binary search to find where that changed. jj bisect run does the whole search for us, given a command that can tell a good revision from a bad one.

Our command needs to exit 0 on a good revision and use most non-zero statuses for a bad one, which is the convention test runners already follow. There are two reserved statuses: 125 skips a revision that can’t be tested, and 127 aborts the search. Let’s put the command in a script somewhere outside the repository. jj will be checking out old revisions, so a script in the working copy could vanish along with everything else:

#!/bin/sh
[ "$(cat v.txt)" -lt 4 ]

Then we can run it over the range we want to search:

$ jj bisect run --range 'mutable() & ~empty()' -- ~/bin/check.sh
Bisecting: 3 revisions left to test after this (roughly 2 steps)
Now evaluating: upqnzxnm d8afdf06 c5
Working copy  (@) now at: kzlyrqkk e7a2a494 (empty) (no description set)
Parent commit (@-)      : upqnzxnm d8afdf06 c5
The revision is bad.

Bisecting: 1 revisions left to test after this (roughly 1 steps)
Now evaluating: lrmywvmw 4e2d0df0 c4
Working copy  (@) now at: oqonuxym d9edf35c (empty) (no description set)
Parent commit (@-)      : lrmywvmw 4e2d0df0 c4
The revision is bad.

Search complete. To discard any revisions created during search, run:
  jj op restore e31971608abc
The first bad revision is: lrmywvmw 4e2d0df0 c4

At each step, jj checks out a revision, runs our command, and narrows the range. The answer at the end is the first revision where the command started failing.

The range

--range is a revset, which gives us a lot of flexibility. git bisect asks us to mark one good commit and one bad commit and then walks between them. Here, we can describe the search space directly:

  • mutable() — everything jj permits you to rewrite. This can include pushed commits on tracked remote bookmarks.
  • trunk()..@ — the work on your branch.
  • main@origin..main — what you’re about to push.

Excluding empty commits with & ~empty() is often worth it, since testing a commit that changes nothing tells you nothing.

Cleaning up after it

Notice what the output offers at the end:

To discard any revisions created during search, run:
  jj op restore e31971608abc

Bisecting checks out revisions as it searches, so it leaves working-copy commits behind. We can use the operation log from earlier in this section to clean them up. One command puts the repository back exactly as it was before the search, and jj gives us the operation ID rather than making us find it.

Customizing your experience

jj ships with sensible defaults, and we’ve used it for this whole book without changing very many of them. A tool we use every day is still worth making our own, though. jj lets us customize which commands we run, what they show us, which sets of commits we can name, and which editors do the work.

Here’s what we’re going to learn:

  • Configuring jj: where settings live, and the ones worth setting
  • Customizing the output of jj commands with templates

Configuring jj

jj stores its configuration in TOML files, and we can work with them through jj config. Let’s start by seeing where they live.

Where settings live

There are four layers. Settings later in this list take precedence over earlier ones:

  1. built-in defaults
  2. your user config
  3. the repository’s config
  4. --config on the command line

We can ask jj where the files are rather than guessing:

$ jj config path --user
/home/steve/.config/jj/config.toml

$ jj config path --repo
/home/steve/.config/jj/repos/07d925992ceecfee7f5a/config.toml

The repository config is worth a second look. It lives in our config directory, keyed by repository, rather than inside the repository itself. So we get per-repository settings that stay private to us. We can’t accidentally commit them, and they won’t turn up in anyone else’s checkout.

There are three ways to change a setting. We can set one key:

$ jj config set --user ui.default-command log

Open the file in your editor:

$ jj config edit --user

Or we can override a setting for one command, which is handy when trying something out:

$ jj --config ui.graph.style=ascii log
@  oqtwrqnv steve@steveklabnik.com 2024-03-25 09:14:44 main* 07effb17
|  (empty) local commit on main
+  quvlnmky steve@steveklabnik.com 2024-03-25 09:12:37 main@origin edd53bde
|  upstream commit 2

We can also ask what is currently in effect, including settings we’ve never changed ourselves:

$ jj config list                       # your settings
$ jj config list --include-defaults    # and the built-in ones
$ jj config get ui.editor

jj config unset --repo ui.default-command removes a key and lets the layer underneath show through again.

--user, --repo, and --workspace each name a whole layer. When a layer is spread across several files — a user config plus a conf.d/ directory, say — --user writes to the first file jj loads. To pick one file exactly, give --file <PATH>; it works with set, edit, and unset, and points at any file jj reads.

Some useful settings

What bare jj does

[ui]
default-command = "log"

Out of the box, typing jj with no arguments prints the help. I prefer to have it run log or status instead.

Editors

[ui]
editor = "nvim"  # writing commit descriptions
diff-editor = ":builtin"  # picking hunks in jj split / jj squash -i
merge-editor = "meld"  # resolving conflicts in jj resolve
diff-formatter = ["difft", "--color=always", "$left", "$right"]

These are four different jobs, so they have four different settings. The built-in TUI we’ve used for interactive splits is :builtin, and it’s the default. We can change diff-editor if we prefer something else.

Paging

[ui]
pager = "less -FRX"
paginate = "never"

paginate = "never" is the one to set when you’re driving jj from a script, or anywhere a pager would hang waiting for a keypress nobody’s there to make.

Movement

[ui.movement]
edit = true

Back in the edit workflow chapter we kept typing jj next --edit. Set this and jj next and jj prev edit by default; --no-edit gets the other behavior when you want it. If you’ve settled on the edit workflow, set it and stop typing the flag.

Remotes

[git]
fetch = "origin"
push = "origin"

These settings choose which remotes jj git fetch and jj git push use when we don’t name one. They’re especially useful on a fork, where we may fetch from upstream and push to origin.

Keeping work off the remote

[git]
private-commits = "description(glob:'wip:*')"

Any commit matching that revset — and anything descended from it — is refused by jj git push. A safety net for the “checkpoint” commits you make for yourself and never mean to publish. jj git push already refuses commits with no description and commits containing conflicts; this lets you add your own rule.

Signing your commits

Some projects require every commit to be signed. jj does it for you, given a backend and a key:

[signing]
behavior = "own"
backend = "ssh"
key = "~/.ssh/id_ed25519.pub"

behavior is the interesting setting. own signs commits we author whenever jj writes them, which is what we usually want. The default, keep, only re-signs our commits that were already signed before a rewrite. force signs every commit it writes, even when somebody else authored it, while drop removes a signature when a commit is rewritten. The backends are ssh, gpg, and gpgsm.

jj sign signs a revision after the fact, and jj unsign removes one. To be sure nothing unsigned escapes:

[git]
sign-on-push = true

Signatures aren’t shown by default, since most of the time they’re noise. ui.show-cryptographic-signatures = true puts them in the log.

Stale workspaces

[snapshot]
auto-update-stale = true

From the workspaces chapter: apply jj workspace update-stale automatically instead of stopping to tell you about it.

Large repositories

[fsmonitor]
backend = "watchman"

Snapshotting the working copy means checking every tracked file for changes. On a big repository that walk gets expensive. Watchman replaces it with a query against a filesystem watcher, and needs the watchman binary on your PATH.

Aliases

We can use aliases to save whole command lines:

[aliases]
l = ["log", "-r", "trunk()..@"]
$ jj l
@  oqtwrqnv steve@steveklabnik.com 2024-03-25 09:14:44 main* 07effb17
│  (empty) local commit on main
~

The value is an array of arguments, not a string to be parsed by a shell. Anything we type after the alias is appended, so jj l --limit 3 works.

Revset aliases

Revset aliases are a little more interesting. Revsets are a language, and we can add our own words to it:

[revset-aliases]
"stack()" = "trunk()..@"
$ jj log -r 'stack()'

Now stack() is available anywhere we can use a revset, including inside other aliases and revset expressions. One note on naming: jj will happily let an alias shadow a built-in function — remember mine() from the revsets chapter — and it won’t warn you when it does, so pick names that aren’t already taken. trunk() itself is one of these aliases. jj git clone writes revset-aliases."trunk()" = "main@origin" into our repository config, which is why the built-in defaults can refer to trunk() without knowing what our project calls its main branch.

One alias worth understanding governs what we’re allowed to rewrite:

[revset-aliases]
"immutable_heads()" = "builtin_immutable_heads() | remote_bookmarks()"

The built-in set is trunk() | tags() | untracked_remote_bookmarks() | untracked_remote_tags(): trunk, tags, and the remote bookmarks and tags you don’t track. Everything at or below those heads is frozen, which is why jj log draws them as and why jj rebase won’t touch them. The line above widens it to every remote bookmark — nothing that has reached a remote can be rewritten. Some teams want that; if you use stacked PRs, you don’t, because rewriting pushed branches is the entire workflow.

Note the builtin_immutable_heads() | at the front. Assign to immutable_heads() without it and you replace the default set rather than adding to it, which unfreezes trunk. Always union with the builtin.

A starting point

If you want something to paste and adjust:

[user]
name = "Your Name"
email = "you@example.com"

[ui]
default-command = "log"
editor = "nvim"

[ui.movement]
edit = true

[aliases]
l = ["log", "-r", "trunk()..@"]

[revset-aliases]
"stack()" = "trunk()..@"

This is deliberately small, but it removes a bunch of repeated typing. I add to it when I notice myself reaching for the same option again and again.

Customizing the output of various jj commands with templates

Just like revsets are a functional language that allow you to describe a set of commits, templates are a typed functional language that allows you to customize the output of commands in jj.

A bunch of commands support -T or --template to allow you to customize their output. For example, right now @ is on an empty commit, so let’s refresh ourselves on what

$ jj log -r @-
○  yzlysuwt steve@steveklabnik.com 2024-02-29 00:35:12 main f80a73c1
│  Fill out a table of contents

Let’s give it a template instead:

$  jj log -r @- -T 'separate(" ", change_id, description.first_line())'
○  yzlysuwtylswszxknppsyqxqoktqpqpz Fill out a table of contents
│

That’s quite different! We’ve got a few things going on here:

  • separate() is a function that takes a separator, a bunch of other templates, and produces the contents of those templates separated by the separator.
  • change_id and description are keywords that resolve to what you’d expect.
  • first_line() is a method on strings that returns the first line.

What if we wanted a nicer change ID? We can do that:

$  jj log -r @- -T 'separate(" ", change_id.shortest(8), description.first_line())'
○  yzlysuwt Fill out a table of contents
│

The shortest method returns the shortest prefix of an ID, and the 8 we pass to it says “show at least eight letters even if the shortest is shorter.”

Templates are powerful, and let you do a lot of interesting things. I would suggest reading the documentation on templates to learn all of the details.