Introduction

Please note this is an in-progress version of my tutorial.

You can find a more complete version over here.

Hi there! My name is Steve. This is my tutorial on jj, a version control system. It is not the official tutorial, but so many folks liked the first version that I wrote, that it may become the official one someday. We'll see!

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 we will sometimes compare and contrast with it, since it is the most popular VCS in current use. You don't need to be a git expert to read this tutorial.

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 other DVCSes, but at the same time, it is more powerful. 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.

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

jj's design is about having a smaller number of essential concepts, and ensure that they fit together nicely. Once you learn the basics, the learning curve to advanced usage is very smooth.

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.

Getting Started

In this first chapter, we'll be jumping right into using jj for a real-world task: creating a pull request on GitHub.

Before we do that though, a quick note: this tutorial was produced for:

$ jj --version
jj 0.25.0

If your jj version is newer than that, there's a chance something has changed.

`jj` is pre-1.0 software, and therefore still makes breaking changes to its CLI.

Don't worry, your repositories will be fine when upgrading.

If you're not just reading this online, and want to follow along, you can! Head over to the installation page to find instructions on installing jj.

I want you to be productive immediately, so in this first chapter, we're going to go pretty quickly, and show off the simplest way to work with jj. Then in Part 2, we'll discuss some of the concepts in a bit more detail. Part 3 will show off two different ways of working with jj that are great ways to get started using it for real work.

Cloning a repository

Let's create a pull request! We're going to do that on GitHub, but these instructions should be easy to adopt to any of the various similar code forges.

Forking and cloning

I've got a sample repository that you can fork. We'll be using this repo as an example for this first part of the tutorial. Click "fork" to create a fork of your own.

Next, let's clone down our fork. Go to the directory where you'd like to create your clone, in my case, that's ~/src. And then type this:

$ jj git clone --colocate git@github.com:<YOUR USERNAME>/hello-world
Fetching into new repo in "/home/<YOUR USERNAME>/src/hello-world"
bookmark: trunk@origin [new] untracked
Setting the revset alias "trunk()" to "trunk@origin"
Working copy now at: snwusnyo 3ea00cda (empty) (no description set)
Parent commit      : qvryknuz 5a15ed3b trunk | Hello, world!
Added 4 files, modified 0 files, removed 0 files

You'll want to cd hello-world if you're following along on your own computer.

Just like git clone, jj git clone will clone a remote repository to your local disk. However, we are passing a certain flag to this command, --colocate. jj supports two different kinds of repositories: colocated, and non-colocated. What's the difference? Well, let's take a look at our repository:

$ tree . -a -L 1 --noreport
.
├── .git
├── .gitignore
├── .jj
├── Cargo.lock
├── Cargo.toml
└── src

We have both a .jj and a .git directory at the top level. This means both jj's information as git's information are co-located: they're next to each other. A non-colocated directory stores .git inside of .jj. For your first foray into jj, I strongly recommend a colocated repository, as it allows you to still easily run git commands as well as jj's. This can help ease you into things. It also means tooling that expects to see a .git at the root of the repository will still work.

Looking at history

Let's see what our repository's history looks like:

$ jj log
@  tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 4fed3b0d(empty) (no description set)
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk git_head() 0c72abbb
│  Hello, world!
~

This looks a bit different than git log, but it's the same general idea: we can see where we our in our history.

There's a lot I could say about this output, but I'd rather show you how to get work done first. Let's make our first change.

Making a new change

jj is a very flexible tool, but in this section, we're going to show the simplest possible workflow. If you're a fan of building up small commits via the git index, we'll learn how to do that in the next chapter. Baby steps!

If you remember from the end of the last section, we're on an empty change. You can double check with jj status (or jj st):

$ jj status
The working copy has no changes.
Working copy  (@) : tnmounps 4fed3b0d (empty) (no description set)
Parent commit (@-): ptrqnyzv 0c72abbb trunk | Hello, world!

So what is a change, anyway? It is the core primitive you'll be working with in jj. We'll talk about that actually means in Part 2. For now, you can think of a change as a commit. There are some differences, but now is not the time for talking, but for action! Let's do some work.

