Git is so deeply embedded in how we work that its rough edges start to feel like facts of life. The staging area, the fear of rewriting history, the stash dance before switching branches, the merge conflict that grinds everything to a halt. Jujutsu — or jj — is a version control system that takes a hard look at those rough edges and asks: does it have to be this way?
The answer, it turns out, is no. jj isn't a radical reinvention — it's a disciplined redesign. It keeps what's great about Git (the distributed model, the commit graph, full GitHub/GitLab compatibility) and replaces the parts that have always been awkward with something more coherent. And as our workflows grow more demanding — particularly with AI agents now writing and managing code alongside us — that cleaner foundation is starting to matter a great deal.
This article is for developers who already know Git and want to understand what jj actually offers. Not a command cheatsheet — a genuine look at where jj's model pays off. Think of it as your white belt.
Getting Started: jj Layers on Top of Git
The first thing to know: jj is not a replacement ecosystem. It's a separate CLI tool that uses a Git repository as its storage backend. Your history lives in Git objects. Your remotes are Git remotes. Your colleagues using plain Git are unaffected.
You can install jj via Homebrew on macOS, Cargo if you're in the Rust ecosystem, or download a binary directly from the jj releases page. On macOS:
brew install jj
Adopting it in an existing repo is then a single command:
jj git init --colocate
This adds a .jj folder alongside your existing .git folder. That's it. The .jj folder holds jj's own metadata — its operation log, working copy state, and repo-level config — but the actual content stays in .git. Crucially, jj keeps .jj out of Git's way by writing a .gitignore inside the folder itself, rather than touching your project's root .gitignore, so your ignore file stays clean and your teammates never see a thing.
From this point on, you can use jj commands alongside or instead of Git commands. Both work on the same repository.
The Log: Your First Window Into jj
Before diving into workflow, it's worth seeing how jj presents your history. Running jj log out of the box gives you something like:
@ mykqwroo bruno@git-tower.com 2026-06-08 15:07:41 my-feature a1b2c3d4
│ add login validation
○ kkzrwqpo bruno@git-tower.com 2026-06-07 09:14:22 main b2c3d4e5
│ update README
○ nnpqvstu bruno@git-tower.com 2026-06-06 16:33:01 c3d4e5f6
│ initial commit
~
The @ marks your current working-copy commit. No flags needed — jj's default log template is already more readable than git log --oneline --graph. You see the relevant context immediately.
Notice those short alphabetic strings like mykqwroo. Those are change IDs — jj's own commit identifiers, entirely separate from Git's SHA hashes. They matter a lot, and we'll come back to them.
A New Mental Model
jj keeps everything structural about Git — the commit graph, the distributed model, the remotes — and rethinks two fundamentals instead: what your working copy actually is, and how commits are identified. Almost everything that feels different downstream traces back to these two ideas.
The Working Copy Is Always a Commit
In Git, there's a three-way distinction between your working directory, the staging area (index), and a commit. You edit files in the working directory, selectively add them to the index with git add, and then commit what's staged.
jj collapses this. There is no staging area. Your working copy is always, automatically, a commit. As you edit files, jj snapshots them continuously. You're never in an uncommitted state — you're always on a commit, it just might not have a description yet.
This means there is no jj add. You simply edit files and jj tracks the changes. When you're ready to describe what you've done:
jj describe -m "add login validation"
Or, if you want to describe the current change and immediately start a new empty one — the closest equivalent to Git's commit-and-move-on:
jj commit -m "add login validation"
# shorthand for: jj describe -m "..." && jj new
And if you want to start fresh work without describing first — say, you're mid-feature and want to capture a checkpoint:
jj new
This seals the current commit and starts a new working-copy commit on top. If no description was provided, jj marks it as (no description set) in the log — it's still a fully tracked commit, just unnamed. Your in-progress work is never lost — it's just a commit without a name yet.
The practical impact of this model is hard to overstate. The staging area was always a source of subtle bugs (git add -A vs git add ., forgetting to stage a file before committing, the confusion of what's staged vs what's not). In jj, that entire class of problem simply doesn't exist.
Change IDs: References That Survive Rewrites
In Git, every commit has a SHA hash derived from its content and its parent. Amend a commit, and you get a new hash. Rebase a branch, and every commit in it gets a new hash. This makes scripting and referencing commits fragile — the identity of a piece of work changes every time you reshape it.
jj introduces change IDs: stable, alphabetic identifiers assigned when a commit is first created and kept constant through any number of rewrites. Amend, rebase, squash — the change ID stays the same.
jj edit mykqwroo # works before and after a rebase
jj edit @- # relative: parent of current commit
jj edit @-- # grandparent
jj edit main # by bookmark name
The @ symbol means "current working-copy commit," and relative navigation with - suffixes lets you move through history without looking up identifiers at all.
Git hashes still exist — jj shows both in jj show — but they're treated as secondary. Day-to-day, you reference commits by change ID, bookmark name, or relative position.
A Calmer Everyday Workflow
Those foundations quietly reshape the day-to-day. Branching, switching tasks, and undoing mistakes — the everyday moments where Git tends to introduce ceremony or risk — all get noticeably calmer.
Bookmarks: Branches Without the Obligation
In Git, you're always on a branch. HEAD points to a branch, the branch moves with every commit, and switching context means committing, stashing, or losing work.
jj has bookmarks — pointers that work like Git branches — but you're not required to be on one. Commits exist freely in the graph, and you attach a bookmark name when you actually need one, typically when you're ready to push.
The workflow will feel familiar, with one key difference: in Git you declare a branch before you start working; in jj you name it at the end, when you're ready to push. Everything in between looks much the same:
# ...edit files...
jj commit -m "add login validation"
# ...edit more files...
jj commit -m "fix edge case"
jj bookmark create my-feature -r @- # name the branch when ready to push
jj git push --bookmark my-feature
Note that jj commit handles staging and committing in one step — there is no git add. We point the bookmark at @- (the parent) because after a jj commit your working copy is a fresh, empty commit sitting on top — @- is the last commit that actually holds work. Otherwise the flow is the same: a stack of commits, a named branch, a push. The PR workflow on GitHub works identically from here.
When your branch is ready to merge locally, you have two options. For a fast-forward:
jj rebase -b my-feature -d main # rebase onto latest main
jj bookmark set main -r my-feature # move main pointer forward
jj git push --bookmark main
Or for a proper merge commit:
jj new main my-feature # new commit with two parents = merge commit
jj bookmark set main -r @ # move main to the merge commit
Note that jj new accepts multiple parents — that's how merges are created. No dedicated merge command.
Switching Context Without the Stash Dance
Here's where the "working copy is always a commit" model pays off most visibly day-to-day.
In Git, switching branches with uncommitted work is a problem. You stash, switch, do your work, switch back, pop the stash, hope for no conflicts. It's tedious and error-prone.
In jj, there is no dirty working copy. Everything is always committed. If you need to switch to something else:
jj edit kkzrwqpo # jump to any commit in the graph
Your in-progress work stays exactly where it was — a commit sitting in the graph. Come back to it with another jj edit. No stash, no ceremony.
You can also maintain multiple in-flight workstreams simultaneously using workspaces — jj's equivalent of Git worktrees:
jj workspace add ../hotfix-workspace
Each workspace gets its own working-copy commit, completely isolated. Switch between them freely. Nothing bleeds across.
Undo Everything
If you use Tower, you already know the comfort of ⌘ + Z (orCTRL + Z on Windows) to undo your last Git action. jj takes this idea further, building undo into the version control model itself.
Every jj action — rebase, amend, describe, restore, even a failed command — is recorded in the operation log:
jj op log
@ abc123def bruno@bruno-mbp 2026-06-08 10:42:05 - 2026-06-08 10:42:05
│ rebase commits
│ args: jj rebase -b my-feature -d main
○ def456abc bruno@bruno-mbp 2026-06-08 10:41:30 - 2026-06-08 10:41:30
│ describe commit mykqwroo
│ args: jj describe -m "add login validation"
○ ghi789def bruno@bruno-mbp 2026-06-08 10:40:12 - 2026-06-08 10:40:12
│ new empty commit
~
Made a mistake?
jj undo # undo the last operation
jj op restore abc123 # go back to any specific point in op history
This is meaningfully different from git reflog. The reflog tracks commit pointer movement. The operation log tracks every structural action — including rebases, squashes, and workspace changes — and makes all of them reversible. It's a safety net that covers ground no Git command reaches.
Reshaping History Without Fear
Git's interactive rebase is powerful but intimidating. A text editor opens with a list of cryptic commands, one wrong edit can corrupt your history, and there's no obvious way to recover if you make a mistake. As a result, many developers avoid history editing entirely.
jj replaces the interactive rebase ceremony with dedicated, composable commands — each reversible with jj undo.
Splitting a Commit
You've been heads-down and realize your commit is doing two unrelated things. In Git, this means git rebase -i and a careful dance of edit, reset, and re-commit. In jj:
jj split
An interactive hunk selector opens. You pick which changes belong in the first commit; the rest automatically become a second commit on top. What makes this particularly powerful: you can split any commit in history, not just the most recent one:
jj split -r mykqwroo
jj rebases any descendants automatically.
Squashing Commits
The inverse — folding one commit into its parent:
jj squash # squash working copy into parent
jj squash -r mykqwroo # squash a specific commit into its parent
jj squash --interactive # pick specific hunks to move, leave the rest
The --interactive flag uses the same hunk selector as jj split. You can move parts of a commit up and leave the rest in place — something that requires several steps in Git.
Abandoning Commits
To discard a commit entirely:
jj abandon mykqwroo
If the abandoned commit has descendants, jj automatically rebases them onto the abandoned commit's parent. Nothing is left dangling. And since it's an operation, jj undo brings it straight back.
Conflicts as a State, Not a Blocker
This is perhaps the most fundamental departure from Git's model.
In Git, a merge conflict is a hard stop. The operation halts, you must resolve every conflict before you can continue, and until you do, your repository is in a suspended state. If you're mid-rebase with ten commits and hit a conflict on the third, you're stuck until it's resolved.
jj treats conflicts differently: they're a state a commit can be in, not a blocker. When a conflict occurs during rebase or merge, jj records it inside the commit and keeps going:
jj rebase -b my-feature -d main
# jj notes conflicts but does not stop
# all commits land in the graph, some marked as conflicted
You can inspect which files have conflicts and resolve them when you're ready:
jj resolve --list # list all conflicted files
jj resolve # open your configured merge tool (VS Code, vimdiff, etc.)
Once you save in the merge tool, jj detects the resolution automatically. No git add to mark files resolved, no git rebase --continue to type.
The deeper implication: you can rebase an entire stack of commits through a conflict, keep all of them in the graph, and resolve everything in one session later. This is not just convenient — it changes how you think about integrating parallel work.
jj in the Age of AI Agents
The properties that make jj better for individual developers turn out to make it particularly well-suited for the agentic workflows that are becoming standard today.
Parallel Agents, Isolated Workspaces
When running multiple AI coding agents simultaneously — each working on a different feature or task — the standard Git approach is worktrees: separate directories checked out from the same repo. jj's workspaces work the same way, but with one key advantage. In Git, two worktrees cannot share the same branch. In jj, since commits are independent of bookmarks, multiple workspaces coexist freely with no restrictions:
jj workspace add ../agent-auth
jj workspace add ../agent-ui
jj workspace add ../agent-tests
Each agent operates in its own workspace, editing its own working-copy commit, with no risk of collision.
Stable References for Orchestrators
A recurring problem in AI-driven workflows: an orchestrator or script tracks a commit by its hash, a rebase or amend happens, and the reference breaks. You're left chasing a commit that has technically vanished.
jj's change IDs solve this. An orchestrator can record the change ID of a piece of work at the start and reference it reliably through any number of rewrites — squashes, rebases, amends — without ever losing track:
# record the change ID when the agent starts work
CHANGE_ID=$(jj log -r @ --no-graph -T 'change_id')
# later, after multiple rewrites, still works
jj show $CHANGE_ID
Async Conflict Resolution
When multiple agents produce conflicting changes, Git's model requires one agent to block and resolve before the other can continue. jj's first-class conflict handling changes this dynamic entirely.
Conflicting agent outputs can land in the graph as conflicted commits, without halting either agent's workflow. An orchestrator — or a human — can then inspect the conflicts at a natural pause point and resolve them in batch:
jj resolve --list # see everything that needs attention
jj resolve # work through them one by one
This maps much more naturally onto asynchronous, parallelized workflows than Git's synchronous conflict model.
Caveats & Closing Thoughts
jj is genuinely impressive, but it's worth being clear-eyed about where it stands today. After all, jj has a bit of a learning curve — but so does any martial art.
A Few Honest Caveats
The CLI is not yet stable. Command names and flags can change between versions. A notable example: what was previously called branches was renamed to bookmarks, turning jj branch create into jj bookmark create overnight — breaking existing guides, scripts, and muscle memory in one go. The jj team is thoughtful about this — changes tend to be improvements — but it means you may occasionally need to update muscle memory or scripts.
The GUI ecosystem is thin. jj's terminal output is excellent, and a handful of dedicated tools have appeared — GG (a cross-platform GUI with a visual graph), terminal UIs like Lazyjj and jjui, and a VS Code extension — but the tooling is nowhere near as mature as Git's. Since jj uses a Git backend, Git GUIs can read the repository, though they won't understand jj-specific concepts like the operation log.
AI coding tools are Git-first. Cursor, Copilot, and most agentic tools are built around Git commands. jj's theoretical advantages for agentic workflows are real, but the ecosystem hasn't caught up yet. You'd be working around some tooling assumptions.
None of this should be dealbreakers, especially given how easy adoption is. The colocated setup means you can try jj without any commitment — use it on your own machine, keep your team on Git, and flip between the two whenever you need.
Closing Thoughts
jj doesn't ask you to abandon what you know about Git. The concepts translate — commits, branches, remotes, merges — and the underlying storage is Git itself. What changes is the model: a working copy that's always a commit, change IDs that survive rewrites, conflicts that don't stop the world, history that's safe to reshape.
For developers who've ever stashed work to switch branches, lost a rebase to a bad conflict resolution, or hesitated before amending a commit in shared history — jj makes those specific moments noticeably less stressful. Not through new features, but through cleaner foundations.
It's worth an afternoon. The install is one command, the undo is always there if you need it, and you might find yourself wondering why we ever settled for the staging area in the first place. Give it a try — you might not look back.
For more on version control workflows and Git best practices, sign up for our newsletter below and follow Tower on Twitter / X and LinkedIn! ✌️
Join Over 100,000 Developers & Designers
Be the first to know about new content from the Tower blog as well as giveaways and freebies via email.