3694 words
18 minutes
How Jujutsu VCS helps to lower the barrier to perfect git history
2025-05-04
2025-05-12

Working properly with Git is a chore. I need to make sure the commits are descriptive (often following Conventional Commits), and the commits need to be atomic. I used to spend a non-trivial amount of time and effort trying to keep my history sensible, and still failed. While I knew about history editing, I always thought it was for experts. Also, when I used git, I preferred to use JetBrains’ IDE features rather than the CLI, and even then, I stuck to the basic add, commit, push, checkout commands, and occasionally merge and rebase. Jujutsu (jj) changed that. jj made it easy to fix the usual mistakes I’d make with git. It’s different from git, but once they clicked, I couldn’t go back. Also, from what I’ve been told, you will also like jj more if you’re a fan of Mercurial. You don’t need to choose between jj and git either, you can use both! Learn more here: Co-located Jujutsu/Git repos. That’s what I did to learn jj. In this blog post I want to show you the building blocks to use jj.

ASSUMED AUDIENCE

Existing git users who share the same pain points.

Somebody who loved hg, and is unhappy about git.

Minimal workflow#

Let’s talk about the standard minimal workflow I used for git, and compare it with jj.

ActionGitJujutsu
Start tracking a filegit add .All files are tracked by default
Commit the filegit commit -m "(hopefully descriptive) commit message"jj commit -m "commit message (easy to change later)"
Push to repogit pushjj git push -b "branch-name"
Check statusgit statusjj st

That’s it! When I first learned about git, those were the only commands I’d use.

Later, I learned about branches. I had no use for branches until I had to collaborate with people in college (college rant1).

Branches#

In git, you first create a branch, and then start working on it. So, I had to be careful of what branch I’m working in. Digital artists have the problem of “Working on the wrong layer”; I had the problem of “Working on the wrong branch”.

jj sidesteps the problem, you commit changes, and then assign the branch equivalent (bookmark) to those changes.

More on branches later in # Working with branches/bookmarks

Partially committing changes#

Part of keeping a good git history is only using atomic commits. I’d always get distracted and do more than one commit worth of work. So, I’d want to commit only certain files, and certain sections of the file. I never found out how to do that using the git cli. I always did it via my IDE.

NOTE

As far as partially committing changes goes, in git you can only stage some and not all files (and only parts of them by using git add -p IIRC) or you can use git gui.

~ ayumi

In jj, you can split a commit by using jj split. You then select the changes you want to commit in a TUI.

Working with the jj split TUI#

Down arrow to move the “cursor” down, and Up arrow to move the “cursor” up#

Right arrow to expand a section, and Left arrow to collapse the section#

In the image above, I’ve expanded the changes for TermErrors.yml file.

Currently, none of the changes are selected.

Selecting a change adds it to first commit, and the rest of the changes are in the second commit.

Let’s select the first change in TermsErrors.yml.

spacebar to select changes#

  1. Press Down arrow
  2. Press spacebar

You’ll notice there are three selection states:

  • [ ]: None of the children are selected.
  • [◐]: Children are partially selected.
  • [●]: All the children are selected.

c to confirm changes#

I use Helix (hx) in terminal, so, the commit messages are edited in hx.

JJ: This commit contains the following changes:
JJ:     M .vale-styles\RedHat\TermsErrors.yml

Only the changes I selected from TermsErrors.yml are in the commit.

Let’s check the status

jj st
NOTE

I use jj st is an alias for jj status in the cli. I use it so often, I instinctively type st instead of status.

Reading the status#

Let’s address the sections one by one.

  PS D:\Sync\Projects\personal-website-v5> jj st
+ Working copy changes:
  M .vale-styles\RedHat\TermsErrors.yml
  D src\content\posts\draft.md
  A src\content\posts\img.png
  A src\content\posts\img_1.png
  A src\content\posts\img_2.png
  A src\content\posts\img_3.png
  M src\content\posts\jj-my-beloved.md
  Working copy  (@) : omrruoov cb05513b (no description set)
  Parent commit (@-): lwymzvzm 1263aaec feat: reduce vale noise

What’s a working copy?

jj doesn’t have a staging area. It treats the current state of the repo as a commit, and when you edit the repo, you’re editing the commit.

  PS D:\Sync\Projects\personal-website-v5> jj st
  Working copy changes:
+ [M] .vale-styles\RedHat\TermsErrors.yml
+ [D] src\content\posts\draft.md
+ [A] src\content\posts\img.png
+ [A] src\content\posts\img_1.png
+ [A] src\content\posts\img_2.png
+ [A] src\content\posts\img_3.png
+ [M] src\content\posts\jj-my-beloved.md
  Working copy  (@) : omrruoov cb05513b (no description set)
  Parent commit (@-): lwymzvzm 1263aaec feat: reduce vale noise

