How to Use Jujutsu to Help Me Have a Bunch of PR and a Megamerge Named Mine on Top of Git
Fleetingsetup
colocate jj on top of an existing git repo
jj git init --colocate
init a fresh colocated repo
jj git init --colocate myrepo && cd myrepo
config
mine_deps() resolves to all bookmarks except mine itself and trunk,
i.e. the individual PR branches. The set subtraction operator ~ makes
it update automatically as bookmarks are added or removed.
[revset-aliases]
"mine_deps()" = "bookmarks() ~ (mine | trunk())"
"dangling()" = "all() ~ (ancestors(bookmarks() | remote_bookmarks() | tags() | @) | root())"
update-mine rebases the mine merge commit so its parents are exactly the
current PR branches. Running it after any rebase or bookmark change keeps
the mega-merge current without manual surgery.
[aliases]
update-mine = ["rebase", "-r", "mine", "-o", "mine_deps()"]
all = ["logs", "-r", "all()"]
watch = ["util", "exec", "--", "bash", "-ec", "watchexec --quiet --clear --restart --watch=$JJ_WORKSPACE_ROOT/.jj/repo/op_heads/heads --wrap-process none -- jj log"]
cleandangling = ["abandon", "-r", "dangling()"]
When I’m reading of a git history, I may want to see ALL the commits, not the few that have bookmarks.
all = ["logs", "-r", "all()"]
watch = ["util", "exec", "--", "bash", "-ec", "watchexec --quiet --clear --restart --watch=$JJ_WORKSPACE_ROOT/.jj/repo/op_heads/heads --wrap-process none -- jj log"]
you want to fix a bug and submit it upstream
Clone the repo. jj creates a repository backed by a git store, so git remotes are natively understood and immediately available:
jj git clone https://github.com/org/repo
cd repo
In jj you don’t create a bookmark upfront1. After cloning, jj has already created an empty working-copy commit on top of trunk — you are already in a new commit. jj records every file change automatically into the current commit, there is no staging area. Just give it a description and start editing:
jj describe -m "fix: login timeout"
If the message needs rewording, jj describe edits it in place without
creating a new commit. Without -r it rewrites the working-copy commit (@);
with -r it targets any other commit:
jj describe -r <change-id>
When the fix is ready, this is the moment to give it a name2. Since you
are still on that commit, no revision flag is needed — jj bookmark set
defaults to @. Pushing a new bookmark for the first time automatically
tracks it against the remote:
jj bookmark set fix-login-timeout
jj git push --bookmark fix-login-timeout
For a single-commit PR, jj git push --change @ is a shortcut that creates,
tracks, and pushes a bookmark in one command — note the generated name is an
auto-prefix like push-vruxwmqv, not the human name you chose above.
you are juggling several features at once and want a single branch to test them all together
You are already in the repo. Start each feature as a new commit off trunk — no bookmarks yet, jj tracks commits by change ID regardless:
jj new trunk() -m "feat: dark mode"
You realize you need more commits on top of that one. jj new creates a child
commit. Since jj bookmarks don’t move automatically, after adding work you have
to slide the bookmark forward yourself — but only when you’re ready to share:
jj new -m "wip: handle system theme preference"
Want to go back and amend an earlier commit instead of stacking? jj edit
puts your working copy directly on any existing commit, no stashing:
jj edit <change-id>
Once you have several independent commits in flight, name each one before
merging — jj new with multiple parents takes revsets, and bookmark names
are the clearest way to refer to them:
jj bookmark set feat-dark-mode -r <change-id-of-dark-mode>
jj bookmark set fix-login-timeout -r <change-id-of-login-fix>
jj bookmark set refactor-api-client -r <change-id-of-refactor>
Then create mine as a merge of all of them in one shot:
jj new feat-dark-mode fix-login-timeout refactor-api-client -m "mine"
jj bookmark set mine
Push mine to your remote to run CI against everything at once, without opening a PR from it. Pushing a new bookmark for the first time automatically tracks it against the remote:
jj git push --bookmark mine
When trunk moves, rebase fallen-behind PR branches. To rebase a single one:
jj rebase -s fix-login-timeout -o trunk()
To rebase all independent stacks between trunk and mine at once:
jj rebase -s 'roots(trunk()..mine)' -o trunk()
Do not rely on jj auto-moving mine as a descendant — rebasing a merge commit
this way can flatten its parent structure. Always run jj update-mine
afterwards: it uses -r (move only mine itself, not its descendants) to
rebuild mine’s parents from scratch using the current mine_deps() set.
Make sure @ is not sitting on mine when you run it:
jj update-mine
When you’re happy with a feature, name it and push it as a proper PR. At this
point the working copy is on mine, so an explicit -r is required to avoid
pointing the bookmark at the wrong commit:
jj bookmark set feat-dark-mode -r <change-id-of-dark-mode>
jj git push --bookmark feat-dark-mode
you need to drop everything and switch context without losing your work
Something urgent comes in while you are mid-work on dark mode. In git you’d stash or create a branch. In jj, the working copy is already a commit3 — just leave it and look at what you have:
jj log
jj tracks all visible heads of the commit graph, so they won’t be
garbage-collected4. Note that the default jj log shows only a filtered
subset — run jj log -r 'all()' to see every commit in the repo, including
those not reachable from any bookmark or from @. jj has no concept
of a “current branch”5 — your working copy simply sits on a commit, and
you move between commits freely. jj edit moves the working copy to an
existing commit so that subsequent file edits amend it directly. jj edit
itself does not rebase anything; if you do modify the commit, jj then
automatically rebases its descendants on top of the updated version:
jj edit <change-id-of-urgent-fix>
Or create a fresh commit off trunk for the urgent work (jj new trunk() is
appropriate here since you are already inside an existing repo and may not be
sitting on trunk):
jj new trunk() -m "fix: critical crash on startup"
When the urgency is resolved, come straight back to the dark mode work:
jj edit <change-id-of-dark-mode-work>
Commits stay in the log until you explicitly abandon them. The one exception:
the working-copy commit is silently discarded when you navigate away from it
if it is both empty and has no description. A placeholder with a message (e.g. jj new -m "wip: ...")
is always kept. jj abandon hides the commit and rebases its children onto
its parents. To undo the abandon immediately, jj undo is the idiomatic tool.
For an operation further back, jj op log shows the history and jj op restore <id> jumps to any prior state. The data is truly gone only after jj util gc runs (default retention: two weeks):
jj abandon <change-id>
your new feature depends on a colleague’s PR that is not yet merged
Rather than waiting, fetch their work first so the bookmark is available locally, then stack directly on top of it using the bookmark name:
jj git fetch
jj new feat-auth -m "feat: user profile"
Work proceeds normally. When their PR eventually lands on trunk, fetch again
so trunk() reflects the merged state, then rebase. Use the bookmark name
here too, or the change ID if the bookmark has been deleted after merge:
jj git fetch
jj rebase -s feat-user-profile -o trunk()
a PR is merged and you want to delete its branch on the remote
Delete the bookmark locally, then push. The deletion only propagates to remotes where the bookmark is tracked — true for any branch you pushed yourself:
jj bookmark delete fix-login-timeout
jj git push --bookmark fix-login-timeout # or --deleted to flush all pending deletions
The catch: if you made the repo with git clone + jj git init --colocate
(not jj git clone), the remote branches are imported untracked, so deleting
the local bookmark queues nothing — the push says “Nothing changed” and
--bookmark NAME says “No matching bookmarks”. Confirm with jj bookmark list --tracked, then track first so the deletion has something to propagate (and
name the remote — a bare push targets origin, not a fork):
jj bookmark track foo@fork
jj bookmark delete foo
jj git push --remote fork --deleted
Or skip jj and let the colocated git do it: git push fork --delete foo. Either
way a forge refuses to delete its default branch — switch the default in its
settings first.
jj bookmark forget is not a substitute: it drops the local record and
untracks the remote so it won’t affect future pushes, but deletes nothing
upstream. Use it only when the remote branch is already gone.
Because the deleted bookmark was part of mine_deps(), rebuild the mega-merge
afterwards so mine no longer carries that now-merged feature as a separate
parent. Make sure @ is not sitting on mine first:
jj update-mine
you did a bunch of rebases and want to abandon the commits left dangling
Rebasing stacks onto a moving trunk leaves commits reachable from no reference
and no longer from your current line — superseded copies, stale mine parents.
dangling() names that set:
"dangling()" = "all() ~ (ancestors(bookmarks() | remote_bookmarks() | tags() | @) | root())"
bookmarks() is local only, so the protected set must also union
remote_bookmarks() and tags() — otherwise a commit held alive solely by a
name@origin or a tag looks dangling and gets swept.
ancestors(@) keeps the current line: we don’t want to abandon the commit
we’re sitting on, nor the unnamed work below it.
Preview, then abandon — reversible via jj undo until jj util gc:
cleandangling = ["abandon", "-r", "dangling()"]
jj log -r 'dangling()'
jj cleandangling
inspecting state
see the full DAG
jj log -r "all()"
see only your work (mine and its ancestors up to trunk)
jj log -r "ancestors(mine) ~ ancestors(trunk())"
see what mine_deps() resolves to
jj log -r "mine_deps()"
check what is in mine but not in a specific PR
jj log -r "ancestors(mine) ~ ancestors(feat-dark-mode)"
watching the logs
watch = ["util", "exec", "--", "bash", "-ec", "watchexec --quiet --clear --restart --watch=$JJ_WORKSPACE_ROOT/.jj/repo/op_heads/heads --wrap-process none -- jj log"]
tips
- Never push
mineas a PR; it exists only as your local integration branch. jj bookmark setrefuses to move a bookmark backwards (to an ancestor) by default; pass--allow-backwards/-Bwhen you intentionally want that.- When a PR is merged upstream, remove its bookmark and run
jj update-mine; jj will recompute mine_deps() automatically excluding the deleted bookmark. update-mineonly makes sense with two or more distinct PR branches.mine_deps()matches commits, not bookmark names, so ifminesits on the very same commit as your only PR branch, the~ minesubtraction empties the set andupdate-minefails with No revisions found to use as parent. That is expected, not a bug: with a single featureminealready equals that branch, so just push the branch directly and skipupdate-mineentirely.- Use
jj squashto fold fixup commits into the right PR commit before pushing. - Use
jj splitto break a commit across multiple PR bookmarks. jj describeedits the commit message of the current revision without creating a new commit.
Permalink
-
“Typically in jj you won’t have bookmark names ready when you’re sending off code reviews.” — neugierig.org, Understanding Jujutsu bookmarks. ↩︎
-
“use
jj bookmark set NAMEto give your changes a name andjj git pushto create a new branch on the remote.” — andre.arko.net, jj part 3: workflows. ↩︎ -
“The working copy is automatically committed. That results in a simpler and more consistent CLI because the working copy is now treated like any other commit.” — docs.jj-vcs.dev, Git comparison. ↩︎
-
“Jujutsu keeps track of all visible heads (leaves) of the commit graph, so the commits won’t get lost or garbage-collected.” — docs.jj-vcs.dev, Git comparison. ↩︎
-
“This is the normal state in Jujutsu (there’s actually no way – yet, at least – to have an active branch/bookmark).” — docs.jj-vcs.dev, Git comparison. ↩︎