JJ has replaced git for me

7 min read
`jj new post`
Photo taken inside the Mosque-Cathedral of Cordoba

Photo taken inside the Mosque-Cathedral of Cordoba

Git is a good tool. It's fast and and reliable. It's been around for over 20 years for a reason.

I don't mind git. I wouldn't say I enjoy using it; I can usually get it to do what I want. When things get messy, I chant an incantation of different commands and SHA's and everything's back on track.

While I feel competent with git, it sometimes feels like a chore to use. Every git user has a bunch of git aliases in their shell. At this point, I wouldn't mind an abstraction on top – some CLI tool or GUI. But I also dislike it when I don't know how to use the abstraction or when it breaks. If I feel like it's getting in the way, I'd want the option of rolling up my sleeves and dropping back down to plain git.


This is why I love Jujutsu (JJ). It's is designed to support different backends, such as git (the only one today). You can also use git and jj at the same time, in the same repository.

$ jj git init --colocate Initialized repo in "." $ git status On branch main No commits yet nothing to commit (create/copy files and use "git add" to track) $ jj status The working copy has no changes. Working copy (@) : skoknytr cae3a7bf (empty) (no description set) Parent commit (@-): zzzzzzzz 00000000 (empty) (no description set)

jj vs git

If you want a pull request to ship quickly, you'll put in effort to make sure it can be reviewed quickly – not the topic of this post – but when you're doing so, you'll probably perform one or more of these manoeuvres:

I'll assume you know how to do this in git, and I'll show you how to do it in jj. But first, I should probably explain how to make a commit in jj.

how to make a commit in jj

In jj, there's no staging area - you're always editing a "commit". When you want to start a new one, you do:

$ jj new

Whenever you amend a commit, the SHA changes. This is also true for git. But in jj, every commit also has a stable change ID.

# Creating a new file
$ touch README.md
# Setting a message
# Note that `git add` is no longer a concept - changes are automatically included.
$ jj describe -m "Initial commit"
Working copy  (@) now at: skoknytr 6b2ba360 Initial commit
Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)

6b2ba360 is the current commit ID and skoknytr is the change ID. Watch what happens when we reword the commit:

# We're still editing the same commit, so `describe` refers to it still.
$ jj describe -m "Hello change IDs"
Working copy  (@) now at: skoknytr c35ceeec Hello change IDs
Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)

The commit ID was updated, but not the change ID. This is one of the advantages jj has over git: it can keep track of commits even when you amend them.

Let's look at more!

Reordering commits

The git incantation I'd use here is git rebase -i HEAD~n which gives me an overview of past commits. The big drawback is that this would rewrite commits in history up to n even when left untouched, so I need to be careful not to rewrite a commit I already pushed to the remote.

With jj, this is much simpler (this is going to be the theme). Let's say we have the following tree

$ jj log
@  mwnlowpn 2025-08-24 11:57:04 40f28eff
Second commit
rltznoyl 2025-08-24 11:56:24 git_head() 4da7f0bf
│  Initial commit
skoknytr 2025-08-23 21:43:08 c35ceeec
│  Hello change IDs
  zzzzzzzz root() 00000000

and we'd like to reorder mwnlowpn and rltznoyl to appear before skoknytr

# '-r' revision, '-B' before 
$ jj rebase -r 'r|m' -B s

Notice how we only had to specify the highlighted prefix of the change ID to reference it? That's because unlike git, jj doesn't hate you1.

$ jj log
skoknytr 2025-08-24 19:17:26 8713ca16
│  Hello change IDs
@  mwnlowpn 2025-08-24 19:17:26 f43732e3
Second commit
rltznoyl 2025-08-24 19:17:26 git_head() 2e822767
│  Initial commit
  zzzzzzzz root() 00000000

Squashing a commit

You're iterating on a change, you made an edit that should have been in an earlier commit, more than one commit back. How do we get it there?

