Though, I'd be remiss not to mention that this (and any other) jj workflow would be much easier with jjui. It's the best TUI around, not just for jj
I proposed incorporating some of this article into it. https://github.com/idursun/jjui/discussions/644
[1] https://docs.jj-vcs.dev/latest/cli-reference/#jj-parallelize [2] https://blog.chay.dev/parallelized-commits
If anyone is JJ-curious, I also can't recommend the Discord[1] enough. The community is very helpful and welcoming.
I'm still not as smooth at figuring out conflicts on mega-rebase.
There's some counterproductive stuff in there from my perspective but at its core you're keeping up a throwaway integration branch, which is helpful practice if you'll ever care about an integration. It's annoying with git because the interface for updating your throwaway integration branch is very clunky and easy to get wrong.
And I hope you do. It is so much better than git in every way. It enables working with stacks and the aforementioned megamerges so easily, allowing me to continue working forward while smaller units of work are reviewed/merged.
When I first tried to use jj, I wasn't entirely committed and switched between jj and git. Finally I hit a breaking point being fed up with stacks/merges and tried jj _for real_.
I recommend to give it a serious try for a few solid days and use it exclusively to really understand it. You won't go back.
The jj Discord is a very helpful place. Thanks to everyone there. Great article Isaac!
I imagine if I follow this workflow, I might accidentally split it off in a way that branch A is dependent on some code changes in branch B, and/or vice versa. Or I might accidentally split it off in a way that makes it uncompilable (or introduce a subtle bug) in one commit/branch because I accidentally forgot there was a dependency on some code that was split off somewhere else. Of course, the CI/CD pipeline/reviewers/self-testing can catch this, but this all seems to introduce a lot of extra work when I could have just been working on things one at a time.
I'm open to changing my mind, I'm sure there are lots of benefits to this approach, since it is popular. What am I missing here?
If none exist, I think there's a great opportunity there, for anyone with the knowledge and motivation to make some absolute beginner guides. Already jj is infinitely more user-friendly, and as the tool matures, it isn't far fetched to think a new generation of programmers could go straight to jj without knowing their way around git first.
IUUC This is already implemented for git as an extension. https://github.com/tummychow/git-absorb
I think this is such a basic thing that should be part of any DVCS implementation.
Eventually I settled on a tree-like megamerge that's more practical: merge 2 branches at a time and merge the merged branch with the next branch. This way I only need to handle 2-way conflicts at a time which is more manageable.
Also you have to be very careful to decide the order when you (and your colleagues) are going to land the branches, or if you expect any new features other people are working on that's going to conflict with your branches. When using megamerger workflow, most of the problems come from coordinating with other colleagues.
I just wish Jujutsu supported git tags rather than only supporting bookmarks as branches. And I also wish that Jujutsu supported preserving commit dates during rebases.
One of my absolute favorite things about Jujutsu is how easy it is to manipulate the commit graph remotely without having to manually checkout each commit first. I've been working on some pull requests to their built-in diff editor lately trying to improve the user experience enough that most conflicts will be fixable without having to use a text editor.
Also, the lack of a special staging area means you also never have to fucking stash your changes before you can do practically anything. Your changes always have a place, you can always go somewhere else and you can always come back.
I gather one scenario is: You do a megamerge and run all your tests to make sure new stuff in one branch isn't breaking new stuff in another branch. If it does fail, you do your debug and make your fix and then squash the fix to the appropriate branch.
When I have discrete, separate units of work, but some may not merge soon (or ever), being able to use mega merges is so amazing.
For example, I have some branch that has an experimental mock-data-pipeline thingy. I have yet to devote the time to convince my colleagues to merge it. But I use it.
Meanwhile, I could be working on two distinct things that can merge separately, but I would like to use Thing A while also testing Thing B, but ALSO have my experimental things merged in.
Simply run `jj new A B C`. Now I have it all.
Because jj's conflict resolution is fundamentally better, and rebases are painless, this workflow is natural and simple to use as a tool
There are commands for manipulating tags (jj tag set, jj tag delete), and recently [1] support for fetching / pushing
because agents are slow.
I use SOTA model (latest opus/chatgpt) to first flesh out all the work. since a lot of agent harness use some black magic, i use this workflow
1. Collect all issues 2. Make a folder 3. Write each issue as a file with complete implementation plan to rectify the issue
After this, i change from SOTA to Mini model
Loop through each issue or run agents in parallel to implement 1 issue at a time.
I usually need to do 3 iteration runs to implement full functionality.
I have a PR up for jjk that does the full change as a review changes, and there's another user's PR that allows diffs over arbitrary ranges (i.e. when working out whether the commits that make up a PR are good as a whole rather than individually)
Probably my favourite thing that has really changed my workflow is being able to write empty commits in advance then just switch between them. It helps me remember what I’m doing and whats next whenever I get distracted or take a break.
When LLMs are driving development, source control stops being an active cognitive concern and becomes a passive implementation detail. The unit of work is no longer “branches” or “commits,” it’s intent. You describe what you want, the model generates, refactors, and reconciles changes across parallel streams automatically.
Parallel workstreams used to require careful coordination: rebasing, merging, conflict resolution, mental bookkeeping of state. That overhead existed because humans were the bottleneck. Once an LLM is managing the codebase, it can reason over the entire state space continuously and resolve those conflicts as part of generation, not as a separate step.
In that world, tools like jj are optimizing a layer that’s already being abstracted away. It’s similar to how no one optimizes around assembly anymore. It still exists, it still matters at a lower level, but it’s no longer where productivity is gained.
what's next, "oh! my gitess"? "chainsvn man"?
It better be, now and going forward for people who use LLMs..because they will need it when LLM messes up and have to figure out, manually, how to resolve.
You ll need all the help (not to mention luck) you need then..
You're bashing the old way, but you do not provide any concrete evidence for any of your points.
> The unit of work is no longer “branches” or “commits,” it’s intent.
Insert <astronaut meme "always has been">.
Branching is always about "I want to try to implement this thing, but I also want to quickly go back to the main task/canonical version". Committing is about I want to store this version in time with a description of the changes I made since the last commit. So both are an expression and a record of intent.
> Parallel workstreams used to require careful coordination: rebasing, merging, conflict resolution, mental bookkeeping of state.
Your choice of words is making me believe that you have a poor understanding of version control and only see it as storage of code.
Commits are notes that annotates changes, when you want to share your work, you share the changes since the last version everyone knows about alongside the notes that (should) explain those changes. But just like you take time to organize and edit your working notes for a final piece, rebasing is how you edit commits to have a cleaner history. Merging is when you want to keep the history of two branches.
Conflict resolution is a nice signal that the intent of a section of code may differ (eg. one wants blue, the other wants red). Having no conflict is not a guarantee that the code works (one reduces the size of the container, while the other increase the flow of the pipe, both wanted to speed up filling the container). So you have to inspect the code and run test afterwards.
Discard the above if you just don't care about the code that you're writing.
This article is written both for intermediate Jujutsu users and for Git users who are curious about Jujutsu.
I’m a big Jujutsu user, and I’ve found myself relying more and more on what we in the JJ community colloquially call the “megamerge” workflow for my daily development. It’s surprisingly under-discussed outside of a handful of power users, so I wanted to share what that looks like and why it’s so handy, especially if you’re in a complex dev environment or tend to ship lots of small PRs.
In a hurry? Skip to the end for some quick tips.
If you’re an average Git user (or even a Jujutsu user who hasn’t dug too deep into more advanced workflows), you may be surprised to learn that there is absolutely nothing special about a merge commit. It’s not some special case that has its own rules. It’s just a normal commit that has multiple parents. It doesn’t even have to be empty
@ myzpxsys Isaac Corbrey 12 seconds ago 634e82e2
│ (empty) (no description set)
○ mllmtkmv Isaac Corbrey 12 seconds ago git_head() 947a52fd
├─╮ (empty) Merge the things
│ ○ vqsqmtlu Isaac Corbrey 12 seconds ago f41c796e
│ │ deps: Pin quantum manifold resolver
○ │ tqqymrkn Isaac Corbrey 19 seconds ago 0426baba
├─╯ storage: Align transient cache manifolds
◆ zzzzzzzz root() 00000000
Gotta put it all together!
You may be even more surprised to learn that merge commits are not limited to having two parents. We unofficially call merge commits with three or more parents “octopus merges”, and while you may be thinking to yourself “in what world would I want to merge more than two branches?”, this is actually a really powerful idea. Octopus merges power the entire megamerge workflow!
Basically, in the megamerge workflow you are rarely working directly off the tips of your branches. Instead, you create an octopus merge commit (hereafter referred to as “the megamerge”) as the child of every working branch you care about. This means bugfixes, feature branches, branches you’re waiting on PRs for, other peoples’ branches you need your code to work with, local environment setup branches, even private commits that may not be or belong in any branch. Everything you care about goes in the megamerge. It’s important to remember that you don’t push the megamerge, only the branches it composes.
@ mnrxpywt Isaac Corbrey 25 seconds ago f1eb374e
│ (empty) (no description set)
○ wuxuwlox Isaac Corbrey 25 seconds ago git_head() c40c2d9c
├─┬─╮ (empty) megamerge
│ │ ○ ttnyuntn Isaac Corbrey 57 seconds ago 7d656676
│ │ │ storage: Align transient cache manifolds
│ ○ │ ptpvnsnx Isaac Corbrey 25 seconds ago 897d21c7
│ │ │ parser: Deobfuscate fleem tokens
│ ○ │ zwpzvxmv Isaac Corbrey 37 seconds ago 14971267
│ │ │ infra: Refactor blob allocator
│ ○ │ tqxoxrwq Isaac Corbrey 57 seconds ago 90bf43e4
│ ├─╯ io: Unjam polarity valves
○ │ moslkvzr Isaac Corbrey 50 seconds ago 753ef2e7
│ │ deps: Pin quantum manifold resolver
○ │ qupprxtz Isaac Corbrey 57 seconds ago 5332c1fd
├─╯ ui: Defrobnicate layout heuristics
○ wwtmlyss Isaac Corbrey 57 seconds ago 5804d1fd
│ test: Add hyperfrobnication suite
◆ zzzzzzzz root() 00000000
Scary! Too much merge!
It’s okay if this sounds like a lot. After all, you know how much effort you put into switching contexts if you have to revisit an old PR to get it reviewed, among other things. However, this enables a few really valuable things for you:
Starting a megamerge is super simple: just make a new commit with each branch you want in the megamerge as a parent. I like to give that commit a name and leave it empty, like so:
jj new x y z
jj commit --message "megamerge"
Making megamerges. It's not so hard after all!
You’re then left with an empty commit on top of this whole thing. This is where you do your work! Anything above the megamerge commit is considered WIP. You’re free to split things out as you need to, make multiple branches based on that megamerge commit, whatever you want to do. Everything you write will be based on the sum of everything within the megamerge, just like we wanted!
Of course, at some point you’ll be happy with what you have, and you’ll be left wondering:
How you get your WIP changes into your megamerge depends on where they need to land. If you’re making changes that should land in existing changes, you can use the squash command with the --to flag to shuffle them into the right downstream commits. If your commit contains multiple commits’ worth of changes, you can either split it out into multiple commits before squashing them or (what I prefer) interactively squash with squash --interactive to just pick out the specific pieces to move.
# Squash an entire WIP commit (defaults to `--from @`)
jj squash --to x --from y
# Interactively squash part of a WIP commit (defaults to `--from @`)
jj squash --to x --from y --interactive
Hunk, I choose you!
Of course, Jujutsu is a beautiful piece of software and has some automation for this! The absorb command will do a lot of this for you by identifying which downstream mutable commit each line or hunk of your current commit belong in and automatically squashing them down for you. This feels like magic every time I use it (and not the evil black box black magic kind of magic where nothing can be understood), and it’s one of the core pieces of Jujutsu’s functionality that make the megamerge workflow so seamless.
# Automagically autosquash your changes (defaults to `--from @`)
jj absorb --from x
Ope, that was fast.
Absorbing won’t always catch everything in your commit, but it’ll usually get at least 90% of your changes. The rest are either easily squashable downstream or unrelated to any previous commit.
Conveniently, things aren’t much more complicated if I have changes that belong in a new commit. If the commit belongs in one of the branches I’m working on, I can just rebase it myself and move the bookmark accordingly.
jj commit
jj rebase --revision x --after y --before megamerge
jj bookmark move --from y --to x
Let’s break that rebase down to better understand how it works:
# We're gonna move some commits around!
jj rebase
# Let's move our WIP commit(s) x...
--revision x
# so that they come after y (e.g. trunk())...
--after y
# and become a parent of the megamerge.
--before megamerge
A little bit of rocket surgery, as a treat.
If I’ve started work on an entirely new feature or found an unrelated bug to fix, then it’s even simpler! Using a few aliases, I can super easily include new changes in my megamerge:2
There are also template aliases which let you change how Jujutsu logs to the terminal using the templating language, and fileset aliases, which act similarly to revset aliases but act on files instead of revisions using the fileset language.
[revset-aliases]
# Returns the closest merge commit to `to`
"closest_merge(to)" = "heads(::to & merges())"
[aliases]
# Inserts the given revset as a new branch under the megamerge.
stack = ["rebase", "--after", "trunk()", "--before", "closest_merge(@)", "--revision"]
Here’s a quick explanation of what closest_merge(to) is actually doing:
heads( # Return only the topologically tip-most commit within...
::to # the set of all commits that are ancestors of `to`...
& merges()) # ...that are also merge commits.
Using that revset alias, stack lets us target any revset we want and insert it between trunk() (your main development branch) and our megamerge commit:
jj stack x::y
Whoa, that was neat!
This is more useful if I have multiple stacks of changes I want to include in parallel; if it’s just one, I have another alias that just gets the entire stack of changes after the megamerge:
[aliases]
stage = ["stack", "closest_merge(@).. ~ empty()"]
closest_merge(@).. # Return the descendants of the closest merge
# commit to the working copy...
~ empty() # ...without any empty commits.
This one doesn’t require any input! Just have your commits ready and stage ‘em:
jj stage
Wait, what? You can do that?
The last missing piece of this megamerge puzzle is (unfortunately) dealing with the reality that is other people:
That’s a great question, and one I spent a couple months trying to answer in a general sense. Jujutsu has a really easy way of rebasing your entire working tree onto your main branch:
jj rebase --onto trunk()
Nice.
However, this only works if your entire worktree is your changes. When you try to reference commits you don’t own (like untracked bookmarks or other people’s branches) Jujutsu will stop early to protect them from being rewritten.3
Wait, not so nice. How do I do this?
Let’s fix that by rebasing only the commits we actually control. I struggled with this one for a while, but thankfully the Jujutsu community is awesome. Kudos to Stephen Jennings for coming up with this awesome revset:
[aliases]
restack = ["rebase", "--onto", "trunk()", "--source", "roots(trunk()..) & mutable()"]
roots( # Get the furthest upstream commits...
trunk()..) # ...in the set of all descendants of ::trunk()...
& mutable() # ...and only return ones we're allowed to modify.
Rather than trying to rebase our entire working tree (like jj rebase --onto trunk() tries to do), this alias only targets commits we’re actually allowed to move. This leaves behind branches that we don’t control as well as work that’s stacked on top of other people’s branches. It has yet to fail me, even with monster ninefold mixed-contributor megamerges! (Say that five times fast.)
There we go, that's better!
Jujutsu megamerges are super cool and let you work on many different streams of work simultaneously. Read the whole article for an in-depth explanation of how they work. For a super ergonomic setup, add these to your config with jj config edit --user:
[revset-aliases]
"closest_merge(to)" = "heads(::to & merges())"
[aliases]
# `jj stack <revset>` to include specific revs
stack = ["rebase", "--after", "trunk()", "--before", "closest_merge(@)", "--revision"]
# `jj stage` to include the whole stack after the megamerge
stage = ["stack", "closest_merge(@).. ~ empty()"]
# `jj restack` to rebase your changes onto `trunk()`
restack = ["rebase", "--onto", "trunk()", "--source", "roots(trunk()..) & mutable()"]
Use absorb and/or squash --interactive to get new changes into existing commits, commit and rebase to make new commits under your megamerge, and commit with stack or stage to move entire branches into your megamerge.4
# Changes that belong in existing commits
jj absorb
jj squash --to x --interactive
# Changes that belong in new commits
jj rebase --revision y --after x
# Stack anything on top of the megamerge into it
jj stage
# Stack specific revsets into the megamerge
jj stack w::z
Remember that megamerges aren’t really meant to be pushed to your remote; they’re just a convenient way of showing yourself the whole picture. You’ll still want to publish branches individually as usual.
I live in this constantly, and you can too.
Megamerges may not be everyone’s cup of tea – I’ve certainly gotten a few horrified looks after showing my working tree – but once you try them, you’ll likely find they let you bounce between tasks with almost no effort. Give them a try!
In Git, merge commits that contain new changes outside of conflict resolution are called an “evil merge”. Evil merges aren’t really “evil” in Jujutsu since it has a more consistent model than Git.5
Commit ID: b976b2a9c6ebbaada7fcd9d112a8390f2cb75b54
Change ID: tqxoxrwqqqtmxvywmzmspstupqqkskqk
Author : Isaac Corbrey <isaac@isaaccorbrey.com> (28 minutes ago)
Committer: Isaac Corbrey <isaac@isaaccorbrey.com> (24 minutes ago)
Parent : ttnyuntn storage: Align transient cache manifolds
Parent : qupprxtz ui: Defrobnicate layout heuristics
io: Unjam polarity valves
Added regular file two.txt:
1: # Sphinx of black quartz, judge my vow
Bubble, bubble, toil and trouble.
Definitely tangential, but I felt it necessary to mention. ↩
Aliases are a super powerful part of Jujutsu. There are two types you should look into: revset aliases, which allow you to create custom functions which return one or more commits with the revset language, and command aliases, which let you extend Jujutsu’s default functionality and add your own. ↩
Jujutsu has a concept of mutable and immutable commits, which basically dictates what commits you’re allowed to modify on a normal basis. It’s largely just a lint since you can override this with --ignore-immutable, but it’s good at keeping you out of trouble. You can use the mutable() and immutable() aliases to select only mutable and immutable commits respectively. ↩
If restack doesn’t quite work the way you like, try incorporating this config from Austin Seipp. My default setup restacks every mutable commit in your repo, which behaves poorly when you have lots of mutable branches from the past you haven’t had time to clean up yet.
[revset-aliases]
'stack()' = 'stack(@)'
'stack(x)' = 'stack(x, 2)'
'stack(x, n)' = 'ancestors(reachable(x, mutable()), n)'
[aliases]
restack = ["rebase", "--onto", "trunk()", "--source", "roots(trunk()..) & stack()"]
Thanks for the tip Cole! ↩
Thanks to Andrew Hoog for helping me figure out footnotes in Astro. Did you know that you can reference footnotes from other footnotes? ↩