You can notice that there are three prefixes for the files.

  • A: New file that will be added
  • D: File deleted
  • M: File modified
  PS D:\Sync\Projects\personal-website-v5> jj st
  Working copy changes:
  M .vale-styles\RedHat\TermsErrors.yml
  D src\content\posts\draft.md
  A src\content\posts\img.png
  A src\content\posts\img_1.png
  A src\content\posts\img_2.png
  A src\content\posts\img_3.png
  M src\content\posts\jj-my-beloved.md
+ Working copy  ([@]) : omrruoov cb05513b (no description set)
+ Parent commit ([@-]): lwymzvzm 1263aaec feat: reduce vale noise

There’s also @ and @-. “What are those?” you might ask.

@ is the current commit. This is the commit you’re editing.

@- is the previous commit.

Similarly, @+ is the next commit.

You can add multiple + or -.

Another way to think about the number of -(or +) is if @- is the “father” of the commit, then @--- refers to “grand-grandfather” of the commit.

There’s also two of what seem to be hashes.

- Working copy  (@) : 
+ [omrruoov] [cb05513b] (no description set)
- Parent commit (@-): 
+ [lwymzvzm] [1263aaec] feat: reduce vale noise

The first hash on each line (omrruoov and lwymzvzm) are change ID. These will remain persistent through changes.

NOTE

If you’re familiar with Mercurial’s “revset” terminology, the change ID is the ID for the revset.

For others, you can think of “revset” as a commit in git. It’s technically wrong, but I can’t think of a parallel in git.

You already know one revset: ”@”.

An interesting revset is root() which is a virtual commit, and all the commits are “descendants” of “root()”.

So, the first commit in the repo is root()+, and all the commits from root() to the current commit @ can be expressed as root()..@.

Revsets are really cool, read more about them on the docs here: https://jj-vcs.github.io/jj/latest/revsets/.

I will be using “revset” from here on where it’s appropriate as it will reduce confusion in the long term.

The second hash on each line (cb05513b and 1263aaec) are commit ID. These will change as you modify the commit.

Entering the full hash is inconvenient, and unless you have really good memory, you can’t memorize the hash. So, jj allows you to use the least number of letters from the hash that won’t cause any collisions, and highlights them. In this case om, cb, lw, and 1.

So, I can use om refer to commit with change ID omrruoov, and lw for the previous one.

Working with branches/bookmarks#

Bookmarks are what git calls branches.

While working in git, the best practice is to work in a separate branch, and then merge all the changes into the main branch. But you might’ve noticed I forgot to create a branch! If I was using git, I would be panicking now.

However, as discussed in # Branches, I don’t need to panic.

Let’s create the bookmark

jj bookmark create jj-my-beloved -r "@"
NOTE

I need to wrap the @ in double quotes as I’m using PowerShell.

From https://jj-vcs.github.io/jj/latest/windows/#typing-in-powershell:

PowerShell uses @ as part the array sub-expression operator, so it often needs to be escaped or quoted in commands:

The command seems fine till jj-my-beloved, and then there’s -r "@". What does that mean?

-r is shorthand for --revision. You pass the change ID or revset to it (tbh I assumed -r stood for revset). jj will then create a new branch, and the latest commit in the branch will be that revset.

@, as discussed before, is just shorthand for the current commit. So, the command will select the current working commit as the latest commit.

Pushing changes#

Let’s try pushing changes to the repo

jj git push -b "jj-my-beloved"
NOTE

Notice that the command is jj git... and not just jj...

That’s because jj was built with different backends in mind, where backend in this case refers to different VCS. So, you can add support for multiple VCS to jj. You aren’t just limited to git.

I would guess that if there are some backend-specific features, they will have the backend’s name in the command (like git here).

Here, we run into one of the many safeguards jj has.

In this case, the branch jj-my-beloved doesn’t exist in origin.

To allow jj to create a new branch you need to pass the -N/--allow-new flag.

So,

jj git push -b "jj-my-beloved" -N
WARNING

HARSH TRANSITION

I like to commit all my changes at the end of day.

NOTE

Or you know,

If I’m using git, I can’t just use the usual command of git commit. I would need to take aside time to ensure my git history is “good enough”. But with jj, I can do jj commit and end my work any time, and then deal with the messy history afterward.

I can just commit everything using the below command, and then worry about splitting and combining the changes tomorrow.

jj commit -m "checkpoint: EOD"
NOTE

If you didn’t notice -m is the same flag you use to add messages using git

NOTE

You can do the same with different branch:

git checkout -b tmp && git all -A && git commit -m "checkpoint: EOD"

or directly on your branch, if it’s just yours. Then just do git reset HEAD~.

~ Andrew

Squashing changes#