Modifying a file

Here's the contents of src/main.rs. Don't worry, you won't need to actually know Rust to complete this tutorial, we just want some code to work with:

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

Let's change that to something else:

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

A bit fatalistic, but it works. Let's run jj st again:

$ jj st
Working copy changes:
M src/main.rs
Working copy  (@) : tnmounps 729bb51c (no description set)
Parent commit (@-): ptrqnyzv 0c72abbb trunk | Hello, world!

We can see we've modified src/main.rs. Whenever we run a jj command, jj will snapshot all of the changes that we've made to any files in our repository and add those differences to the change we're working on. If you're a git user, you may be already wondering about something like git's index. Don't worry, even though jj always adds the contents of files into your changes, you don't lose the staging area. We'll talk about that soon. Also, you can turn this off, but I strongly suggest you give it a try. I thought I would hate it, but now I love it.

Using jj commit

Let's say we're happy with the contents of this change. We're done, and we want to start working on something else. To do that, we can use jj commit:

$ jj commit -m "Goodbye, world!"
Working copy  (@) now at: opqvmvrn 95d5c471 (empty) (no description set)
Parent commit (@-)      : tnmounps 326253c2 Goodbye, world!

Easy enough! Our working copy is now on a fresh new change, and its parent is our "Goodbye, world!" change that we just committed.

To see our changes in context, let's look at jj log again:

$ jj log
@  opqvmvrn steve@steveklabnik.com 2025-02-06 09:43:34 95d5c471(empty) (no description set)tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 git_head() 326253c2
│  Goodbye, world!
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk 0c72abbb
│  Hello, world!
~

You can see that we're currently working on an empty change. It has x as a change ID, but there's also a little @ there: @ is an alias for the working copy, that is, whatever change @ is on is the one that we're currently working on.

Its parent is our "Goodbye, world!" change, t. Its parent is p, the "Hello, world" change I included in the repository we cloned down.

In the next section, we'll make a pull request for this change!

Interacting with GitHub

Briefly, all of these instructions should work with any of the various git forges out there, but since GitHub is very popular, I'm going to talk about the topic with GitHub as the specific example. No shade to the other hosts, I just feel that trying to speak about this in the abstract would make it harder to understand.

Let's look at that jj log output again:

$ jj log
@  opqvmvrn steve@steveklabnik.com 2025-02-06 09:43:34 95d5c471(empty) (no description set)tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 git_head() 326253c2
│  Goodbye, world!
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk 0c72abbb
│  Hello, world!
~

Do you see that little trunk over on the right there? That is a bookmark in jj, and it's how jj understands git branches. trunk is the name of the default branch of the git repository we pulled down from GitHub. (I prefer trunk to main or master these days, so that's why I did this, but those are of course popular as well.) jj doesn't really have "named branches" like git does, but it does have bookmarks, and jj will create a bookmark for each branch in the underlying git repository.

We made our change on top of trunk, but we never created a branch! jj allows our branches to be anonymous. That's great when we're working locally, but when we interact with GitHub, it needs a branch name.

To create a bookmark, we can use jj bookmark:

$ jj bookmark create goodbye-world -r @-
Created 1 bookmarks pointing to tnmounps 326253c2 goodbye-world | Goodbye, world!

jj bookmark create takes a name for the bookmark, and then we also pass a -r flag. This is short for "revision," which is a sort of catch-all name for the various kinds of IDs we can use to refer to changes. We could have also used t, for example, which is the change ID. In this case, we pass @-, which means "the parent of @."

Let's look at our log:

$ jj log
@  opqvmvrn steve@steveklabnik.com 2025-02-06 09:43:34 95d5c471(empty) (no description set)tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 goodbye-world git_head() 326253c2
│  Goodbye, world!
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk 0c72abbb
│  Hello, world!
~

We can now see goodbye-world listed on the right. Great! Let's push that up to GitHub. But before we do, a warning:

Because this involves interacting with the outside world, and we aren't going to end up merging this PR, the next section will start off with the repository in the state that it's currently in at this moment. So if you'd like to follow along, with the next section, you may want to just read this next section instead of doing it.

