Working with Stacked PRs using jj

 ·  subscribe to my posts

A while back I wrote about working with stacked PRs using git-branchless, git-autofixup, and git-pr. It worked well. But since then I switched to jj — and I’m not going back.

jj is a new version control system that is compatible with git. It can use an existing git repo as its backend. I started using it as a drop-in replacement for git and expected it to feel roughly equivalent. Instead, it made the stacked PRs workflow feel so natural that I wonder how I ever worked without it.

This article explains why, shows my full setup, and documents the workflow I use every day at Connectly.


What Are Stacked PRs?

If you haven’t read the previous article, here’s the short version.

A stacked PR workflow means splitting a feature into a chain of small, dependent pull requests — each one building on the previous — instead of one giant PR that touches everything. The stack for a “user sign-up” feature might look like this:

main ← [1: update cache pkg] ← [2: add email pkg] ← [3: implement sign-up]

Each PR is small and focused. Reviewers can read them in order and give targeted feedback. You don’t have to wait for PR 1 to merge before starting on PR 3 — you just stack it on top. When PR 1 is approved and merged, PR 2 becomes the new base, and so on.

The downside is that rebasing a chain of commits is painful in stock git. Edit one commit in the middle and you have to manually rebase everything above it. That friction is why most teams default to the big-bang PR.

jj eliminates that friction almost entirely.


Why jj Is a Perfect Fit for Stacked PRs

The working copy is always a commit

In git, your working directory has three states: the working tree, the index (staging area), and commits. You git add files to the index, then git commit to turn them into a commit.

In jj, there is no index. Your working directory is automatically a commit — always. As you edit files, jj continuously amends the current “working copy” commit. When you’re ready to start a new commit, you run jj new to create one. It’s a simpler mental model: you’re always editing a commit.

This matters for stacked PRs because you can freely jump between commits (jj edit) without stashing or worrying about your current state. jj just moves your working copy to the target commit.

Change IDs are stable

jj assigns every commit two identifiers:

  • Commit ID — like git’s SHA. Changes every time you amend the commit.
  • Change ID — stable. Stays the same even after rebases and amends.

This is a huge deal for stacked work. When you rebase a stack of ten commits onto a new main, every commit ID changes. But every change ID stays the same. You can refer to commits by change ID in scripts, in git-pr tracking headers, or in your own notes, and they remain valid through the whole lifecycle of the PR.

Auto-rebase on edit

This is the one that changes everything for stacked PRs.

In git (even with git-branchless), editing a commit in the middle of a stack means you need to explicitly rebase everything above it. The tooling helps, but it’s still a separate step.

In jj, when you jj edit a commit and make changes, all descendant commits are automatically rebased. You edit, the stack adjusts. That’s it.

Here’s what it looks like in practice:

je abc123   # jump to commit abc123 in the middle of the stack
# ... make your changes ...
j+          # move back up to the next commit — descendants already rebased

No --interactive, no --autosquash, no manual rebase invocation.

Conflicts don’t block you

When rebasing a stack in git, a conflict in commit 3 of 10 stops everything. You resolve it, continue, hope the next one is clean, repeat.

jj takes a different approach: it records conflicts inside commits and keeps going. A conflicted commit just shows (conflict) in the log. You can see the whole stack, keep working on other commits, and resolve the conflict when you’re ready — or skip it entirely if a later operation makes it disappear.

This is especially useful at Connectly, where CI bots occasionally add auto-generated commits to main. When I sync my stack, these can create transient conflicts in the middle. Most of the time I can just jdel the conflicted commit and the rest of the stack resolves cleanly — because the conflict was only in the CI-generated text, not in my actual code. More on this in the workflow section.

jj absorb — the killer feature

jj absorb is the built-in equivalent of git autofixup. It looks at what you’ve changed in your working copy, compares it to the blame history of the stack, and automatically squashes each hunk into the commit that last touched those lines.

You don’t have to think about which commit in the stack to fix up. You just make the change and run jab. jj figures it out.

# fix a bug that spans multiple commits in the stack
# ... edit files ...
jab         # absorb into the right parent commits automatically

