Jujutsu — A Version Control System

I am hearing of a new version control system called Jujutsu that even the creator of Git is acknowledging as a great alternative to Git. It is a version control system that is designed to be simple and powerful, and it is compatible with Git. This is going to be a simple overview of it while I teach myself how to use it. I am not sure if it is going to be something that we end up using, but I think it would be irresponsible if I did not at least explore it.
Installation
JJ is written in Rust, so you can cargo install it. However, I am going to use Brew to install it on my Mac.
Cargo Binstall
1cargo binstall --strategies crate-meta-data jj-cli
Without the --strategies
option, you may get equivalent binaries that should
be compiled from the same source code.
Linux
From Source
First, make sure that you have a Rust version >= 1.84 and that the libssl-dev
,
openssl
, pkg-config
, and build-essential
packages are installed by running
something like this:
1sudo apt-get install libssl-dev openssl pkg-config build-essential
Now run either:
1# To install the *prerelease* version from the main branch
2cargo install --git https://github.com/jj-vcs/jj.git --locked --bin jj jj-cli
or:
1# To install the latest release
2cargo install --locked --bin jj jj-cli
Arch Linux
You can install the jujutsu
package from the
official extra repository:
1pacman -S jujutsu
Or install from the AUR repository with the AUR Helper:
1yay -S jujutsu-git
NixOS
You can install a released version of jj
using the
nixpkgs jujutsu
package.
To install a prerelease version, you can use the flake for this repository.
For example, if you want to run jj
loaded from the flake, use:
1nix run 'github:jj-vcs/jj'
You can also add this flake url to your system input flakes. Or you can install the flake to your user profile:
1# Installs the prerelease version from the main branch
2nix profile install 'github:jj-vcs/jj'
Homebrew
This is my preferred method.
If you use Homebrew, you can run:
1# Installs the latest release
2brew install jj
Gentoo Linux
dev-vcs/jj
is available in the
GURU repository. Details on how to
enable the GURU repository can be found
here.
Once you have synced the GURU repository, you can install dev-vcs/jj
via
Portage:
1emerge -av dev-vcs/jj
OpenSUSE Tumbleweed
jujutsu
can be installed from the official
openSUSE-Tumbleweed-Oss
repository:
1zypper install jujutsu
Mac
From Source, Vendored OpenSSL
First make sure that you have a Rust version >= 1.84. You may also need to run:
1xcode-select --install
Now run either:
1# To install the *prerelease* version from the main branch
2cargo install --git https://github.com/jj-vcs/jj.git \
3 --features vendored-openssl --locked --bin jj jj-cli
or:
1# To install the latest release
2cargo install --features vendored-openssl --locked --bin jj jj-cli
From Source, Homebrew OpenSSL
First make sure that you have a Ruse version >= 1.84. You will also need Homebrew installed. You may then need to run some or all of these:
1xcode-select --install
2brew install openssl
3brew install pkg-config
4export PKG_CONFIG_PATH="$(brew --prefix)/opt/openssl@3/lib/pkgconfig"
Now run either:
1# To install the *prerelease* version from the main branch
2cargo install --git https://github.com/jj-vcs/jj.git --locked --bin jj jj-cli
or:
1# To install the latest release
2cargo install --locked --bin jj jj-cli
Homebrew
If you use Homebrew, you can run:
1# Installs the latest release
2brew install jj
MacPorts
You can install jj
via the
MacPorts jujutsu
port:
1# Installs the latest release
2sudo port install jujutsu
Windows
First make sure that you have a Rust version >= 1.84. Now run either:
1# To install the *prerelease* version from the main branch
2cargo install --git https://github.com/jj-vcs/jj.git --locked --bin jj jj-cli --features vendored-openssl
or:
1# to install the latest release
2cargo install --locked --bin jj jj-cli --features vendored-openssl
Initial Configuration
You may want to configure your name and email so commits are made in your name.
1jj config set --user user.name "Martin von Zweigbergk"
2jj config set --user user.email "martinvonz@google.com"
Command-line Completion
To set up command-line completion, source the output of
jj util completion bash/zsh/fish
. Exactly how to source it depends on your
shell.
Improved completions are also available. They will complete things like
bookmarks, aliases, revisions, operations, and files. They can be context aware,
for example they respect the global flags --repository
and --at-operation
as
well as some command-specific ones like --revision
, --form
, and --to
. You
can activate them with the alternative "dynamic" instructions below. They should
still complete everything the static completions did, so only activate one of
them.
Bash
Standard
1source <(jj util completion bash)
Dynamic
1source <(COMPLETE=bash jj)
Zsh
Standard
1autoload -U compinit
2compinit
3source <(jj util completion zsh)
Dynamic
1source <(COMPLETE=zsh jj)
Fish
| Note | | ---------------------------------------------------------------------------------------- | | No configuration is required with fish >= 4.1 which loads dynamic completions by default |
Standard
1jj util completion fish | source
Dynamic
1COMPLETE=fish jj | source
Nushell
1jj util completion nushell | save completions-jj.nu
2use completions-jj.nu * # Or `source completions-jj.nu``
(Dynamic completions not available yet)
Xonsh
1source-bash $(jj util completion
(Dynamic completions not available yet)
Powershell
Insert this line in your $PORFILE file: (usually $HOME/Documents/PowerShell/Microsoft.PowerShell_profile.ps1)
1Invoke-Expression (& { (jj util completion power-shell | Out-String) })
(Dynamic completions not available yet)
Tutorial/The Basics
I am going to create a simple 'Hello World' project in go to test jj out. You can follow along in any language that you prefer or just read along.
If you would rather follow along with a video, I would do that using the following YouTube Video by DevOps Toolbox. He does a great job of walking you through the same tutorial I am about to.
Create a file main.go
with the following content:
1package main
2
3import "fmt"
4
5func main() {
6 fmt.Println("Hello World")
7}
Then in your terminal, run the following commands:
1go mod init
2
3# After this finishes
4
5go mod tidy
6
7# After this finishes
8
9go run main.go
Our app should run and 'Hello World' should be printed to the console. No we can start plating with jj.
Run the following command to initiate a new jj based repository:
1jj git init
Jujutsu is still backed by git so git is still in the command. Instead of .git
a .jj
was created that is slightly different. The .jj
holds a working copy
with the state and a bunch of other files. The repo holds a state of operations
and other storage information.
Get status
The first command we will be looking at other than initiating the repo is
jj st
or 'get status'. Run the following in your terminal.
1jj st
This will display files added indicated by an 'A' next to the file name. A parent root commit which is how jj repos start and a working copy commit with a few interesting details. Unlike git, jj has no index.
If you are unfamiliar with git's index, this is also known as a staging area where files are staged and committed. In a way jj has everything staged at all times.
Back to your terminal. You will notice that each line has a series of characters, this is what jj tracks as change and next to it will be a more familiar hash which indicates a commit.
Typing just jj
into the terminal is a shorthand for jj log
and, seemingly,
the most useful command in the system. Go ahead and type it in. It will show the
same entries in the status but add a time-stamp and, more importantly,
identifiers next to it. The most important of which is the '@' sign indicating
which change we are currently editing. Now that we have made some changes, or,
are ready to give our changes a proper description we can do this using
jj describe
and can be done more than once on the same change.
Descriptions
Giving a description does not mean that we are done, it just means that we have a good description for what we have done. Think of this as making small commits when working with a git repo.
We can provide a description in two ways by either using
jj describe -m "{message}"
or by typing jj describe
and then using the
internal editor. Choose your method and add a description to your change and
save.
1jj describe -m "Added hellow world"
Now if you run jj
or jj log
again you will see that the description has been
added to the commit.
Blame
Jujutsu, like git, asks you to identify yourself with a name and an email which we will run through really quick right here.
Simply run the following command in your terminal:
1jj config edit --user
And you will be taken to a file where you should add the following details:
1[user]
2name = "YOUR NAME"
3email = "YOUR EMAIL"
Save this file run jj describe
and leave the message 'Added my email.' and
then run jj
or jj log
again and you will now see your name and email appear
along the commit and message.
You will also notice that the '@' symbol is still saying that we are editing the same change. A commit, or a 'change' as jj calls them, is a dynamic, ongoing, ever-evolving creation. This means that a change does not have an end of its own, or rather ends when a new change starts.
New Change
jj new
starts a new change and running it will move the '@' symbol. You will
also notice that the change ID on the previous working copy above is now at the
parent commit. This is also addressable by '@-'. Now there is a new change that
is currently empty and has no description.
Let's make a small change by adding a comment to the top of our file that simply says 'This is a hello world program'.
Running jj st
now will show that in the status there has been a modification
indicated by 'M'. A new, unfamiliar file will always show up as a change that is
added with an 'A' next to it. (Go ahead and create a new file really quick). In
Git this would be an 'untracked' file, but in jj it is a change like any other
(You can go ahead and delete the file now).
Creating another new change by running jj new
works even though the previous
change has no description. This might seem weird but the intention of jj is to
not get in the way, it wants to let you work even when it is not ideal.
Before we make some changes backwards let's go over a mind blowing concept that
is pulling me more and more towards using jj over git. JJ comes with 'undo'.
Running jj undo
will bring you back to the previous change. To try this out
run jj new
and then jj st
to see that you are in a new change and then run
jj undo
and then jj st
again to see that you are now back in the previous
change.
You might have also noticed at this point that the first letter of every id is colored. This is so that you can easily address a change without having to know the entire ID.
Now lets describe our change really quick and add another commit. Remember that changes in jj are a dynamic creation and does not end it describing it again opens the editor with the current message that can be changed or added to and saved.
We can also provide the message while starting a new change. Lets do this by running the following:
1jj new -m 'Another comment'
Squashing and Branching
This feels like it could have fit with the previous change and like there was no
need to add another change to the tree. jj squash
will do exactly that;
combines the changes and their message. Upon saving something interesting has
happened. You might notice that while the change has stayed the same the commit
hash changed as well as the time-stamp indicating this is a different atomic
commit. Not only that, but there is a new, empty change automatically started on
top that we are currently in.
The cool and somewhat confusing thing about jj squash
is that it does not have
to address and existing change. If I make a change to my file 'main.go' with no
description or anything by adding another comment and then running the following
command:
1jj squash main.go
This will specifically push changes from that file to the parent commit. This
will again keep the change ID but changing the commit hash and time-stamp. To
confirm this works run jj st
again and you will see that the working copy has
no changes, but the top of 'main.go' has the final comment that we just added.
If you want to get really crazy you can run jj squash -i
and this will run an
interactive session where you can pick specific files and hunks of code and
squash them down if you want them.
If you don't like the current working change you can always abandon it by
running jj abandon
this will reset the working copy and create a new empty
change for you to work on. No messing about with rebasing or reset commands.
BUT we are just getting started this next bit is the real power of jj over git.
Let's start a new change and give it a descriptive name. I went for the following:
1jj new -m 'print something else'
And added the following code to my file:
1fmt.Println("Something else")
So the full file looks like this:
1// This is a hello world program in Go
2// This is another comment
3// ANother comment added to test jj squash
4package main
5
6import "fmt"
7
8func main() {
9 fmt.Println("Hello World")
10 fmt.Println("Something else")
11}
Now I am going to create a new change on top with an empty string by running
jj new -m ''
. Now, what if, I kept working, but I realized that I actually
wanted to make additional modifications to my previous change? There are couple
of ways to 'travel back in time' with jj.
The first of which is rather straight forward. Let's run the following command:
1jj new -B @ -m 'remove most comments'
This is telling jj to create a new change before with '-B'. Before what change? Well in this case, we are going to go before the current change indicated by the '@' symbol. This does not have to be the change that is on top. And, of course, we give it a description.
After running that command you will see that jj is saying that it has rebased 1 descendant commits. Why is it saying this? Because it pushed a change before the current edited one, so it had to rebase the changes on top.
If you are a little confused I am going to break it down here because rebasing got me the first couple of times I worked with it on Git. We committed a change before the one that we are currently working on and it has automatically rebased our original change. But, what if there are conflicts? Well, we will get to that a little later. For now, understand something that is a little difficult to believe. This rebase will always succeed. This sounds ridiculous, but it is AMAZING.
Let's go back to our file and delete most of our comments so that the full file looks like this:
1// This is a hello world program in Go
2
3package main
4
5import "fmt"
6
7func main() {
8 fmt.Println("Hello World")
9 fmt.Println("Something else")
10}
Now, let's say I want to make changes to an earlier entry, not necessarily the last. Remember those colored letters at the front of our ID? Well, we are about to use them.
By running jj edit {ID OR it's colored first character(s)}
this will throw us
back to it. If you open your file this will be evidenced by seeing that your new
changes are gone and your previous changes have appeared. I ran jj edit pn
and
ended up back when I first created the file and there were no comments.
If I know run jj next --edit
this will move us to the next edit on the tree
and you can keep doing this until you reach the current working copy. This can,
of course, be done with a direct edit, but it is a super cool feature of jj and
one that can help you run through and make changes to a tree as you need.
So, now about the thing that I am sure we are all wondering. What about branches? Well, jj doesn't really use them. You can think of them as anonymous branches or think of them as a 'branch-less workflow'. Another huge difference is, it turns out, you don't really need to name your branches. Apparently, this is influenced by Facebook (Meta) who has supposedly been doing that in their own system for years (which is especially funny because jj was initially create, or trademarked or whatever by Google). The reason jj doesn't use branches is because branches are just changes stemming from different parents. We don't need to name our branches so long as we are describing their changes.
To 'branch out' in jj you run jj new
with a reference to a different change
than the one that we are at. That is pretty much it. For example I am going to
run jj new zmw
to get to a different change than the one that I am currently
at in my file tree. This will create a new working copy where the parent is the
'print something else' change.
Looking at the log by running jj
or jj log
will show a visual branch out of
what we have just done, so it is easier to track later when we merge paths
again. In my file at this branch I am going to make a quick comment saying 'This
is the branch change' to note, well, that this is the change in this branch.
Now when I describe it with jj desc -m 'added a branch out comment'
we will
see a fork in the road that is described by the change.
I am going to get a little crazier and go to the change before that labeled
'remove most comments' and add another function. To do this I am going to run
jj edit zmq
and I will add the following function to the file:
1func another() {
2 fmt.Println("Another function")
3}
Since jj doesn't have branches as first class citizens and these are just
anonymous branches stemming out from different places to list branches we can
use the log
command along with two internal functions 'heads(all())
making
the full command:
1jj log -r 'heads(all())'
These functions are called 'rev sets' or 'revision sets' and they allow us to cover a range of commits.
Merging Branches
Let's go ahead and start merging our branches. This is actually really easy. All you have to do in jj is make a new change with references to two parents and that will merge them. So, to merge our branches with changes we will run the following (obviously change your references as needed):
1jj new zmq l -m 'merge branches'
This is actually insanely powerful. In jj you can add as many references as you like (not just the two that you changed) and combine them into one new working copy. Looking at our current file we have now our branched out comment and new function both merged successfully.
Even though jj kind of eliminated the need for it, it does have a rebase command to instruct the system to do exactly that, place another change on top of another. Let's go ahead and do this by combining some of the concepts we have already covered.
By using the 'undo' command we can get rid of the merges we just did (which can actually be ran again to undo the undo).
So I am going to run jj undo
to get rid of my merge and then I am going to
rebase my merge on top of the main branch with the following command (again
change your reference IDs as needed):
1jj rebase -s l -d zmq
The tree is now flat but keep in mind that you are at the change you were
working in not on top of the rebased change like we were earlier. We will run a
nice trick for that and run jj edit @+
to end up at the child of the commit of
where we are at.
Resolving Conflicts
Before we finish things up lets see how jj handles conflicts. Let's go ahead and remove the new function (this is the one that I called 'another') then creating a new change that is described as 'adding a third function' that stems from an earlier commit, in my case 'zmq' so the full command after you have removed the function will be:
1jj new -m 'add third function' zmq
And then I will add a third function so that the whole file looks like:
1// This is a hello world program in Go
2
3package main
4
5import "fmt"
6
7func main() {
8 fmt.Println("Hello World")
9 fmt.Println("Something else")
10}
11
12func another() {
13 fmt.Println("Another function")
14}
15
16func third() {
17 fmt.Println("Third")
18}
Now I am going to rebase this change on top of the top one which will create a conflict with the following command:
1jj rebase -s l -d ps
You will see that a conflict will appear, but what is more interested is that the rebase went through.
Running jj
or jj log
will show a conflict and as earlier we are at the
source of the rebase, not at the conflict, so we will run jj edit @+
to get to
the conflict where we are told that it is a two sided conflict.
Going into the file shows a familiar site showing the changes and where they came from. So we fix them, save them, and describe them.
But there is another way to do it built right into jj.
Let's run jj undo
to return to our conflict. Or create a host of other
conflicts like I did and then jump into the first file with a conflict and run
jj resolve
this will pop an interactive interface where we can find conflicted
files and resolve them. The mouse is available here and you can use it to
resolve your conflicts as needed. It is important to note that only 2 sided
conflicts are supported in the editor, if it is more than that you need to
resolve them yourself if the method described before.
Cloning and Pushing to GitHub
Now those are the basics. We are going to now go through how to work push this to GitHub or how to clone something from GitHub and work in it.
More details instructions can be found on the jj docs page here.
Cloning a Git Repo
Cloning is pretty straight forward you simply run jj git clone {URL OF REPO}
and then cd into it to begin working in it just like with git. From there you
can change the files that you want and make any changes you would like using jj
as your version control.
Working with GitHub
Set up an SSH key
As of October 2023 it's recommended to set up an SSH key to work with GitHub projects. See GitHub's Tutorial. This restriction may be lifted in the future, see issue #469 for more information and progress on authenticated HTTP.
Basic workflow
The simplest way to start with jj is to create a stack of commits first. You will only need to create a bookmark when you need to push the stack to a remote. There are two primary workflows: using a generated bookmark or naming a bookmark.
Using a generated bookmark name
In this example we're letting jj auto-create a bookmark:
1# Start a new commit off of the default bookmark.
2$ jj new main
3# Refactor some files, then add a description and start a new commit
4$ jj commit -m 'refactor(foo): restructure foo()'
5# Add a feature, then add a description and start a new commit
6$ jj commit -m 'feat(bar): add support for bar'
7# Let Jujutsu generate a bookmark name and push that to GitHub. Note that we
8# push the working-copy commit's *parent* because the working-copy commit
9# itself is empty.
10$ jj git push -c @-
Using a named bookmark
In this example, we create a bookmark named bar
and then push it to the
remote.
1# Start a new commit off of the default bookmark.
2$ jj new main
3# Refactor some files, then add a description and start a new commit
4$ jj commit -m 'refactor(foo): restructure foo()'
5# Add a feature, then add a description and start a new commit
6$ jj commit -m 'feat(bar): add support for bar'
7# Create a bookmark so we can push it to GitHub. Note that we created the bookmark
8# on the working-copy commit's *parent* because the working copy itself is empty.
9$ jj bookmark create bar -r @- # `bar` now contains the previous two commits.
10# Push the bookmark to GitHub (pushes only `bar`)
11$ jj git push --allow-new
While it's possible to create a bookmark in advance and commit on top of it in a Git-like manner, you will then need to move the bookmark manually when you create a new commits. Unlike Git, jj will not do it automatically.
Updating the repo
AS of October 2023, jj has no equivalent to git pull
command (see
issue #1039). Until such a command
is added, you need to use jj git fetch
followed by a
jj rebase -d $main_bookmark
to update your changes.
Working in a Git co-located repo
After doing jj git init --colocate
, Git will be in a
detached HEAD state,
which is unusual, as Git mainly works with named branches; jj does not.
In a co-located repo, every jj command will automatically synchronize jj's view
of the repo with Git's view. For example, jj commit
updates the HEAD of the
Git repo, enabling an incremental migration.
1$ nvim docs/tutorial.md
2$ # Do some more work.
3$ jj commit -m "Update tutorial"
4# Create a bookmark on the working-copy commit's parent
5$ jj bookmark create doc-update -r @-
6$ jj git push --allow-new
Working in a Jujutsu repo
In a jj repo, the workflow is simplified. If there's no need for explicitly named bookmarks, you can just generate one for a change. As jj is able to create a bookmark for a revision.
1# Do your work
2jj commit
3# Push change "mw", letting Jujutsu automatically create a bookmark called
4# "push-mwmpwkwknuz"
5jj git push --change mw
Addressing review comments
There are two workflows for addressing review comments, depending on your project's preference. Many projects prefer that you address comments by adding commits to your bookmark. Some projects (such as jj and LLVM) instead prefer that you keep your commits clean by rewriting them and then force-pushing.
Adding new commits
If your project prefers that you address review comments by adding commits on top, you can do that by doing something like this:
1# Create a new commit on top of the `your-feature` bookmark from above.
2jj new your-feature
3# Address the comments by updating the code. Then review the changes.
4jj diff
5# Give the fix a description and create a new working-copy on top.
6jj commit -m 'address pr comments'
7# Update the bookmark to point to the new commit.
8jj bookmark move your-feature --to @-
9# Push it to your remote
10jj git push
Notably, the above workflow creates a new commit for you. The same can be achieved without creating a new commit.
| Warning |
| ----------------------------------------------------------------------------------------------------------------------- |
| We strongly suggest to jj new
after the example below, as all further edits still get amended to the previous commit. |
1# Create a new commit on top of the `your-feature` bookmark from above.
2jj new your-feature
3# Address the comments by updating the code. Then review the changes.
4jj diff
5# Give the fix a description.
6jj describe -m 'address pr comments'
7# Update the bookmark to point to the current commit.
8jj bookmark move your-feature --to @
9# Push it to your remote
10jj git push
Rewriting commits
If your project prefers that you keep commits clean, you can do that by doing something like this:
1# Create a new commit on top of the second-to-last commit in `your-feature`,
2# as reviewers requested a fix there.
3jj new your-feature- # NOTE: the trailing hyphen is not a typo!
4# Address the comments by updating the code. Then review the changes.
5jj diff
6# Squash the changes into the parent commit
7jj squash
8# Push the updated bookmark to the remote. Jujutsu automatically makes it a
9# force push
10jj git push --bookmark your-feature
The hyphen after your-feature
comes from the
revset syntax.
Working with other people's bookmarks
By default, jj git clone
imports the default remote bookmark (which is usually
main
or master
), but jj git fetch
doesn't import new remote bookmarks to
local bookmarks. This means that if you want to iterate or test another
contributor's, you'll need to do jj new <bookmark>@<remote>
onto it.
If you want to import all remote bookmarks including inactive ones, set
git.auto-local-bookmark = true
in the config file. Then you can specify a
contributor's bookmark as jj new <bookmark>
instead of
jj new <bookmark>@<remote>
.
You can find more information on that setting here.
Using GitHub CLI
GitHub CLI will have trouble finding the proper Git repo path in jj repos that
aren't
co-located
(see issue #1008)g. You can
configure the $GIT_DIR
environment variable to point it to the right path:
1GIT_DIR=.jj/repo/store/git gh issue list
You can make that automatic by installing direnv and
defining hooks in a .envrc
file in the repo root to configure $GIT_DIR
. Just
add this line into .envrc
:
1export GIT_DIR=$PWD/.jj/repo/store/git
Run direnv allow
to approve it for direnv to run. Then GitHub CLI will work
automatically even in repos that aren't co-located so you can execute commands
like gh issue list
normally.
Useful Revsets
Log all revisions across all local bookmarks that aren't on the main bookmark nor on any remote:
1jj log -r 'bookmarks() & ~(main | remote_bookmarks())'
Log all revisions that you authored, across all bookmarks that aren't on any remote:
1jj log -r 'mine() & bookmarks() & ~remote_bookmarks()'
Log all remote bookmarks that you authored or committed to:
1jj log -r 'remote_bookmarks() & (mine() | committer(<your@email.com>))'
Log all ancestors of the current working copy that aren't on any remote:
1jj log -r 'remote_bookmarks()..@'
Using several remotes
It is common to use several remotes when contributing to a shared repository. For example, "upstream" can designate the remote where the changes will be merged through a pull-request while "origin" is your private fork of the project.
1jj git clone --remote upstream https://github.com/upstream-org/repo
2cd repo
3jj git remote add origin git@github.com:your-org/your-repo-fork
This will automatically setup your repository to track the main bookmark from the upstream repository, typically main@upstream or master@upstream.
You might want to jj git fetch from "upstream" and to jj git push to "origin". You can configure the default remotes to fetch from and push to in your configuration file (for example, .jj/repo/config.toml):
1[git]
2fetch = "upstream"
3push = "origin"
The default for both git.fetch and git.push is "origin".
If you usually work on a project from several computers, you may configure jj to fetch from both repositories by default, in order to keep your own bookmarks synchronized through your origin repository:
1[git]
2fetch = ["upstream", "origin"]
3push = "origin"