You can also just make a copy of your repository, and try this out from the copy, rather than your local repository.

Like I said, let's push it to GitHub:

$ jj git push --allow-new
Changes to push to origin:
  Add bookmark goodbye-world to e2dd22df5f5d
remote: 
remote: Create a pull request for 'goodbye-world' on GitHub by visiting:
remote:      https://github.com/<YOUR USERNAME>/hello-world/pull/new/goodbye-world
remote: 

A jj git push will push up all of our changes. In this case, we have our new bookmark, which has turned into a git branch. Doing a jj git push is kind of like doing a git push --force-with-lease, and so jj wants us to confirm that we intend to create a new branch by passing --allow-new.

Because this is a tutorial repository, I won't be merging any pull requests you send me. Feel free to do so anyway if you want!

Let's pretend that this PR was merged. What exactly that looks like depends on if your upstream does a merge, a rebase, or a squash merge. Let's talk about the default case for now, a merge.

The first thing we need to do is add a new remote. Right now, we have one: origin. I like to use the name "upstream" for the repositories I've forked, but you can name them whatever you'd like. We can do that like this:

$ jj git remote add upstream https://github.com/jj-tutorial/hello-world

No output. That's vaguely ominous, but I promise, you're fine. Let's pull in the changes:

$ jj git fetch --remote upstream
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
bookmark: trunk@upstream [new] untracked

It tells us we have a new bookmark: trunk@upstream, and it's not tracked. What does that mean? If something changes in upstream, and we do another fetch, we won't see the changes. By default, jj doesn't automatically track branches. So let's tell it we care about this bookmark:

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

Great! Let's see what our history looks like now:

❯ jj log
@  kozrnusy steve@steveklabnik.com 2025-02-09 11:48:43 a1be631e
│  (empty) (no description set)
│ ○  oltlpuxu steve@steveklabnik.com 2025-02-09 11:42:09 trunk* 82008a30
╭─┤  (empty) Merge pull request #4 from steveklabnik/goodbye-world
○ │  zourloqr steve@steveklabnik.com 2025-02-09 11:40:46 goodbye-world git_head() b8f74a89
├─╯  Goodbye, world
◆  ptrqnyzv steve@steveklabnik.com 2024-09-23 19:43:36 trunk@origin 0c72abbb
│  Hello, world!
~

That's quite different! We can see that o is a merge commit, because it has two parents, z and p. Our working copy is still on top of z.

If we take a closer look at trunk, you'll notice it's changed to trunk*. Since we have a local branch named trunk, and we fetched trunk@upstream, it has updated our bookmark to match. We can see trunk@origin is where trunk used to be. That * is there to remind us that our local trunk doesn't line up with trunk@origin, so we may want to push it. Let's do that:

$ jj git push -b trunk
Changes to push to origin:
  Move forward bookmark trunk from 0c72abbb8365 to 82008a304090
remote:

-b is short for bookmark. Let's look at our history now:

❯ jj log
@  kozrnusy steve@steveklabnik.com 2025-02-09 11:48:43 a1be631e
│  (empty) (no description set)
│ ◆  oltlpuxu steve@steveklabnik.com 2025-02-09 11:42:09 trunk 82008a30
╭─┤  (empty) Merge pull request #4 from steveklabnik/goodbye-world
│ │
│ ~
│
◆  zourloqr steve@steveklabnik.com 2025-02-09 11:40:46 goodbye-world git_head() b8f74a89
│  Goodbye, world
~

We have a new bit of output in the log, a tilde (~). It's been at the bottom of our log this whole time, but you probably only really noticed it now, once the second one appeared under our merge there. This tilde means that there are changes that are currently not being shown. jj log tries to be helpful, and by default will show you... well, it's a bit complex for now, let's just say that it tries to show you helpful context around where you're working currently. You can always pass it options to show something else, but I don't want to get further sidetracked here. Our working copy is still based off of where we were before we did all this remote stuff. Let's create a new change on top of trunk:

$ jj new trunk
Working copy now at: rzownqqx 569855c9 (empty) (no description set)
Parent commit      : oltlpuxu 82008a30 trunk | (empty) Merge pull request #4 from steveklabnik/goodbye-world