Colocated with git — no lock-in

jj operates as a layer on top of git. In “colocated” mode it uses your existing .git directory. You can run git log, git push, git pr — all the regular git tools still work. jj imports any git commits automatically.

This means there’s no big migration. You can start using jj in your existing repo today, keep using your existing tools, and adopt jj features gradually.

Trade-offs

To be fair:

  • Learning curve. The mental model is different. “Working copy is a commit” and “change IDs vs commit IDs” take some getting used to.
  • --ignore-immutable everywhere. Once a commit is pushed to a remote, jj marks it immutable. Editing it requires --ignore-immutable. I’ve aliased everything to include this flag by default.
  • git hooks don’t run. jj does not trigger git hooks. If your repo uses pre-commit hooks for linting, they won’t fire on jj commands. They still fire on direct git commands.
  • Some colocated edge cases. Mixing jj and git commands heavily can create divergent change IDs. It’s not data loss, but it’s messy. I use jclean regularly to tidy up.
  • Newer tool. jj is under active development. Most things work great, but you’ll occasionally hit rough edges.

Setup with an Existing Git Repo

# Install (macOS)
brew install jj

# Initialize jj in an existing git repo (colocated mode)
cd ~/my-project
jj git init --colocate

# Verify
jj log

Colocated mode means jj and git share the same .git directory. Your git history, remotes, and branches all remain intact. jj calls branches “bookmarks” internally, but they map 1:1 to git branches.

Set up ~/.config/jj/config.toml at minimum:

[user]
name = "Your Name"
email = "you@example.com"

[ui]
default-command = "log"   # just typing 'jj' shows the log
paginate = "never"
editor = "code"           # or vim, nano, subl, etc.

For pushing stacked PRs with git-pr, nothing special is needed — it uses git under the hood and works fine in a colocated jj repo.


Core Concepts for git Users

A quick orientation before diving into the workflow:

gitjj
branchbookmark
staging area / git addnot needed — working copy is a commit
git commit -m "msg"jj describe -m "msg" (working copy auto-committed)
git commit --amendjust edit files — jj amends automatically
git checkout <hash>jj edit <change>
git rebase -i HEAD~Njj edit <ancestor>, then jj squash/jj split
git stashnot needed
git logjj log
git statusjj status

The most important shift: in git you stage changes then commit them. In jj you just edit, and the current working copy commit is continuously updated. When you’re satisfied, you jj new to start the next commit, leaving the previous one sealed.


My Setup — Custom Log Template

The default jj log shows a lot of information, but it’s dense and hard to scan at a glance. Here’s the default:

Default jj log output

And here’s the same repo with my custom template:

Custom jj log output

Much cleaner. The j alias (my focused stack view) with default template:

Default j alias output

And with my template:

Custom j alias output

Each commit shows two lines:

<change_id> <time_ago> [HEAD] [(conflict)] [(divergent)] [✦remote-bookmark] [local-bookmark]
 <commit_id> [(empty)] <description>

Line 1 uses the change ID — stable, used for jj commands. Line 2 uses the commit ID — git’s SHA, used for GitHub links and git tools. Having both at a glance means I never have to copy-paste between jj log and git log.

The full template (goes in ~/.config/jj/config.toml):

[templates]
log = '''
change_id.shortest(7) ++ " " ++
committer.timestamp().ago() ++ " " ++
if(self.contained_in("first_parent(@)"), label("git_head_label", "HEAD "), "") ++
if(conflict, label("conflict", "(conflict) "), "") ++
if(divergent, label("divergent", "(divergent) "), "") ++
separate(", ",
  self.remote_bookmarks().filter(|r| r.remote() == "origin").map(|r| label("bookmark", "✦" ++ r.name())),
  self.local_bookmarks().map(|r| label("bookmark", r.name())),
  self.tags().map(|r| label("bookmark", r.name())),
) ++
if(author.email() == "you@example.com", "", " " ++ author.email()) ++
"\n " ++
commit_id.shortest(6) ++ " " ++
if(conflict, label("conflict", "×× "), "") ++
if(divergent, label("divergent", "﹖ "), "") ++
if(empty,
  label("empty", "(empty) ") ++ description.first_line(),
  if(description.first_line(),
    description.first_line(),
    label("empty description placeholder", "(no description)")
  )
) ++
"\n"
'''

