TL;DR
I became productive with jj (Jujutsu, dauntless version control) on day 1. This is my story, along with my mindset changes, and delicious recipes. Scroll down to the end for a short list of when to use what command/alias.
Why try jj
?
Well, dauntless is not the official slogan, but I really need it to describe a liberating feeling much stronger than “fearless”.
Git is an intimidating black box for some, but I don’t fear it in my daily work, as I’m skilled enough to easily recover work if I messed up.
jj
, on the other hand, gives me the ultimate power in the river of time:
In
jj
, I can go anywhere in history, do anything, be done with it, and history will reshape itself accordingly.
I wouldn’t know this when I first read Steve’s Jujutsu Tutorial months ago, it’s well written but still a haystack to find the needle that would click with me.
jujutsu on tangled intrigued me, by telling me “Jujutsu is built around structuring your work into meaningful commits”, and showing how it helps contributors to iterate on changes with dependencies on other changes, individually reviewed, reworked, and merged. But what if I just use it on my personal repo, and don’t care much about clean history?
A Newbie’s First Contribution to (Rust for) Linux: Git gud shows the author’s Git workflow to contribute to Linux kernel with a lot of fast moving forks. The configuration for rebase
and rerere
seems really solving the roadblocks of contributing to a thriving project. But the author said: “Jujutsu is much better at this kind of workflow and what I was actually using most of the time. I first discovered this workflow while using Jujutsu and only later found out you could do something similar with Git.” A refreshing remark! Maybe I do need native and friendly support for more advanced features, even just to alleviate my mind.
Eventually, pksunkara’s Git experts should try Jujutsu, and his side-by-side workflow comparisons with Git, and his jj
configuration full of aliases that gave me the first boost, so I finally decided to spend a day with jj
. I definitely no Git expert, but those aliases really look attempting, what if I can pick my few favorites?
I started the journey with a minimal goal. I just wanted to recover my Git workflow in jj
, as jj
coexists with a local Git repo, and I need jj
to work with my GitHub (or any code forge I might migrate to in future). I learned much more, and they are much easier than I thought.
Protect my main branch
First thing, I went to my GitHub settings and protected my main branch, to at least forbid force push, in case (my misuse of) jj
messes up the remote. (Here is how to do even better from CLI.)
Install and configure jj
I use just to write code snippets as tasks. Installing jj
on Mac, and prepare the configurations is simply:
prep-jj:
which jj || brew install jj
mkdir -p ~/.config/jj
cp -f dotfiles/.config/jj/config.toml ~/.config/jj/config.toml
jj config set --user user.name "$(git config --get user.name)"
jj config set --user user.email "$(git config --get user.email)"
jj config list --no-pager
The dotfiles/.config/jj/config.toml
is an adapted version from pksunkara’s, and all changed places are annotated with [NOTE]
comments and inspiring links. By copying to the right path, it becomes the user-wide configuration.
jj config set --user
will write additional and more personal information into the same file (assuming git has already configured these information), so they came later. Finally, the configuration is listed for inspection.
Every time I’ve updated the configuration, or I’m on a new machine, I’ll run just prep-jj
again, so I’m all set again. Your way to manage code snippets might be different, but it all boils down to similar few lines.
You might want to change the line editor = 'hx'
back to favorite editor, as mine is now Helix.
It also assumed that you have installed delta, a syntax-highlighting pager for git, diff, grep, and blame output. It’s eye candy, and makes the diff much more clear.
Initialize the Git repo
jj
can coexist, or officially, colocate with a local Git repo. I place all my local Git repos under ~/projects/
so my just task looks like this:
init-jj PROJ:
#!/usr/bin/env zsh
cd ~/projects/{{PROJ}}
jj status || jj git init --colocate
jj status
will detect if it’s already a jj
repo, otherwise we will initialize jj
to colocate with Git. I try to make all my tasks open to reentrance, so I can easily tweak and rerun them.
Actually it’s already alias to jj init
by pksunkara, but here we go with the origin command, to avoid locked-in. But later on, I’ll use more aliases, and you may consult the configuration for the full command.
Thrown into limbo
Once jj
is initialized, we are no longer on any branch of Git. If we go back to git
or lazygit
, we’ll see that we are on a detached state.
I don’t know about you, but my feeling was like floating in the air, with no solid ground under my feet.
I felt much better when I learned that what I commit as changes in jj
will still be reflected in Git as commits, and commits can still find their parents.
We can go back and edit a change then commit again, it would be become a new Git commit, but its identity as the original change remains the same. So changes can still find their parent changes.
Actually, a branch in Git, is just a reference always point to the latest commit of the branch, so its history can be traced via parents.
jj
has bookmark for pointing to a change. Unlike in Git, when you commit more changes on top of it, the bookmark doesn’t move to the latest change automatically, however, we can move it.
With these 2 powerful concept, we are lifted off ground, start flying among changes, with bookmarks marking our path.
The working copy
OK, we are not on any branch, so where are we?
jj log
(or simple jj l
or even jj
) gave me something like these at the top:
@ rrptyzpt 0f23c873 (empty) (no description set)
◆ vmzsxwzu f800c4ed My last commit on the main branch
That’s a lot to take in!
f800c4ed
and My last commit on the main branch
can be recognized as my last Git commit hash and message.
vmzsxwzu
is the change id assigned to it, along with a diamond ◆ indicating that it’s immutable: It’s written on the stars by Git, so we can’t edit it.
But what’s the commit-like stuff on top of it? I didn’t like it, and even tried to jj abandon
it, but it keeps coming back with a new change id and a new commit hash.
Finally, I accepted it as the “working copy”, indicated by @
. Unlike in Git, where I’ll be on HEAD, along with some local changes to be staged, in jj
, I’m already on the future commit as a working copy. That’s why sometimes we need @-
that points to the parent of @
, the last committed change.
(empty)
means I haven’t made any edits to this change. (no description set)
means I haven’t described what I’m going to do for this change (via jj desc
).
I’ll feel much better if it’s just (no description)
, as the set
makes me feel guilty for not providing a description ahead. And it turns out that I didn’t need to! Even after I’m done with it, and moved on (jj
will keep the change in its history, like automatically doing git stash
). If I have trouble recognizing the change without a description, I could always use jj d
to inspect the diff.
jj s
(status) shows it more verbosely:
The working copy has no changes.
│Working copy (@) : rrptyzpt 0f23c873 (empty) (no description set)
│
│Parent commit (@-): vmzsxwzu f800c4ed main | My last commit on the main branch
The change id of my working copy begins with rr
(bold and colored, indicating we can refer to it uniquely with rr
).
It’s a bit annoying to enter and quit pager, and unable to see the latest changes, so I’v configured jj
to use no pager and only occupy about 2/3 screen.
There’s also alias jj lp
that shows private (i.e. not pushed) changes, and jj lg
that shows all changes, including elided changes that you just pushed.
Commit
I worked on a repo that I’ll also add my just tasks and configurations for jj
into it. So I edit the justfile in my editor Helix, and saved it to disk.
Alias jj d
will show the diff, that confirms that jj
has seen our edits.
We can now commit the change. jj commit
will commit everything, opening the configured editor for commit message (just save and quit to submit, quit without saving to cancel).
Aliases like jj ci
will also fire up a built-in diff editor to interactively select which files, or even which parts (called sections) to commit. Arrow keys can fold/unfold files/sections, space to select, c
to commit, and q
to quit. (I couldn’t find how to commit until I tried c
, there is no visual hint at all, unless you use mouse to click on the menus, but I didn’t realize that I could do it on day 1, I tried every possible key combination to trigger the menu, no luck).
I often find myself repeatly use jj ci
to review, select and commit, to split my local edits into a few independent changes.
With alias jj cim <message>
, we can directly supply the commit message from CLI without opening an editor, then select which parts to commit.
New
After commit, we’ll again on an empty change with no description, so we can continue making new edits.
But if after working for a while, I suddenly want to discard my (uncommitted) edits, I can do jj n rr
to start over from the last change. The uncommitted edits will be still there in jj
as a change, but local files will be reset to the state when we commited rr
.
Here rr
could be any other change id, or any bookmark, or any revset in general. We’ll learn more about revset later, for now, we just need to know that a change id, a bookmark, @
and @-
that we know so far are all revset s.
jj n
(new) effectively spins off a new history line from the parent change. We can go along the line for a few changes, and spin off from anywhere again, without having to create a named branch.
Edit
In Git, I seldom amend a commit, usually I just use reword
in lazygit to amend the commit message, or reset --mixed
(gm
in lazygit) to discard history after a commit, then stage and commit them in a better way again.
In jj
, if I don’t like a change, I could go back and edit it:
jj e <revset>
will auto stash any dirty edits, and reset local files to the state of that <reveset>
(read: change), I can happily do any edits, or commit it with alternative selections of files or sections.
We don’t need to explictly commit, when we use jj n <revset>
or jj e <revset>
to jump to anywhere else, the local edits will be automatically committed to the original change.
Evolution of a change
Wait, what if I messed up when editing a commit, and left it, can I go back?
At that time, it’s beyond what jj undo
can easily do, especially we might be confused by what we did and didn’t do.
The first thing we can do is to inspect how a change has evolved over time by
jj evolog -r <revset>
It looks like this:
❯ jj evolog -r uo
○ uoxoknux 92fb8d3a (no description set)-- operation a0693515ab31 (2025-07-09 11:54:16) snapshot working copy
○ uoxoknux hidden 1f73cdfd (no description set)-- operation 79813357f450 (2025-07-09 11:52:58) snapshot working copy
○ uoxoknux hidden c2bf8f4f (empty) (no description set)-- operation a22c21ce9fc1 (2025-07-09 11:52:32) new empty commit
We can inspect the diffs of each hidden commits with their commit hash via jj dr <revset>
(alias for diff -r
) where <revset>
should be the commit hash to disambiguate.
The we can use either jj n
or jj e
on the commit hash of our choosing, to continue work.
Note that the change id would become ambiguous, so we need to jj ad
one of the commit hash so we can continue to refer to the change id.
It looks like this for me:
click to expand
❯ jj n 1f73 Working copy (@) now at: unyrkpru 003d7750 (empty) (no description set) Parent commit (@-) : uoxoknux?? 1f73cdfd (no description set) Added 0 files, modified 1 files, removed 0 files❯ jj e 1f73 Working copy (@) now at: uoxoknux?? 1f73cdfd (no description set) Parent commit (@-) : mxowtxvr 68b37035 (no description set)
❯ jj evolog -r uo Error: Revset
uo
resolved to more than one revision Hint: The revsetuo
resolved to these revisions: uoxoknux?? 92fb8d3a (no description set) uoxoknux?? 1f73cdfd (no description set)Hint: Some of these commits have the same change id. Abandon the unneeded commits with
jj abandon <commit_id>
.❯ jj ad 92f Abandoned 1 commits: uoxoknux?? 92fb8d3a (no description set) Rebased 1 descendant commits onto parents of abandoned commits
We may also use jj op diff --op <operationid>
to inspect meta changes of an operation (it has --from
--to
variant so it also works on a range of operations).
PR with an ad hoc branch
see also the official doc Working with GitHub: Using a generated bookmark name
If I like my changes so far on the current history line, I could open an unnamed PR by first commiting changes, then using alias
jj prw
where prw
stands for PR working copy.
Per our config, it will push to a branch named jj/<changeid>
on origin, then open a PR via gh
(Github’s official CLI to interact with issues, PRs, etc.), it will ask you for a title (default to the description of the first change), and a body, we could just press enter all the way to submit, and change anything later.
Personally I think such a tiny ad hoc PR is a good way to record the proposed changes, even for a personal repo. But if pusing to a branch is all you need, alias jj pscw
(push changes of working copy) will do.
After merging the PR, we need
jj git fetch
to let both Git and jj
to learn about the changes.
Here is how jj l
looked like after I PR a change “Make a start on trying jj” as #1
, merged it on GitHub, then fetched the changes:
◆ rzyozkll 929fe190 main Make a start on trying jj (#1)
│
◆ vmzsxwzu f800c4ed My last commit on the main branch
gh pr view #1 -w
can open the PR in the browser.
If we have made edits to the change (but not new changes on top of it, because they would be pushed to their own jj/<changeid>
branch), like jj e
or jj sqar
(introduced below), we can use
jj pscw
where pscw
stands for pushing local changes of working copy.
The result looks like this:
Changes to push to origin:
Move sideways bookmark jj/stwxmxoqkyym from 33c46b992efc to 4d3c2a60e0d2
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
If we have made other changes on top of the change, we can treat jj/<changeid>
as a named branch like below (i.e. jj bs jj/<changeid> -r <latest changeid>
and jj psb jj/<changeid>
).
PR with a named branch
This would best resemble a usual GitHub workflow.
First we create a local bookmark:
jj bs <bookmark> -r <revset>
<revset>
could be just a change id, @-
could come in handy to refer to the last committed change.
<bookmark>
is the name of the bookmark, just like a normal branch name. And bs
is the alias for bookmark set
.
We can see if the bookmark is correctly placed from jj l
. If we don’t like it, we can either jj undo
or jj bd <bookmark>
(bookmark delete).
Then we can PR that bookmark:
jj prb <bookmark>
This would push to a branch named <bookmark>
on Github, then use gh
to PR it.
It looks like working on a branch in Git, but after we commit more changes, we need to call jj bs -r <revset>
to set it to desired <revset>
, then
jj psb <bookmark>
Note that the alias begins with ps
instead of pr
, meaning “push bookmark”.
Fetching
What if we committed some changes on GitHub, like accepting a review suggestion, we need to use jj git fetch
to make jj
know about the changes. We’ll see something like
bookmark: our-name-of-bookmark@origin [updated] tracked
origin
is the remote on GitHub. [updated]
means jj
noticed the changes on the remote, tracked
means the local bookmark is also automatically updated.
If it’s not tracked, we can use
jj bt our-name-of-bookmark@origin
to track it.
Here is how jj l
looks like when I PR a few changes in this way then fetched it:
@ nozpulso fd589b2a (empty) (no description set)
○ kpxkrxvm a22ed20a configure-jj* Add frequently used jj command alias
○ tvvoymuu 6808370c Keybindings for builtin diff editor
○ ylnlzpvx b20ac2d1 Adapt jj config from pksunkara
○ rnkttumw f6296b0a Improve configs in `just init-jj`
◆ rzyozkll 929fe190 main Make a start on trying jj (#1)
◆ vmzsxwzu f800c4ed My last commit on the main branch
Rebase
But I didn’t get such a clean history from the start. I’d begun with a few fixup commits, and one of the commits is mixed with different changes. And initially, I pushed it with an ad hoc branch, accepted a few review suggestions on Github.
After opening the PR, I’ve noticed a weird non-empty commit xm
○ ...more changes...
◆ rzyozkll 929fe190 main Make a start on trying jj (#1)
│ ○ xmnykxym 4dcb41d9 (no description set)
├─┘
◆ vmzsxwzu f800c4ed My last commit on the main branch
After inspecting the diff, I believe its changes is aborbed into rz
(#1
), so I rebased xm
on top of rz
to see if there is any diff left.
❯ jj rb -s xm -d rz
Rebased 1 commits to destination
where rb
stands for rebase
.
I just need to remember that I’ll be rebasing source (-s
) onto destination (-d
).
Now I have
○ ...more changes...
│ ○ xmnykxym 2f622327 (empty) I think this is what absorbed into #1
├─┘
◆ rzyozkll 929fe190 main Make a start on trying jj (#1)
Nice, it’s indeed empty, so I can be assured no work is lost.
It’s an extremely simple use case, but I’m happy with being able to transplant a parallel change to be on top of another change, without much hassle.
Squash
There are some other complications:
@ ...more changes...
│ ○ zquplvzx 2b572336 configure-jj Update dotfiles/.config/jj/config.toml
│ ○ nmuntxon 746cb300 Fix typo found by copilot
├─┘
○ ...some changes in between...
○ ylnlzpvx b20ac2d1 Adapt jj config from pksunkara
○ rnkttumw f6296b0a Improve configs in `just init-jj`
I want to absorb typo fixes nm
and zq
into yl
, how do I do that?
It’s as simple as correctly specifying from
and to
:
❯ jj sqa --from zq --to y
Rebased 3 descendant commits
Working copy (@) now at: srnwoqzq c831d68c (empty) (no description set)
Parent commit (@-) : kpxkrxvm 7d911838 jj/vzzzxqvnptov* | Add frequently used jj command alias
Added 0 files, modified 1 files, removed 0 files
where sqa
is short for squash. Note from the output that any changes after them will be properly rebased.
There is also a shortcut if I just want to absorb a change into its parent: jj sqar <revset>
.
I keep using the word “absorb” but jj absorb
does a completely different thing (it splits a change, allowing some other changes to absorb it, by “moving each change to the closest mutable ancestor where the corresponding lines were modified last”, that sounds like “desolve” to me).
Summary
I could go on and try splitting a change, but the above is enough for day 1.
This doesn’t demonstrate the full power of jj
, but imagining how the same things could be done in Git, I’ll definitely rather spend the time with jj
.
I’ll wrap up with a summary of the commands/aliases that would be enough for daily work:
Getting started
alias | when to use |
---|---|
jj init | init jj in a local Git repo |
Show
alias | when to use |
---|---|
l | show log, jj works better |
lp | show private log |
lg | show all log, even elided changes |
s | show status |
w | show status and diff |
d | show the diff of current change |
dr <revset> | show the diff of revision <revset> |
Edits
alias | when to use |
---|---|
n <revset> | create a new change based on <revset> |
cm <message> | commit everything with <message> |
ci | commit the change, choose interactively, write commit message in $EDITOR |
e <revset> | edit any <revset> after commit |
<revset>
could be a short change id, a bookmark, and defaults to @ (working copy).
After edit, just go anywhere else (with e or n), the changes will be commited; or an explicit commit will do.
Working with remote branch
alias | when to use |
---|---|
git fetch | will update local bookmark from remote branch |
bt <branch>@origin | track remote branch |
PR with named branch
alias | when to use |
---|---|
bs <bookmark> -r <revset> | move bookmark <bookmark> to <revset> |
psb <bookmark> | push bookmark to remote branch named <bookmark> |
prb <bookmark> | create a PR from <bookmark> to the default branch |
PR with ad hoc branch
alias | when to use |
---|---|
prw | open (committed) working copy as a PR |
pscw | push changes of working copy to remote branch named jj/<changeid> |
History cleanup
alias | when to use |
---|---|
undo | undo last jj operation when messed up |
rb -s <src> -d <dst> | rebase <src> onto <dst> |
sqar <revset> | squash <revset> into its parent |
sqa --from <src> --to <dst> | squash <src> into <dst> |
ad <revset> | abandon a change |
evolog <revset> | show how a change evolve in time |
op log | show log of operations on jj |
bd <bookmark> | delete a bookmark |
These aliases can be found in this config file which is based on pksunkara’s gist.