Don't worry about our old change; because it was empty and had no description, jj automatically abandons it, so you won't have a bunch of empty stuff littering up your repository.

We'll talk about it more in the next section, but we're using new here in a way similar to "checking out a branch" in other DVCSes.

Let's take a look at our log:

@  rzownqqx steve@steveklabnik.com 2025-02-09 12:29:32 569855c9
│  (empty) (no description set)
◆  oltlpuxu steve@steveklabnik.com 2025-02-09 11:42:09 trunk git_head() 82008a30
│  (empty) Merge pull request #4 from steveklabnik/goodbye-world
~

Where'd everything go?!? Well, everything is in harmony: since our local trunk agrees with both origin and upstream, jj only bothers to show us trunk. Our goodbye-world bookmark still exists:

$ jj bookmark list
goodbye-world: zourloqr b8f74a89 Goodbye, world
trunk: oltlpuxu 82008a30 (empty) Merge pull request #4 from steveklabnik/goodbye-world

But since it's behind trunk, jj log doesn't bother to show it.

What happens when our upstream's trunk changes. The next time we fetch:

❯ jj git fetch --remote upstream
remote: Enumerating objects: 7, done.
remote: Counting objects: 100% (7/7), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 4 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
bookmark: trunk@upstream [updated] tracked

It'll pull in those changes. Here's our log now:

$ jj log
@  rzownqqx steve@steveklabnik.com 2025-02-09 12:29:32 569855c9
│  (empty) (no description set)
│ ○  wuxwwlxm steve@steveklabnik.com 2025-02-09 12:38:31 trunk* 9fbe724a
├─╯  Update main.rs
◆  oltlpuxu steve@steveklabnik.com 2025-02-09 11:42:09 trunk@origin git_head() 82008a30
│  (empty) Merge pull request #4 from steveklabnik/goodbye-world
~

This is the same as when our pull request got merged, except since w isn't a merge commit, things look simpler.

One last thing: in this sort of scenario, where you have origin as a fork of upstream, it's common to configure jj so that jj git fetch pulls from upstream by default, and jj git push pushes to origin by default.

We can do that via jj config. jj has per-repository settings as well as per-user settings. I think this one is more appropriate for this repository only, so:

$ jj config edit --repo

This will open up your local configuration in an editor. It should have this in it:

[revset-aliases]
"trunk()" = "trunk@origin"

We can ignore this for now. You can add this below that stuff:

[git]
fetch = "upstream"
push = "origin"

And now we have default remotes for jj git fetch and jj git push. If you work on your fork from multiple computers, you may want to do this instead:

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

And now it'll fetch from both. Up to you.

With that, you have the basics down! There's a lot more to talk about of course: how to respond to pull request feedback, what to do if your upstream squash merges, and rebasing your work on top of an updated upstream all come to mind. We'll get there. But you now know how to do the simplest possible version of working with GitHub.

In the next section, we'll talk about what we just did in a bit more depth, so you'll be prepared to learn some more advanced topics.

Learning more about core concepts

Before we go any further, we should clarify some things. If you really want to skip this section, you could go on to Section 3, but you might end up being a bit confused sometimes. You could always give it a try and come back here if you have questions, it's up to you.

Changes, commits, and revisions

If there's one concept that's at the heart of jj, that would be the change. Let's talk about them, as well as two related concepts: commits and revisions.

Changes

Changes serve the same purpose, conceptually, that commits do in git: they're a snapshot of your project, and the graph of these changes forms a sort of timeline of the history of the project.

So why not call them commits? Well, there's a big difference between changes in jj and commits in git, and that's because git's commits are immutable, whereas (by default) changes are mutable. Let's talk about how we use these tools to compare and contrast the two.

Creating Changes vs Commits

I really like this description from Pro Git:

The basic Git workflow goes something like this:

  • You modify files in your working tree.
  • You selectively stage just those changes you want to be part of your next commit, which adds only those changes to the staging area.
  • You do a commit, which takes the files as they are in the staging area and stores that snapshot permanently to your Git directory.