[colors]
git_head_label = { fg = "bright magenta", bold = true }
conflict       = { fg = "magenta", bold = false }
divergent      = { fg = "yellow",  bold = false }
bookmark       = { fg = "green" }

Replace "you@example.com" with your own email. Commits from others will show their author email; your own won’t clutter the display.


My Most Useful Aliases

I use short aliases for everything. Here’s what they do and why.

Viewing the stack

j (no args) — The most-used command. Calls jm(), which shows commits in the current stack relative to main. It filters to only what’s relevant: commits between main and the current head, plus main itself and the working copy:

j           # show current stack vs main
j abc123    # pass-through to jj: j log -r abc123, j diff, etc.

jsjj status. Show what’s changed in the current working copy commit.

jss [change] — Show a commit summary: which files changed, insertions/deletions. Defaults to current (@). Also aliased as jjs.

Moving around

j- / j-- / j-N — Move to parent commit(s). j-3 goes up 3 commits in one shot.

j+ / j++ / j+N — Move to child commit(s). The clever part: if you’re at the top of the stack (no children), j+ automatically creates a new empty commit instead of failing. This means I can just keep pressing j+ to navigate forward and extend the stack when I reach the end.

jn [change] — Create a new empty commit after the given change (or current if omitted).

jn- — Insert a new empty commit before the current one. Anything above auto-rebases. Useful for retroactively splitting work.

je [change] — Edit a commit. Moves the working copy to that commit; all descendants auto-rebase.

Describing changes

jd — Open editor to write/edit the commit message for the current change.

jd -m "message" — Set message inline, no editor.

jnm / jnn — New empty change on top of main. This is how I start a new PR stack.

Syncing

jsync — Rebase the current stack onto local main, then clean up empty commits:

jsync  # = jj sync && jj clean && j

jjsync — Same but fetches from remote first:

jjsync  # = jj git fetch && jj sync && jj clean && j

The underlying jj sync alias is:

sync = ["rebase", "--ignore-immutable", "-s", "roots(main@origin..@)", "-d", "main@origin"]

This rebases everything from the root of my current stack up to @ onto main@origin. It only touches my current stack — not every branch in the repo.

Cleanup

jclean — Two steps: delete empty commits in the stack (jj clean), then remove any divergent copies created by mixing jj and git commands (jj-clean-divergent).

j del [change] / jdel [change] — Abandon a commit. Descendants automatically rebase onto its parent.

jsq [change] — Squash a commit into its parent, then clean up the resulting commit message (removes CI-generated lines and deduplicates Remote-Ref trailers).

Bookmarks (branches)

jbjj bookmark. Manage bookmarks (jj’s name for git branches).

jbc [name] — Create a bookmark at the current commit.

jmove [change] [dest] — Rebase a change to a destination. Defaults: rebase current change to main:

jmove                 # rebase @ onto main
jmove abc123          # rebase abc123 onto main
jmove abc123 xyz456   # rebase abc123 onto xyz456

Files

jrs [file] — Restore files from a change. Like git checkout -- <file>.

jabjj absorb. Automatically squash working copy changes into the right parent commits. My most-used cleanup command after editing across multiple commits.

Conflict resolution

j resolve / jresolve [change] — Open the merge editor for a conflicted commit. I use GoLand’s built-in merge tool, configured in ~/.config/jj/config.toml:

[ui]
merge-editor = ["/Applications/GoLand.app/Contents/MacOS/goland", "merge", "$left", "$right", "$base", "$output"]

My Workflow

Starting a new feature stack

jnm                              # new empty change on top of main
jd -m "feat: update cache package"
# ... write code ...
jn                               # new empty change (next in stack)
jd -m "feat: add email package"
# ... write code ...
jn
jd -m "feat: implement sign-up logic"
# ... write code ...
j                                # view the stack

Checking the current state

