I’ve really been enjoying Jujutsu (jj) for source control over the past several months. The ability to easily move amongst commits, move commits around, and rebase with abandon is great, and is akin to the power of Fig (a Mercurial descendent) that I used extensively at Google. But until recently one feature has been mystical, powerful, unknowable… Jujutsu workspaces. Try as I might, I couldn’t find decent examples or posts about how to use them effectively. I want to demystify workspaces for you today.
tl;dr: What are jj workspaces?
Jujutsu’s workspaces are copies of your repo that are completely independent in the filesystem, but which are linked and share commits and history among each other.
Why would this be useful? This lets you work on multiple branches of code concurrently, without needing to change commits or bookmarks. This setup enables workflows like the following:
- Running long-running tests in one workspace while changing code in another (comes from the jj docs)
- Comparing files across commits with tooling that doesn’t natively support jj.
- Giving AI agents their own sandbox to work in when doing parallel feature development with multiple agents (so hot right now!)
They are similar to Git worktrees of course, which are just now becoming more commonly used too, but read later for a summary of the differences.
Creating and using workspaces
I’ll walk us through some very simple examples to show how workspaces interact with each other in practice. We’ll create a barebones repository, modify some files over several revisions, and see how the workspaces interact. Feel free to follow along in your own terminal to see it in action.
We’ll start with a simple, empty repository.
~$ mkdir jjworkspaces ~$ cd jjworkspaces ~/jjworkspaces$ jj git init
Now let’s add a text file called a.txt and put some content in it:
~/jjworkspaces$ echo "This is the first file." > a.txt
~/jjworkspaces$ jj describe -m "Creating base with file A"
~/jjworkspaces$ jj
@ tsyosozn jlyman b15fdc46
│ Creating base with file A
◆ zzzzzzzz root() 00000000
Great, now we have a base file called a.txt, and we’re ready to start the fun stuff! Let’s make a new workspace to set up a parallel track to the default repository.
~/jjworkspaces$ jj new
Working copy (@) now at: ormssprp a91d0eb8 (empty) (no description set)
Parent commit (@-) : tsyosozn b15fdc46 Creating base with file A
~/jjworkspaces$ jj workspace add ../feature-b
~/jjworkspaces$ jj
@ ormssprp jlyman default@ a91d0eb8
│ (empty) (no description set)
│ ○ kvztmzuz jlyman feature-b@ 95e61d14
├─╯ (empty) (no description set)
○ tsyosozn jlyman b15fdc46
│ Creating base with file A
◆ zzzzzzzz root() 00000000
Pro tip: don’t forget to
jj newbefore you create the new workspace, so that the parent of the new workspace is common to the last commit you made.
Pretty important note that I messed up the first few times I was experimenting with workspaces: they should live as siblings to the main repo in the filesystem. That’s why we use
../feature-bin the command above. When you create a new workspace you specify its destination. You can optionally give it a name too with--name, but the destination is the only required bit. Leaving off the name will default it to the name of the directory/destination you assign it. If you don’t, jj will warn you because you’ll end up in a weird place where you have a copy of a repo inside a repo.
Cool! Now we have a default@ workspace, and a new feature-b@ workspace. They are two sibling directories. Run a jj log in the main directory, and you’ll see it showing the commit status and working copy of the repo, along with the tree of the feature-b. If you cd ../feature-b and jj log there, you see the same thing, just with the difference that the active commit is the one on the feature-b@ tree.
Let’s make a change in each directory.
# From the default workspace:
~/jjworkspaces$ echo "This is a second file, B, created on default@" > b.txt
~/jjworkspaces$ jj describe -m "Add file B on default"
~/jjworkspaces$ cd ../feature-b
# Now in the feature-b workspace
~/feature-b$ echo "File C, created on feature-b@" > c.txt
~/feature-b$ jj describe -m "Add file C on feature-b"
~/feature-b$ jj
@ syxoxwqu jlyman feature-b@ 02745a4d
│ Add file C on feature-b
│ ○ zzkzosxy jlyman default@ 97192234
├─╯ Add file B on default
○ lkzptmqw jlyman 95519f27
│ Creating base with file A
◆ zzzzzzzz root() 00000000
This is all about as we’d expect. Our default workspace has the commit tree with files a.txt and b.txt, and our second workspace has files a.txt and c.txt. You can confirm this by looking at the filesystem.
What’s interesting is that we can see all changes in all workspaces at once. That’s powerful, because we don’t lose context of work progress in other workspaces. Even cooler, with jj we can move these commits around among workspaces, as if they were nothing. Let’s do that with our c.txt file.
~/feature-b$ jj rebase -s w -d m
~/feature-b$ jj
@ syxoxwqu jlyman feature-b@ 565bdce4
│ Add file C on feature-b
○ zzkzosxy jlyman default@ 97192234
│ Add file B on default
○ lkzptmqw jlyman 95519f27
│ Creating base with file A
◆ zzzzzzzz root() 00000000
Now take a look, and we’re on a singular tree, with the feature-b@ now ahead of the default@ workspace. If we ls we can see that we have files a, b, and c all present.
If you switch into the default directory, you’ll only see a and b, and that it is a commit behind the second workspace.
Do we need to “merge” the second workspace into our default one? Not really, we just did! When we rebased the commit on top of the default workspace’s commit, it placed everything in one common line. Remember, with jj all workspace commit histories are visible and available for modification, even if they live in different physical directories. If we want to advance our default workspace to be effectively at HEAD, we just catch ourselves up to that revision.
~/feature-b$ cd ../jjworkspaces
~/jjworkspaces$ jj edit s
~/jjworkspaces$ jj
@ syxoxwqu jlyman default@ feature-b@ 565bdce4
│ Add file C on feature-b
○ zzkzosxy jlyman 97192234
│ Add file B on default
○ lkzptmqw jlyman 95519f27
│ Creating base with file A
◆ zzzzzzzz root() 00000000
At this point, we now see that both our default@ and feature-b@ are pointing to the same revision. Both directories will also have the exact same contents. At this point, we can get rid of feature-b@:
~/jjworkspaces$ jj workspace forget feature-b
~/jjworkspaces$ rm -r ../feature-b
All cleaned up! Let’s dive deeper. What about conflicting changes across two workspaces?
~/jjworkspaces$ jj new -m "Change file C in default"
~/jjworkspaces$ jj workspace add ../feature-c
~/jjworkspaces$ echo "\nNew line in default" >> c.txt
~/jjworkspaces$ cd ../feature-c
~/feature-c$ echo "\nAnd this line was created in feature-c." >> c.txt
~/feature-c$ jj describe -m "Creating conflicting change in another workspace"
Here we’ve generated two lines that will conflict in c.txt whenever we go to rebase the revisions. Let’s find out what happens.
~/feature-c$ cd ../jjworkspaces
~/jjworkspaces$ jj rebase -s k -d u
Rebased 1 commits to destination
New conflicts appeared in 1 commits:
ktonvlwn f6352ad4 (conflict) Creating conflicting change in another workspace
Ah ha! Just what we’d hope: Jujutsu’s typical conflict handling kicks in, and asks us to resolve the conflict. Resolve it however you’d like (jj resolve, vim, etc.) and as soon as you do, you’ll be in fine shape again. Note that, depending on how you resolve it, this will likely just change the current commit–there is no conflict resolution or merge commit. If you needed to emulate that behavior, you could do so by new-ing up a new commit first and doing the conflict resolution there. That approach can have merit, especially if it’s a complicated merge. But that will not happen by default.
Once again, let’s clean up the additional workspace:
~/jjworkspaces$ jj workspace forget feature-c
~/jjworkspaces$ rm -r ../feature-c
What’s the difference between jj workspaces and Git worktrees?
They definitely feel very similar. The biggest difference is that a Git worktree is always explicitly tied to a branch, and everything that goes with it. You cannot move revisions around as freely from branch to branch as you can in jj. You have to merge the branch/worktree back in to the main workspace, following the normal Git merge process. You can’t have two worktrees for the same branch–you’d have to create a dummy one if you wanted to work on the same revision.
None of those are necessarily “bad” things at all, they are just the Git way of doing it. To be honest, you can probably equate both of them to the same concept in practice and be just fine. They are technically different, but in reality are accomplishing much the same work, using their respective paradigms.
Equivalents in Googleland and Git
As a SWE at Google, I loved working with Piper, CitC, and Fig in a trunk-based development like approach. (For a great article on Google’s VCS, see “Why Google stores billions of lines of code in a single repository” by Rachel Potvin and Josh Levenberg.) I worked on two primary products, but I’d regularly have 3-7 different streams in each, each for a different feature, that I’d work on in isolation. With one command you spin up a copy of the entire Google monorepo in a new CitC client, which gives you complete filesystem isolation, and merge in any changes from HEAD super easily.
Jujutsu workspaces are the closest equivalent to a new CitC client I can find in the non-Google world. It works great for me. In fact, it’s even a little easier, because I don’t have to get revisions merged into HEAD before I can absorb them into another client/workspace. It’s also more powerful than a Git branch because it is still part of the revision tree with every other workspace, while still maintaining its independence.
Overall, once I was able to work past the mystery (and lack of decent examples!) of jj workspaces, I’m quite happy with where I’ve landed, and hope they are useful to you, too.

Be First to Comment