Konubinix' opinionated web of thoughts

How to Use Jujutsu to Help Me Have a Bunch of PR and a Megamerge Named Mine on Top of Git

Fleeting

setup

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 mine as a PR; it exists only as your local integration branch.
  • jj bookmark set refuses to move a bookmark backwards (to an ancestor) by default; pass --allow-backwards / -B when 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-mine only makes sense with two or more distinct PR branches. mine_deps() matches commits, not bookmark names, so if mine sits on the very same commit as your only PR branch, the ~ mine subtraction empties the set and update-mine fails with No revisions found to use as parent. That is expected, not a bug: with a single feature mine already equals that branch, so just push the branch directly and skip update-mine entirely.
  • Use jj squash to fold fixup commits into the right PR commit before pushing.
  • Use jj split to break a commit across multiple PR bookmarks.
  • jj describe edits the commit message of the current revision without creating a new commit.

  1. “Typically in jj you won’t have bookmark names ready when you’re sending off code reviews.” — neugierig.org, Understanding Jujutsu bookmarks↩︎

  2. “use jj bookmark set NAME to give your changes a name and jj git push to create a new branch on the remote.” — andre.arko.net, jj part 3: workflows↩︎

  3. “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↩︎

  4. “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↩︎

  5. “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↩︎