j           # which commits are in my stack vs main
js          # what files are changed in the current working copy
jss         # summary of files changed + message for current commit
jss abc123  # same but for a specific commit

Editing a commit in the middle of the stack

This is where jj shines:

je abc123   # jump to abc123 — all descendants auto-rebase
# ... make changes ...
js          # check what changed
j+          # move to the next commit (already rebased)

No --interactive, no --autosquash, no separate rebase step.

Making a fix across the stack

When a reviewer points out a bug that spans multiple commits, or I realize I need to touch code that lives in an earlier commit:

# edit the files in the working copy
jab         # absorb: automatically routes each hunk to the right parent commit
j           # verify the stack still looks right

Syncing with main

jjsync      # git fetch + rebase stack onto main@origin + clean empties
j           # verify the stack

The CI auto-commit problem

At Connectly, CI bots occasionally add auto-generated commits to main. When I sync, my stack rebases onto a main that includes these commits. Sometimes this creates a conflict marker in the middle of my stack — even though my actual code has no conflict.

The fix is usually:

j               # find the (conflict) commit in the log
jdel rzpkyuo    # abandon the conflicted commit
j               # the stack is often clean now
jclean          # clean up any remaining empties and divergent copies

This works because the conflict was only in the CI-generated text, not in my code. Abandoning that commit means jj rebases my real commits directly onto each other, and the conflict disappears.

If the conflict is real, I open the merge editor:

jresolve        # resolve conflicts in current commit
jresolve abc123 # resolve in a specific commit

Pushing stacked PRs

Once the stack looks good:

je <top-of-stack>   # check out the topmost commit in my stack
git pr              # push all commits as stacked PRs on GitHub

git-pr uses a Remote-Ref: <branch> trailer in each commit message to track which GitHub branch corresponds to which commit. On subsequent runs it updates existing PRs rather than creating new ones.

After a review

je abc123       # jump to the commit the reviewer commented on
# ... make changes ...
jab             # absorb if changes span multiple commits
git pr          # update all PRs in the stack
j-      # go to parent (older)
j+      # go to child (newer), or create new if at end of stack
j-3     # go up 3 commits
j+2     # go down 2 commits

Quick Comparison: jj vs git-branchless

I used git-branchless before jj. Here’s how they compare for stacked PR work:

Featuregit-branchlessjj
Smart loggit sljj log / j
Edit middle commitgit checkout <hash>jj edit <change>
Auto-rebase descendantsYes (via git hooks)Yes (built-in)
Change trackingcommit SHA onlystable change ID + commit SHA
Working copytraditional gitalways a commit
Conflict during rebasestops and asksmarks commit, continues
Absorb changesgit autofixup (external tool)jj absorb (built-in)
Undogit undojj undo / jj op log
Git compatibilityfull native gitcolocated (mostly full)
git hookssupportednot supported
Maturitymore establishednewer, active development

The biggest practical difference: git branchless sync syncs all branches in the repo. jsync/jjsync only syncs the current stack. There’s no direct equivalent in git-branchless for “rebase only my current stack.”

I switched because:

  1. Change IDs make tracking PRs through rebases much less fragile.
  2. Auto-rebase is faster and less error-prone when it’s built in, not hook-based.
  3. Not stopping on conflicts is a genuine quality-of-life improvement on a busy main branch.
  4. jj absorb handles more cases correctly than git autofixup.

Appendix A: Q&A

Q: I accidentally made changes in the wrong commit. How do I undo?

jj undo — jj keeps a complete operation log. Every jj edit, jj rebase, jj describe is recorded. jj op log shows the full history. jj undo steps back one operation. You can also jj op restore <op-id> to jump to any point in the history.

Q: I have a conflict in the middle of my stack. Do I have to resolve it right now?

No. jj marks the commit as (conflict) and keeps going. You can:

  • Keep working on commits above it.
  • jdel the conflicted commit if it’s a CI auto-commit — often resolves the whole stack automatically.
  • jresolve when you’re ready to fix it properly.

Q: How do I split a commit into two?

jj split -r <change>    # opens interactive diff editor

Select which hunks go into the first commit. The rest become the second commit. Descendants auto-rebase.