"Creating a commit" is an action that you do after you've got things set up the way you want them to be saved. You can use the index to do this incrementally if you'd like.

If we were to talk about jj in a similar way, I'd say this:

The basic jj workflow goes something like this:

  • You create a new change
  • You modify files in your working tree.
  • There is no step 3.

We are always working within the context of some change. jj refers to the change we're working on as @. Whenever you run a jj command, it will sync up your working tree and @. You can also ask Watchman to do this synchronization every time you save a file.

This means that in some sense, you work "backwards" in jj from git's perspective: it's not "modify files in your working tree, produce a commit" it's "make a new change, modify files in your working tree your working tree."

Descriptions

We haven't really talked about descriptions yet. We've used jj commit to give some of our changes descriptions, but as you've seen, jj new is totally fine with creating a new change without one. We can use jj describe to give @ a description. This will open up your $EDITOR and works very similar to git commit. If you give a lengthy description to a commit, jj will use its first line as the description in jj log. Just as with git, it's recommended that you create a relatively short first line for this reason.

jj commit is like running jj describe followed by a jj new. Many people find jj commit to be a useful tool when they start learning jj, but a lot of people end up not using it as much once they're more comfortable with the tool. There's nothing wrong with using it if it's the workflow you like, though!

Change IDs

You may have noticed change IDs use purely letters instead of letters and numbers. This is deliberate, and in fact, doesn't include a through f, so you cannot confuse a change ID for a commit ID.

Immutable Changes

While changes are mutable, there are some instances when you'd prefer not to mutate one. jj considers some commits immutable, and if you try to mutate one, it will refuse unless you pass --ignore-immutable as an argument.

Let's look at jj log:

$ jj log
@  opqvmvrn steve@steveklabnik.com 2025-02-06 09:43:34 95d5c471(empty) (no description set)tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 goodbye-world git_head() 326253c2
│  Goodbye, world!
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk 0c72abbb
│  Hello, world!
~

We have the green @, but its parent instead has a . This change is mutable. But what about p? It has a . This change is immutable.

jj considers a change immutable if it's part of your trunk/main/master branch, if it's got a git tag, if it's an untracked remote bookmark. You can override this, but I don't recommend it.

Oh, and the root change is also immutable. Let's talk about that!

The root change

One interesting thing about jj is that there's a special change that exists in every repository, the root change. While git allows for multiple root commits, jj does not: every change other than the root change must have a parent change. This simplifies a lot of the various algorithms used to modify history, but the specifics aren't important to us right now.

We can use jj show to show information about changes. The root change has a change ID of all zs, let's check it out:

$ jj show zz
Commit ID: 0000000000000000000000000000000000000000
Change ID: zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz
Author   : (no name set) <(no email set)> (1970-01-01 11:00:00)
Committer: (no name set) <(no email set)> (1970-01-01 11:00:00)

    (no description set)

Pretty fun. Wait, what's that Commit ID doing there? Okay, let's talk about commits.

Commits

So we've been talking about how changes are different than git's commits... but jj also has commits. Let me explain. Let's use jj st to look at our current change:

$ jj st
The working copy has no changes.
Working copy  (@) : opqvmvrn 95d5c471 (empty) (no description set)
Parent commit (@-): tnmounps 326253c2 goodbye-world | Goodbye, world!

Do you see how we have four identifiers there?

opqvmvrn 95d5c471
tnmounps 326253c2

The two on the left are change IDs, but the two on the right are commit IDs. What are commits for? Well, whenever you modify a change, that has to be stored in git somehow. And since git's commit IDs are based on (among other things) the contents of the commit, when you create a new git commit, you're also going to get a different ID. Here, let's give it a try: modify src/main.rs, in any way that you'd like. Then we'll run jj st again:

$ jj st
Working copy changes:
M src/main.rs
Working copy  (@) : opqvmvrn c920ae70 (no description set)
Parent commit (@-): tnmounps 326253c2 goodbye-world | Goodbye, world!

o used to have a commit ID of 95d5c471, but now it's c920ae70. Our change ID remains stable, but the commit ID will change over time.

