Discuss on /r/git and Bluesky

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 revset uo 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

aliaswhen to use
jj initinit jj in a local Git repo

Show

aliaswhen to use
lshow log, jj works better
lpshow private log
lgshow all log, even elided changes
sshow status
wshow status and diff
dshow the diff of current change
dr <revset>show the diff of revision <revset>

Edits

aliaswhen to use
n <revset>create a new change based on <revset>
cm <message>commit everything with <message>
cicommit 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

aliaswhen to use
git fetchwill update local bookmark from remote branch
bt <branch>@origintrack remote branch

PR with named branch

aliaswhen 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

aliaswhen to use
prwopen (committed) working copy as a PR
pscwpush changes of working copy to remote branch named jj/<changeid>

History cleanup

aliaswhen to use
undoundo 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 logshow 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.