Working with GitHub

Lesson 5 · jj for Git Users · ~10 minutes

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.

Bookmarks: jj's Branches

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
You rarely create bookmarks manually

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.

Pushing to a Remote

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 shortcut: push by change ID

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.

When to use --change vs -b

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.

Fetching Remote Changes

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

Git: update feature branch

# 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

jj: update your stack

# 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.
No checkout needed

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.

The Full PR Workflow

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
Local workflow: GitHub: jj new → write code (nothing yet) jj new → write tests (nothing yet) jj git push --change @- →→→ Branch appears, ready for PR gh pr create →→→ PR opened jj edit → fix → jj new (review comments addressed) jj git push →→→ PR updated (force-push) ←←← PR merged jj git fetch main updated locally

Addressing Review Feedback

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.

Git: address review on commit 2 of 3

# 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

jj: address review on commit 2 of 3

# Direct and clean:
$ jj edit <change-id>    # Jump to commit 2
# Make the fix
$ jj new                  # Done
$ jj git push             # Auto force-pushes
Force-pushing is normal

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).

Using gh pr create with jj

The 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
Naming bookmarks for PRs

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.

Multi-Commit PRs

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.

Keeping Your Stack Up to Date

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 origingit 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.

Check Your Understanding

When do you need to create a bookmark in jj?
Correct! Bookmarks exist solely for remote interaction. Locally, you use change IDs to reference commits. You never need to "create a branch" before starting work — that's a git habit you can drop.
Not quite. Bookmarks are only needed when pushing to a remote (like GitHub). Locally, jj uses change IDs to identify commits. You don't need to create a bookmark before working — only before pushing.
What does jj git push --change @- do?
Correct! --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."
Not quite. 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.
After jj git fetch, how do you update your working stack on top of the latest main?
Correct! 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.
Not quite. After fetching, use 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.
Primary Source

jj Documentation — Working with GitHub — official guide covering bookmarks, pushing, PRs, and GitHub-specific workflows in detail.

Questions? The git-to-jj bridge for GitHub workflows can feel unfamiliar at first. Ask me about specific scenarios — stacked PRs, handling merge conflicts on push, working with forks, or anything else about the remote interaction model.
← Prev Next →