jj doesn't use branches internally — but GitHub still needs them. This lesson covers the bridge: bookmarks. You'll learn how to push changes to GitHub, create PRs, address review feedback, and keep your stack up to date — all while staying in jj's superior workflow.
The good news: you only need bookmarks when interacting with remotes. Inside your local repo, you never think about them.
A bookmark in jj is equivalent to a git branch — a named pointer to a commit. The key difference: bookmarks are only needed for remote interaction. Locally, you identify commits by change ID or description. Bookmarks exist purely to give GitHub something to push to.
# Create a bookmark pointing at a specific revision:
$ jj bookmark create my-feature -r @-
# Create a bookmark pointing at the current working copy's parent:
$ jj bookmark create my-feature -r @-
# List all bookmarks:
$ jj bookmark list
The jj git push --change command auto-creates bookmarks for you. Manual bookmark creation is only needed when you want a specific name (e.g., matching a Jira ticket). For most PRs, let jj name them automatically.
Once a bookmark exists, push it to GitHub:
# Push all bookmarks that have local changes:
$ jj git push
# Push a specific bookmark:
$ jj git push -b my-feature
The most common workflow skips manual bookmark creation entirely. --change creates a bookmark named after the change ID and pushes it in one step:
# Auto-create a bookmark for the parent commit and push it:
$ jj git push --change @-
# Push a specific change by its ID:
$ jj git push --change sqpuoqvx
This creates a bookmark like push-sqpuoqvx and pushes it. GitHub sees a branch; you never had to think about naming it.
Use --change for quick single-commit PRs where you don't care about the branch name. Use -b (with a manually created bookmark) when you want a human-readable branch name, or when pushing a multi-commit stack under one bookmark.
Pull the latest from the remote with jj git fetch. This updates your remote-tracking bookmarks (like main@origin) without touching your local commits:
# Fetch all remotes:
$ jj git fetch
# After fetching, update your stack to sit on top of the latest main:
$ jj rebase -d main
# Pull latest main and rebase your branch:
$ git checkout main
$ git pull
$ git checkout my-feature
$ git rebase main
# Resolve conflicts if any...
# Or: git pull --rebase origin main
# Pull latest and rebase in two commands:
$ jj git fetch
$ jj rebase -d main
# Done. No checkout dance.
# Conflicts stored if any — resolve at leisure.
Unlike git, you don't need to "be on" a branch to rebase it. jj rebase -d main rebases your current stack's root onto main regardless of where your working copy is. You never switch branches — because jj doesn't have branches to switch between.
Here's the complete cycle from local work to merged PR:
# 1. Do your work (normal jj workflow — no branch needed yet):
$ jj new -m "add user authentication"
# ... write code ...
$ jj new -m "add auth tests"
# ... write tests ...
# 2. Ready for review — push with auto-bookmark:
$ jj git push --change @-
# Creates bookmark and pushes. Output shows the branch name.
# 3. Create the PR using GitHub CLI:
$ gh pr create --head push-sqpuoqvx --title "Add user authentication"
# 4. Reviewer requests changes. Address them:
$ jj edit sqpuoqvx # Jump to the commit that needs fixing
# Make the fix (auto-amends)
$ jj new # Return to top of stack
# 5. Force-push the updated bookmark:
$ jj git push
# jj detects the bookmark moved and force-pushes automatically.
# 6. PR merged! Clean up:
$ jj git fetch # Pulls the merge
$ jj bookmark forget push-sqpuoqvx # Optional cleanup
This is where jj's history rewriting (Lesson 4) pays off in the PR workflow. When a reviewer asks for changes to a specific commit in your stack:
# Reviewer says: "Fix the error handling in commit sqpuoqvx"
$ jj edit sqpuoqvx # Jump directly to that commit
# Fix the error handling
$ jj new # Back to top of stack
$ jj git push # Force-pushes the updated bookmark
No git commit --fixup. No git rebase --autosquash. No separate "address review" commits that you squash later. You fix the actual commit, and the PR updates cleanly.
# Option A: fixup commit (messy history)
$ git commit -m "fixup: address review"
$ git push
# Option B: amend the right commit (complex)
$ git rebase -i HEAD~3
# Mark commit 2 as "edit"
# Make fix, amend, continue
# Resolve any conflicts...
$ git push --force-with-lease
# Direct and clean:
$ jj edit <change-id> # Jump to commit 2
# Make the fix
$ jj new # Done
$ jj git push # Auto force-pushes
In jj, every time you rewrite history and push, it's a force-push. jj handles this automatically — you don't need --force or --force-with-lease. This is safe because your PR branch is yours. Just don't rewrite commits that others are building on (same rule as git).
gh pr create with jjThe GitHub CLI works perfectly with jj. The only thing to know: use --head to specify your bookmark name:
# After pushing:
$ gh pr create --head push-sqpuoqvx --base main
# Or with more options:
$ gh pr create \
--head my-feature \
--base main \
--title "Add user authentication" \
--body "Implements OAuth2 flow for the login page"
# List your PRs:
$ gh pr list --author @me
# Check PR status:
$ gh pr status
If you plan to use gh pr create, consider creating a named bookmark instead of relying on the auto-generated push-xxxxx name. It makes the --head flag more readable: --head add-auth vs --head push-sqpuoqvx.
For a PR containing multiple commits (a stack), point the bookmark at the top of your stack:
# Your stack:
# main ← add-model ← add-migration ← add-endpoint (@)
# Create bookmark at the top:
$ jj bookmark create my-feature -r @-
# Push (includes all commits between main and the bookmark):
$ jj git push -b my-feature
# GitHub PR will show all 3 commits.
When you rewrite any commit in the stack (edit, split, squash), the bookmark stays at the top. Just jj git push again to update the PR.
When the remote main moves forward (other PRs merge), update your local stack:
# Fetch latest:
$ jj git fetch
# Rebase your entire stack onto the new main:
$ jj rebase -d main
# If conflicts arise, jj log shows them:
$ jj log
# Resolve conflicted commits at your leisure.
# Push updated stack:
$ jj git push
This replaces the git workflow of git fetch origin → git rebase origin/main → resolve conflicts serially → force push. In jj, conflicts don't block the rebase — they're stored and resolved whenever you're ready.
jj git push --change @- do?--change @- targets the parent commit (@-), automatically creates a bookmark named after its change ID (like push-sqpuoqvx), and pushes it. One command replaces "create branch + push."jj git push --change @- takes the parent commit (@-), auto-creates a bookmark for it (named after the change ID), and pushes that bookmark to the remote. It's a shortcut that combines bookmark creation and pushing.jj git fetch, how do you update your working stack on top of the latest main?jj rebase -d main rebases your current stack onto the latest main. No need to checkout main first, no need for a merge step. Two commands total: fetch then rebase.jj rebase -d main to move your stack on top of the updated main. There's no jj pull or jj checkout — those are git concepts that don't apply here.jj Documentation — Working with GitHub — official guide covering bookmarks, pushing, PRs, and GitHub-specific workflows in detail.