Q: How do I view what changed in a specific commit?

jss <change>          # summary: files changed, insertions/deletions
jj diff -r <change>   # full diff
jj show <change>      # diff + metadata

Q: What happens to my git branches?

jj calls them “bookmarks.” They map 1:1 to git branches and are fully visible to git tools. jj bookmark list shows all bookmarks. Remote bookmarks appear as ✦branch-name in the log template.

Q: Can I still use regular git commands?

Yes, in colocated mode. jj imports any git commits automatically on the next jj invocation. Avoid heavy mixing though — rapidly alternating between git commit and jj can create divergent change IDs. Use jclean to tidy up if that happens.

Q: Does .gitignore work?

Yes. jj respects .gitignore files.

Q: What about git hooks (pre-commit, commit-msg, etc.)?

git hooks do not run when using jj commands. They only fire on direct git commands. If your team uses pre-commit hooks for linting, they won’t run on jj operations. I use jfix as a workaround — it manually runs pre-commit on the files changed in the current stack.

Q: How do I move a commit to a different position in the stack?

jj rebase -r <change> -d <new-parent>   # move one commit (descendants stay in place)
jj rebase -s <change> -d <new-parent>   # move commit and all descendants

Or use jmove:

jmove                    # rebase current change onto main
jmove abc123             # rebase abc123 onto main
jmove abc123 xyz456      # rebase abc123 onto xyz456

Q: I pushed a commit and need to edit it. jj says it’s immutable.

Use --ignore-immutable. All my aliases already include this flag. For raw jj commands:

jj describe --ignore-immutable -r <change> -m "new message"
jj edit --ignore-immutable <change>

After editing a pushed commit you’ll need to force-push the corresponding branch. git pr handles this automatically.

Q: How do I delete a commit from the middle of the stack?

j del <change>   # or: jdel <change>, or: jj abandon <change>

Descendants automatically rebase onto the abandoned commit’s parent.


Appendix B: jj ↔ git Cheatsheet

git commandjj equivalent
git initjj git init --colocate
git clone <url>jj git clone <url>
git statusjj status / js
git logjj log / j
git add . && git commit -m "msg"jj describe -m "msg" (working copy auto-committed)
git commit --amendjust edit files — jj amends automatically
git checkout <branch>jj edit <change>
git checkout -b <branch>jj new then jj bookmark create <name>
git stashnot needed
git rebase -i HEAD~3jj edit <ancestor>, then squash/split as needed
git rebase mainjj rebase -s @ -d main / jsync
git pushjj git push / git push
git fetchjj git fetch / git fetch
git merge <branch>jj new <change1> <change2> (creates merge commit)
git cherry-pick <hash>jj duplicate -r <change> then jj rebase
git reset HEAD~1jj squash or jj abandon
git restore <file>jj restore <file> / jrs <file>
git diffjj diff
git blame <file>jj file annotate <file>
git bisectnot built-in yet
git tag <name>jj tag create <name>

Appendix C: Full Alias Reference

zsh functions and aliases

# Main dispatcher: 'j' with no args shows stack, 'j <cmd>' passes through to jj
j() {
  if [[ -z "$1" ]]; then
    jm
  else
    jj "$@"
  fi
}

# Show current stack relative to main
jm() {
  if [[ -z "$1" ]]; then
    jj log -r '(main..@)- | (main@origin..@)- | (@-):: & mine() | main | @'
  else
    jj log -r '(main..'"$1"')- | ('"$1"'..main)- | '"$1"' | (main..@)- | (main@origin..@)- | main | @'
  fi
}

# Print commit message
jmsg() {
  if [[ -z "$1" ]]; then
    jj log -r @ -T 'description'
  else
    jj log -r "$1" -T 'description'
  fi
}

# Show commit summary (files + line counts)
jshow() {
  if [[ -z "$1" ]]; then
    jj show @ --summary
  else
    jj show "$1" --summary
  fi
}

# Sync: rebase current stack onto local main, clean up
jsync()  { jj sync && jj clean && j }

# Sync: fetch first, then rebase current stack onto main@origin, clean up
jjsync() { jj git fetch && jj sync && jj clean && j }