You might’ve noticed, I’m writing this blog post and showing you examples of how I use jj by showing screenshots of the commands I’m using. So, all the changes since the “EOD” commit haven’t been commited.

How can I be sure my changes aren’t commited? Let’s check the status.

jj st

It says (no description set), which means I haven’t described the change in the commit, but it doesn’t say (empty), which means the commit isn’t empty. You can interpret that as I have changes that I haven’t commited yet.

In git I think I would need to amend the changes. I never did it using the cli tho, always through my IDE.

In jj however, the command is

jj squash

Voilà!

Cleaning yesterday’s history mess#

Yesterday I used jj commit, so all the changes were commited, so now I’m editing a different commit.

When I commited, I accidentally commited the temporary images, and deleted draft.md.

Let’s revert those mistakes!

As mentioned in # Reading the status, you can just use the highlighted part of the change ID instead of typing the whole thing. So, from here-on, I’m going to use the shortened form.

jj split yx

I want to keep the changes to this file, so I need to add the file to first commit, and the rest will in the working commit.

I also need to undo deleting draft.md, let’s split the current change again, this time, draft.md will be in first commit.

Reverting commits#

There are two ways to undo the mistake in git AFAIK, git reset and git revert.

jj has similar commands.

  1. jj backout -r <revision>
    • Reverts the commit by creating a commit that cancels out the commit.
    • Reversible.
    • similar to git revert
  2. jj abandon -r <revision>
    • Just, straight up, delete the commit.
    • Irreversible.
    • similar to git reset --hard

In my case, I’d prefer to not touch the file. Since the “last updated date” on my posts are taken from the git history.

So, I’m going to use abandon.

jj abandon -r pp

Editing history#

This is where the power of jj comes through the most.

In # Partially committing changes, I only commited part of the TermsErrors.yml file.

Let’s fix that.

First I need to find the change ID for the commit.

Let’s use

jj log

The letters I need to enter are “lw”

Let’s commit the TermsErrors.yml file, so jj split.

I need to move the commit into “lw”

jj squash --from sx --into lw
NOTE

In # Reading the status I said that commit ID changes, and change ID remains the same.

You can see it here!

Notice how earlier, the commit ID for “lw” was “1”, and now it’s “24”.

Show the changes in commit#

Let’s see the changes in the revision/change “lw”

jj show lw

Oops, I forgot to remove the duplicated description from the commit message.

Let’s edit the commit message.

jj describe lw
NOTE

You can change it with git commit --amend hash. But only first, the rest through git rebase -i. I assume jj does this behind the scene anyway, because changing any commit will change the hash of the rest.

~ Andrew

Done!

NOTE

unlike git’s way of doing things, jj describe can be used any time.

In fact, jj commit -m "msg" is an alias for jj new followed by jj describe -m "msg".

Merging branches#

Another common operation is merging branches.

Let’s say I want to update the template for my website. For my setup, I have “origin” as my fork, and “upstream” as the original template.

Let’s fetch the changes from all remotes.

jj git fetch --all-remotes

Merging changes in jj is the same as creating a new commit with two parents. To use a different remote for a branch/bookmark, you need to write it in the form bookmark@remote.

jj new "@" "main@upstream"

Oh, no! So many conflicts!

Fortunately, conflicts feel easier to solve in jj for whatever reason.

Solving merge conflicts#

In jj, conflicts are first-class citizens. You can just, leave the conflicts as they as are if you need to.

The recommended way to resolve merge conflicts is to create a new commit, resolve the conflicts, and then squash the commit. This is called the Squash Workflow. More on that later in # Squash Workflow.

So, jj new.

jj also has an inbuilt conflict resolver, but I prefer to resolve them in my IDE.

You can use the inbuilt resolver by using

jj resolve

OK, now, the template is updated, I want to remove the images from git history, again ^^; I need to first change to the “rs” commit, and then split.

jj edit rs
jj split

Now, I have a problem.

I need to bring the images’ commit to latest commit.

Let’s think about what I need to do to achieve that.

  1. I need to rebase the commits.
  2. The change ID for the images’ commit is “sw”.
  3. The change ID for the latest commit is “wm”.
  4. I need to move the commit so “sw” is before “wm”.
jj rebase -r sw --insert-before wm

So what does the command do? It moves the revision “sw”, and insert it before “wm”.
Perfect!

Done!

That being said, I need to squash “wm” into “sw”, and then split again, as I’m editing the blog post live ^^; The changes from me writing into this document are in “wm”.

No problem, easy enough, just use split!

Case Study#

As you see, even with just a few commands, I can do so much. Let’s talk about a real world example where editing history came useful.

As I mentioned in # Reverting commits, I use git history to find the last updated date.

When I released Hyper-V shenanigans with nixos-generators, I ran the linter in my IDE and didn’t check what files it “touched”.

Turns out, it touched all the Markdown files, and so, the “last updated date” for all my posts became 2025-04-25.