This is very powerful! Part 4 of the tutorial is titled "Fixing Problems," and a lot of the stuff we will talk about there is powered by commits. We can use jj evolog, the "evolution log," to see how a change has evolved over time:

$ jj evolog --summary
@  opqvmvrn steve@steveklabnik.com 2025-02-06 09:43:34 c920ae70(no description set)M src/main.rsopqvmvrn hidden steve@steveklabnik.com 2025-02-06 09:43:34 95d5c471
   (empty) (no description set)

There are a lot of flags to control jj evolog's output. I've chosen the summary flag here to show which files we changed.

Revisions

The term revision is sometimes used as well. "revision" is a synonym for "commit." It's a slightly more generic term.

The jj project is still working out which terminology is best, and so sometimes, you'll find things like this. In particular, there's a desire to turn these three concepts into only two, but it's not clear which word ends up being best, so for now, there's three of them.

Throwing away changes & jj undo

We've talked a lot about how to make changes, but haven't talked about getting rid of them!

Getting rid of changes is very easy in jj: we can do it with jj abandon.

If you remember, we're in the middle of some stuff:

$ jj st
Working copy changes:
M src/main.rs
Working copy  (@) : opqvmvrn c920ae70 (no description set)
Parent commit (@-): tnmounps 326253c2 goodbye-world | Goodbye, world!

If you are coming to this section fresh, just type jj new a few times to give yourself some good changes. Done? Great. Let's throw them away.

jj restore to reset contents

Let's say we don't like that "hello and goodbye world" stuff. We're not going to pursue that further. Getting rid of it is as easy as:

$ jj restore --from p src/main.rs
Created opqvmvrn b37efa21 (no description set)
Working copy  (@) now at: opqvmvrn b37efa21 (no description set)
Parent commit (@-)      : tnmounps 326253c2 goodbye-world | Goodbye, world!
Added 0 files, modified 1 files, removed 0 files

By default, jj restore takes changes from your parent change, and puts them into @. But there's --from and even --into flags you can pass as well. Let's grab the diff from our first commit, and apply it to @:

$ jj restore --from p src/main.rs
Created opqvmvrn b37efa21 (no description set)
Working copy  (@) now at: opqvmvrn b37efa21 (no description set)
Parent commit (@-)      : tnmounps 326253c2 goodbye-world | Goodbye, world!
Added 0 files, modified 1 files, removed 0 files

As you can see we aren't empty any more. Well, what does our code look like? Let's use jj diff to see:

$ jj diff
Modified regular file src/main.rs:
   1    1: fn main() {
   2    2:     println!("GoodbyeHello, world!");
   3    3: }

This format is different than git's: we have red and green to indicate what's changed, for example.

If you want to get a git style diff instead, that is easy as well:

$ jj diff --git
diff --git a/src/main.rs b/src/main.rs
index 865c8c8225..e7a11a969c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,3 @@
 fn main() {
-    println!("Goodbye, world!");
+    println!("Hello, world!");
 }

We only had one file that was changed, so we didn't need to pass the path to jj restore, but jj restore is mostly used with individual paths. If we passed no arguments to jj restore, it would restore every file, that is, move the entire diff from your parent to @, effectively emptying out the change.

But what if we want to delete a change entirely?

jj abandon

At any time, you can get rid of a change with jj abandon. It's tons of fun! Let's try it:

$ jj abandon
Abandoned 1 commits:
  opqvmvrn b37efa21 (no description set)
Working copy  (@) now at: nvnlxpxw 0d1ce6d4 (empty) (no description set)
Parent commit (@-)      : tnmounps 326253c2 goodbye-world | Goodbye, world!

This throws away our current change. We abandoned opqvmvrn, and since that was the same as @, jj makes a new change for us, in this case, called nvnlxpxw.

But what if we abandon something that's not @? Like, let's say, t, the change that we're currently on top of. What's the worst that could happen?

$ jj config set --repo debug.randomness-seed 12365
$ jj abandon -r t
Abandoned 1 commits:
  tnmounps 326253c2 goodbye-world | Goodbye, world!
