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 describeto describe our commits in a human-friendly way - Using
jj newto create new changes - Viewing the contents of your repository with
jj log - Seeing what changed with
jj diffandjj show - Which files
jjtracks, 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:
- Create new repositories with
jj git init. - To start working on a new change, use
jj new. - To describe a change so humans can understand them, use
jj describe. - We can look at our work with
jj st. - 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:
- We describe the work we want to do.
- We create a new empty change on top of that one.
- As we produce work we want to put into our change, we use
jj squashto 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:
- We create a new change to work on our feature.
- If we end up doing exactly what we wanted to do, we’re done.
- 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.
- 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 absorbwill tell youNothing 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 yx | y: changes that are in either x or y::xAncestors of xx::Descendants of xx-: direct parents of xx+: direct children of xx::y: descendants of x that are also ancestors of y, including both x and yx+::y: paths from direct children of x to y, excluding x and including yx..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 changeall(): this function returns all visible changesmine(): this function returns all changes authored by the current user
More complex functions can take arguments:
parents(x): the parent changes ofxancestors(x): the same as::x, but see the next exampleancestors(x, depth): limits the results to a certain depth, which you can’t do with the::xsyntaxheads(x): commits inxthat are not ancestors of other commits inxdescription(substring:x): commits that have a substringxin 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
common — common..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
jjwith 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:
- Make change
- Update branch
- Make change
- Update branch
- Push
It is:
- Make change
- Make change
- Update branch
- 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
gitin the default colocated repository - Non-colocated repositories, and where the line between
jjandgitsits
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
trunkto@ontotrunk.
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
gitandjjat the same time against one repository. Run one, then the other. - Prefer
jjfor anything that changes history, such as committing, merging, or rebasing.jjignoresgit’s staging area and can’t model a half-finishedgit rebaseor an unresolvedgitindex conflict. Agitmutation that completes cleanly can usually be imported afterwards, but never handjjan operationgitleft 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
jjwon’t check them out or update them. - Git LFS is unsupported. Pointer files are ordinary files.
githooks 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..gitattributesis ignored — no clean/smudge filters, no line-ending normalization.- Annotated tags can be read and checked out, but not created.
git configis mostly ignored, except remote configuration andcore.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 restoreandjj 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, usejj 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()— everythingjjpermits 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
jjcommands 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:
- built-in defaults
- your user config
- the repository’s config
--configon 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_idanddescriptionare 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.