# Rebase a change to main (or to a specified destination)
jmove() {
  if [[ -z "$1" ]]; then
    jj rebase -s @ -d "main" --ignore-immutable
  elif [[ -z "$2" ]]; then
    jj rebase -s "$1" -d "main" --ignore-immutable
  else
    jj rebase -s "$1" -d "$2" --ignore-immutable
  fi
}

# Abandon a change
jdel() {
  if [[ -z "$1" ]]; then
    echo "Usage: jdel <rev>"
  else
    jj abandon -r "$1"
  fi
}

# Run pre-commit linters on changed files in current stack
jfix() {
  local root=$(jj workspace root 2>/dev/null)
  if [[ -z "$root" ]]; then
    echo "Not in a jj workspace"
    return 1
  fi
  local rev="${1:-main..@}"
  local files=$(jj diff -r "$rev" --name-only 2>/dev/null)
  if [[ -z "$files" ]]; then
    echo "No changed files in $rev"
    return 0
  fi
  if [[ -f "$root/.pre-commit-config.yaml" ]]; then
    (cd "$root" && echo "$files" | xargs pre-commit run --files)
  elif [[ -x "$root/.husky/pre-commit" ]]; then
    (cd "$root" && ./.husky/pre-commit)
  else
    echo "No pre-commit config found"
    return 1
  fi
}

# Open merge editor to resolve conflicts
jresolve() {
  if [[ -n "$1" ]]; then
    jj resolve -r "$1" --ignore-immutable
  else
    jj resolve -r "@" --ignore-immutable
  fi
}

# Remove divergent commits (copies created by mixing jj and git commands)
jj-clean-divergent() {
    echo "Finding divergent change IDs in current branch..."
    local divergent_ids=$(jj log -r 'ancestors(@)' --no-graph | grep '(divergent)' | awk '{print $1}' | sort -u)

    if [[ -z "$divergent_ids" ]]; then
        echo "No divergent commits found"
        return 0
    fi

    local count=$(echo "$divergent_ids" | wc -l | tr -d ' ')
    echo "Found $count divergent change IDs"

    local revsets=()
    while IFS= read -r id; do
        revsets+=("change_id($id) ~ ancestors(@)")
    done <<< "$divergent_ids"

    echo "Abandoning divergent commits (keeping current branch)..."
    jj abandon "${revsets[@]}" --ignore-immutable

    echo "Clean up complete."
}

# Clean commit message: remove [ci] auto-generated lines, deduplicate Remote-Ref trailers
jj-clean-msg() {
  local rev="${1:-@}"
  local msg=$(jj log -r "$rev" -T 'description' --no-graph)
  local processed=$(node -e '
const msg = process.argv[1];
const lines = msg.split("\n");

let footerStart = lines.length;
for (let i = lines.length - 1; i >= 0; i--) {
  if (/^[A-Za-z][A-Za-z0-9-]*:/.test(lines[i])) {
    footerStart = i;
  } else if (lines[i].trim() === "") {
    continue;
  } else {
    break;
  }
}

const bodyLines = lines.slice(0, footerStart).filter(line => {
  const lower = line.toLowerCase();
  return !(lower.includes("[ci]") && lower.includes("auto-generated"));
});

let remoteRefSeen = false;
const footerLines = lines.slice(footerStart).filter(line => {
  if (line.trim() === "") return false;
  const lower = line.toLowerCase();
  if (lower.includes("[ci]") && lower.includes("auto-generated")) return false;
  if (/^Remote-Ref:/.test(line)) {
    if (remoteRefSeen) return false;
    remoteRefSeen = true;
  }
  return true;
});

while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") {
  bodyLines.pop();
}

if (footerLines.length > 0) {
  console.log(bodyLines.join("\n") + "\n\n" + footerLines.join("\n"));
} else {
  console.log(bodyLines.join("\n"));
}
' "$msg")

  if [[ "$processed" != "$msg" ]]; then
    jj describe --ignore-immutable -r "$rev" -m "$processed"
  fi
}