Deleted bookmarks: goodbye-world
Rebased 1 descendant commits onto parents of abandoned commits
Working copy  (@) now at: nvnlxpxw dfde4351 (empty) (no description set)

So what happened here?


$ jj config set --repo debug.randomness-seed 12366
$ jj log
@  nvnlxpxw steve@steveklabnik.com 2025-02-06 09:43:33 dfde4351(empty) (no description set)
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk git_head() 0c72abbb

As you can see, because we got rid of the commit we were standing on, instead of throwing us away too, jj just re-parented us onto the abandoned commit's parent. We're still on change nvnlxpxw, but now our parent is ptrqnyzv, not tnmounps.

But what if that was a mistake? What if we didn't actually want to throw away tnmounps, and we regret our actions here?

I have good news.

jj undo

There's a really useful subcommand that goes by jj undo:


$ jj config set --repo debug.randomness-seed 12367
$ jj undo
Undid operation: 57a0967c9ede (2025-02-06 09:43:34) abandon commit 326253c27c9867c92d11422133a39b67fdcb602f
Working copy  (@) now at: nvnlxpxw 0d1ce6d4 (empty) (no description set)

That's it! We're good again:


$ jj config set --repo debug.randomness-seed 12368
$ jj log
@  nvnlxpxw steve@steveklabnik.com 2025-02-06 09:43:32 0d1ce6d4(empty) (no description set)tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 goodbye-world git_head() 326253c2
│  Goodbye, world!
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk 0c72abbb

Everything is back to where we put it. We can always jj undo to undo any of our last operations, and jj will make things right again. Don't underestimate how good this feels: you can really try out things and not worry about messing up the state of the world, it's very freeing. It even brought our bookmark back!

There is one funny thing about jj undo I feel compelled to mention, though. What do you think would happen if we jj undo'd again right now?

Make your guess, and then give it a try:


$ jj config set --repo debug.randomness-seed 12369
$ jj undo
Undid operation: f4d229f0501c (2025-02-06 09:43:34) undo operation 57a0967c9ede1d9ea8a06f733fddf8cf6bcd632857444936fc8f1b2d17bfdb8e1e525cc3baa3264afb92adb480499cc23fde7348ca51fc8807081462071fe211
Working copy  (@) now at: nvnlxpxw dfde4351 (empty) (no description set)
Parent commit (@-)      : ptrqnyzv 0c72abbb trunk | Hello, world!
Added 0 files, modified 1 files, removed 0 files

That's right: the last thing you did was an undo, so an undo just undoes that undo. Hilarious, but kind of frustrating. There's a desire to let you go back an arbitrary number of undos, but it's a bit trickier than it sounds.

Regardless, we can fix this: there's no problem with jj undo that you can't solve by throwing more jj undos at it:


$ jj config set --repo debug.randomness-seed 12370
$ jj undo
Undid operation: 588ca5b61206 (2025-02-06 09:43:34) undo operation f4d229f0501caf00432edafeacfbc0f733188ff7ba910e490dd091dc5fed7a3d9bbb2bb6227c41cb7bc518452753cdfccb97546752058495e223f51b6c7c352e
Working copy  (@) now at: nvnlxpxw 0d1ce6d4 (empty) (no description set)
Parent commit (@-)      : tnmounps 326253c2 goodbye-world | Goodbye, world!
Added 0 files, modified 1 files, removed 0 files

Whew. That's enough of that.

Automatically abandoning changes

Having an empty change with no description is fine to have if it's @, or if it has children. Here's a fun party trick:


$ jj config set --repo debug.randomness-seed 12371
$ jj new --before n --no-edit
Created new commit wmznvnuw 24fb2f9f (empty) (no description set)
Rebased 1 descendant commits

That's right: jj new can take --before or --after flags to squish a change in between others. (Yes, we're trying to make squish happen.) And --no-edit means that we don't want to move our working copy to the new change: @ stays right where it is:


$ jj config set --repo debug.randomness-seed 12371
$ jj log
@  nvnlxpxw steve@steveklabnik.com 2025-02-06 09:43:34 6ac2036e(empty) (no description set)wmznvnuw steve@steveklabnik.com 2025-02-06 09:43:31 git_head() 24fb2f9f(empty) (no description set)tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 goodbye-world 326253c2
│  Goodbye, world!
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk 0c72abbb