It took me ~10 minutes to fix the mistake, where I spent a lot of time just fumbling commands.

While doing it, one of jj’s safeguards activated, preventing me from editing a commit that I have already pushed.

NOTE

git will stop you from pushing edited commits, but not editing them locally.

~ Andrew

It was an error like the image above.

I bypassed the safeguard by using --ignore-immutable flag, and then force pushed.

NOTE

jj has more safeguards like jj op log, jj undo.

But I don’t know how to use them yet ^^;

Squash Workflow#

The squash workflow is a general good practice.

It goes as follows:

  1. Create a new commit (do not edit the commit)
  2. Make the required changes
  3. Squash the changes into the old commit

jj can directly edit the commits, so why would you go through all this effort?

This workflow ensures that the previous commit remains as is, and if required, you can reset the changes. The changes also remain “atomic”, in the sense that either you fix the problem (by squashing), or you don’t. You won’t be stuck in a middle phase.

Links to learn more from experts!#

  1. Official docs
  2. The tutorial I followed by Steve Klabnik (@steveklabnik.com) that initially sold me on at least trying out jj
  3. An excellent essay by Chris Krycho (@chriskrycho.com) on jj
  4. An episode of Bits and Booze by GitButler where they discuss jj
  5. An article recommended by amos in goblin mode (@[email protected])
  6. Orhun Parmaksız (@orhun.dev)‘s stream VOD dedicated to learning Jujutsu on YouTube

Inspiration for writing the blog post#

Amos (also known as fasterthanlime) posting “jj is going to make me worse at git isn’t it”#

Link to my reply

Fediverse Link to Amos’ post

Bluesky Link to Amos’ post

If you want to read more about other people’s experiences, you can read them on fediverse, and bluesky.

A conversation on Typst’s discord server#

I recognized the person was using jj because of the distinct output for jj log, and started talking about Amos’ post, and how jj made life easier.

A random person replied to my message asking how Amos’ post can be taken in a negative light.

Misc#

jeez i had a variety of hacks in place to manage simultaeneous streams of work with git (branches, stashes, worktrees).

… and jj just has one tool: jj new. fewer concepts but so much more powerful.

— akshay (@oppi.li) May 10, 2025 at 10:34 PM


Post-writing discussion#

I asked ayumi if, after reading the blog post, she’s at least somewhat interested in jj.

Her reply:

Maybe once it’s finished—for example nowadays I mainly contribute to Tangara firmware and I need Git submodules support for that, which appears to be absent in jj. Some other things like SHA–256 Git repositories also appear not to work. I’m sure that those two things will be implemented eventually. I played with it a little and getting used to the different workflow would probably take some time in my case, though if I were to guess jj would probably be simpler to use for people who either didn’t use a VCS before or haven’t got used (or can’t get used) to Git yet.

Garnet chimed in and said that she uses git commit --amend very often, and ayumi concurred.

Garnet also commented:

I like how jj handles commit hashes (as in, allowing you to use the shortened hash) but on Git I don’t have a much of a problem either because the CLI supports tab completion also git has an interactive rebase feature git rebase --interactive it is a little confusing to me sometimes but I take it as a skill issue I’m not convinced that it’s worth switching to jj but that’s just my opinion

ayumi also had a “fun fact” about jj

if you set up commit signing and like me have the signature stored on a U2F key it will ask you to tap the key every time it sees changes.

By “it sees changes”, ayumi meant everytime jj is executed.

While trying out jj, ayumi noticed a problem, even three line jj st output was shown in the pager.

The solution she found was

jj config set --repo ui.pager 'less -F'

Another interesting thing,

Post by @[email protected]
View on Mastodon

Proofreaders#

Divyesh Patil

ayumi (also the comment in # Partially committing changes giving an idea of how to do it in git ayumi’s comment)

  • Opted out of sharing socials

Andrew (also multiple comments, 1, 2, 3)

Garnet

  • Opted out of sharing socials

Footnotes#

Footnotes#

  1. See, in Indian colleges, the “team” in “team project” is in name only. Most of the projects are either copied directly from GitHub, or from a YouTube video. One person does the “coding”; another does the documentation required for the project (which is just asking ChatGPT to write the docs), and the rest just exist. The number of people in a class who actually program are usually in single digits. If the stolen projects don’t run, they just go to the classmates who know, and then make them fix it, sometimes in exchange for money. So, I never really got the chance to do teamwork until last semester of third year, and last year. I even conducted git and GitHub workshops. But, I’m bad at explaining, so while I did make the slides, I left the explanation to my peers who were way better at teaching.

How Jujutsu VCS helps to lower the barrier to perfect git history
https://sakurakat.systems/posts/jj-my-beloved/
Published at
2025-05-04
License
CC BY 4.0