# Squash into parent, then clean the resulting commit message
jsq() {
  local rev="${1:-@}"
  jj squash --ignore-immutable -r "$rev" || return $?
  jj-clean-msg "$rev-"
}

# Navigate to next child commit; create new if at end of stack
jnext() {
  if ! jj --ignore-immutable next 2> /dev/null; then
    jj new
  fi
}

# Short aliases
alias jab="jj absorb --ignore-immutable"
alias jb="jj bookmark"
alias jbc="jj bookmark create"
alias jclean='jj clean && jj-clean-divergent'
alias jd="jj describe --ignore-immutable"
alias je="jj edit --ignore-immutable"
alias jn="jj new --ignore-immutable"
alias jn+="jj new --ignore-immutable -A @"
alias jn-="jj new --ignore-immutable -B @"
alias jnn="jj new main"
alias jnm="jj new main"
alias jrb="jj rebase --ignore-immutable"
alias jrs="jj restore --ignore-immutable"
alias js="jj status"
alias jss="jshow"
alias jjs="jshow"

# Navigate forward (j+) — creates new if at end of stack
alias j+="   jnext  "
alias j++="  jnext;jnext"
alias j+++=" jnext;jnext;jnext"
alias j++++="jnext;jnext;jnext;jnext"
alias j+2="  jnext;jnext"
alias j+3="  jnext;jnext;jnext"
alias j+4="  jnext;jnext;jnext;jnext"
alias j+5="  jnext;jnext;jnext;jnext;jnext"
alias j+6="  jnext;jnext;jnext;jnext;jnext;jnext"

# Navigate backward (j-) — go to parent commits
alias j-="   jj --ignore-immutable prev"
alias j--="  jj --ignore-immutable prev 2"
alias j---=" jj --ignore-immutable prev 3"
alias j----="jj --ignore-immutable prev 4"
alias j-2="  jj --ignore-immutable prev 2"
alias j-3="  jj --ignore-immutable prev 3"
alias j-4="  jj --ignore-immutable prev 4"
alias j-5="  jj --ignore-immutable prev 5"
alias j-6="  jj --ignore-immutable prev 6"

jj config aliases (~/.config/jj/config.toml)

[aliases]
'-'  = ["prev"]
'+'  = ["next"]
l    = ["log", "-r", "(main..@)- | (main@origin..@)- | @"]
ll   = ["log", "-r", "(main..@):: | (main..@)- | (main@origin..@)+"]
lr   = ["log", "-r"]
ab   = ["absorb"]
b    = ["bookmark"]
cp   = ["duplicate"]
d    = ["describe", "--ignore-immutable"]
dd   = ["duplicate"]
e    = ["edit", "--ignore-immutable"]
g    = ["git"]
gp   = ["git", "push", "--allow-new"]
gf   = ["git", "fetch"]
n    = ["new", "--ignore-immutable"]
s    = ["status"]
rb   = ["rebase", "--ignore-immutable"]
rs   = ["restore", "--ignore-immutable"]
sp   = ["split", "--ignore-immutable"]
sq   = ["squash", "--ignore-immutable"]
sync = ["rebase", "--ignore-immutable", "-s", "roots(main@origin..@)", "-d", "main@origin"]
del  = ["abandon", "--ignore-immutable"]
pl   = ["parallelize"]
px   = ["parallelize"]

move  = ["rebase", "--ignore-immutable", "-b", "$1", "-d", "$2"]
clean = ["del", "empty() & mine() ~merges() ~zzzzzz ~@"]

If you’re already using the git-branchless workflow from the previous article, switching to jj is low-risk: colocated mode means your existing git repo, remotes, and git-pr setup all keep working. The difference is you get better ergonomics on every stacked PR operation.

If you’re starting fresh with stacked PRs, jj is the place to start.


Let's stay connected!

If you like the post, subscribe to my newsletter to get latest updates:

Author

I'm Oliver Nguyen. A software maker working mostly in Go and JavaScript. I enjoy learning and seeing a better version of myself each day. Occasionally spin off new open source projects. Share knowledge and thoughts during my journey. Connect with me on , , , , or subscribe to my posts.