So that change is fine. But what if we move away from these changes? Let's make a new change on top of trunk:


$ jj config set --repo debug.randomness-seed 12372
$ jj new trunk
Working copy  (@) now at: oquwwlss 09f65a23 (empty) (no description set)

We had two empty commits on top of goodbye-world before, but what about now?


$ jj config set --repo debug.randomness-seed 12373
$ jj log
@  oquwwlss steve@steveklabnik.com 2025-02-06 09:43:32 09f65a23(empty) (no description set)
│ ○  wmznvnuw steve@steveklabnik.com 2025-02-06 09:43:31 24fb2f9f
│ │  (empty) (no description set)
│ ○  tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 goodbye-world 326253c2
├─╯  Goodbye, world!
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk git_head() 0c72abbb

Our empty change nvnlxpxw was discarded, automatically. You don't have to worry about jj new littering up your repository, empty changes will end up abandoned.

Branching and Merging

The Squash Workflow

All of the code samples in this chapter are going to be messed up for now, I need to write the intervening chapters before they'll make any sense. This is just content ported from the old tutorial until I'm done with the rest of it.

So, in the introduction to this tutorial, I made the claim that jj is simpler yet just as powerful as git is. But I also just told you that jj does not have an index. Well, the thing is, we can still do what the index lets us do with git, it's just that it's a workflow rather than a feature. Let's give it a try.

If you recall, we are currently sitting on an empty change:

tnmounps steve@steveklabnik.com 2025-02-06 09:43:34 git_head() 326253c2
│  Goodbye, world!
  ptrqnyzv steve@steveklabnik.com 2024-09-24 12:43:36 trunk 0c72abbb
│  Hello, world!
~

$ jj config set --repo debug.randomness-seed 12352
$ jj bookmark create goodbye-world -r @-

Earlier, we used jj commit -m to give a commit message to @ and create a new change. Let's do that again:

$ jj config set --repo debug.randomness-seed 12353
$ jj log
@  opqvmvrn steve@steveklabnik.com 2025-02-06 09:43:34 95d5c471(empty) (no description set)

Now we have a change that represents our work, and @ is a new change with that one as its parent.

We can make a change to src/main.rs:

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

We're treating @ kind of like a index in this sense. Our work isn't going into our feature just yet. Let's do that now:

│  Hello, world!
~

This command moves the contents of @ into our parent. You can see that our current commit is now empty, and our parent has some changes, that is, the (empty) isn't there any more.

If you like doing git add -p to include only parts of the diff into the index, you can jj squash -i, and it'll bring up a little terminal editor you can use to select which parts of the diff you'd like to move. You can even click on the menus!

With this, we've shown how you can have the equivalent of a index if you want one, without needing "the index" as a distinct concept.

Why does this matter?

Basically, jj has managed to provide equal functionality, while removing the concept of "the index" as an independent feature entirely. This may not seem like a huge deal, and while it's not a massive one, it does have a number of other implications that aren't immediately obvious.

Because we don't have a index, we can do that auto-snapshotting of your working directory, removing the distinction between the working copy and a change. This makes it easy to not accidentally lose work. You know how people say it's hard to cause data loss in git? They're not wrong, but with jj, it's even more so. Having the working copy, index, and commit contents be three different things, where only one of them is permanently stored, means you can run commands that end up losing data. Have you ever run git reset --hard and realized that there was something in your index you forgot to save first? It's now gone forever. with jj,

Because the index is just a commit, we can use all of the tools that we use to transform commits onto our index. jj squash is not fundamentally about the index pattern: it's about moving portions of the diff one change represents into another change. Later in this book, we'll use jj squash for a similar, but different purpose.

Because we don't have a index, various commands don't need to account for it in their behavior. git reset has --soft, --mixed, and --hard modes (and a few more...), all of which do different things. This is because git reset, as a command that modifies the current branch head, needs to know what to do with the working copy and index, and so you need flags for each permutation of behavior someone may want to make use of.