With git, I'd make a temporary commit and use git rebase -i to apply the fixup marker. In jj, the squash command takes in a revision or even a file path.

$ jj status
Working copy changes:
A oh_no_i_should_be_in_second_commit.txt
Working copy  (@) : ktvukyzy 97336797 (no description set)
Parent commit (@-): skoknytr 8713ca16 Hello change IDs
# '-t' revision to squash into, 'm' is the prefix of 'mwnlowpn'
$ jj squash oh_no_i_should_be_in_second_commit.txt -t m
Rebased 1 descendant commits
Working copy  (@) now at: mymznssv 08423a87 (empty) (no description set)
Parent commit (@-)      : skoknytr 6fc085f7 Hello change IDs

And in interactive mode -i, you could also select hunks.

Rebasing a stack

The working model is quite similar to how you'd do this in git, but the management aspect is far more pleasant.

Here's a stack of 2 branches. In jj, branches are called bookmarks. They're not exactly the same as git branches, but you can think about them and work with them in the same ways. When you push a bookmark to the remote origin, the git backend creates a git branch.

$ jj log
@  ylxytysw 2025-08-24 21:52:13 a5baed47
I'm a parallel change
│ ○  xtoyxsww 2025-08-24 21:44:12 osama/change-b 6b0b88fd
│ │  Stack change b-2
│ ○  uvwzswtr 2025-08-24 21:43:55 e015b029
│ │  Stack change b-1
│ ○  wkqnuxzy 2025-08-24 21:43:47 osama/change-a 42fb31e1
│ │  Stack change a-2
│ ○  mymznssv 2025-08-24 21:43:41 f4fcd8f9
├─╯  Stack change a-1
skoknytr 2025-08-24 20:40:35 git_head() 6fc085f7
│  Hello change IDs
mwnlowpn 2025-08-24 20:40:35 8de99ce6
│  Second commit
rltznoyl 2025-08-24 19:17:26 2e822767
│  Initial commit
  zzzzzzzz root() 00000000

There's also a parallel change ylxytysw that's based on the same revision as our stack, skoknytr. This could be a commit that landed in main while we're working on our changes or our own merge commit for one of the earlier branches in our stack.

When I'm faced with this in git, I reach for an advanced command git rebase --onto2 to avoid dealing with conflicts. It's a big time save, but it's still only capable of rebasing one branch at a time.

What we're doing here is moving a connected range of commits to come after a particular commit. The jj version is as simple as that sentence.

# '-r' revision, 'my::x' from `my` to `x`, 'A' after
$ jj rebase -r my::x -A y
Rebased 4 commits to destination
$ jj log
xtoyxsww 2025-08-24 22:35:19 osama/change-b a6cdd68c
│  Stack change b-2
uvwzswtr 2025-08-24 22:35:19 6305545b
│  Stack change b-1
wkqnuxzy 2025-08-24 22:35:19 osama/change-a 8fa3ce81
│  Stack change a-2
mymznssv 2025-08-24 22:35:19 a94003c8
│  Stack change a-1
@  ylxytysw 2025-08-24 21:52:13 a5baed47
I'm a parallel change
skoknytr 2025-08-24 20:40:35 git_head() 6fc085f7
│  Hello change IDs
mwnlowpn 2025-08-24 20:40:35 8de99ce6
│  Second commit
rltznoyl 2025-08-24 19:17:26 2e822767
│  Initial commit
  zzzzzzzz root() 00000000

I haven't even shown you jj split or jj op - there's a ton more about jj that makes it a joy to use.

If you're intrigued, check out the docs. There are many (lengthy) tutorials in the wild. But honestly, you can just dive into it. jj new, jj bookmark set [name] and jj git push is all you need in the beginning. Oh and if you use the built-in merge tool, this cheatsheet will be handy.

Footnotes

  1. Why are Jujutsu's ID Prefixes So Short?

  2. Mastering git rebase --onto

Discuss on Bluesky