Git turned 20 years old and we’re still dealing with the same footguns: a confusing staging area, terrifying rebase conflicts, merge commits that make history unreadable, and 4,000-line PRs because splitting a feature across branches is too painful to bother with.
The solution — and it’s been proven at Google, Meta, and Stripe — is the stacked diff workflow. Write code in small, logical, reviewable slices. Each slice sits on top of the previous one. Reviewers see focused, reviewable changes. You stop dreading code review. The whole team moves faster.
The tools I’m covering today — jj (Jujutsu), Sapling, and git-spice — each attack this from a different angle. None of them are silver bullets. All three are worth understanding.
The core problem with Git’s mental model
Git’s staging area (the index) made sense in 2005. You’re crafting a patch to email to a mailing list. You need fine-grained control over exactly which bytes go in. Fine.
What we actually do today: open a PR, get review, address feedback across 20 files, then try to organize that mess into a logical commit history. Git makes this genuinely hard. You’re doing mental gymnastics with git add -p, git stash, and git rebase -i just to maintain a clean history. And if your feature touches 8 logical areas? You either ship one giant PR that takes 3 days to review, or you manually manage 8 branches that diverge and conflict constantly.
Big tech solved this internally years ago with tools like Phabricator, Gerrit, and their custom VCS systems. The idea is simple: your unit of work is a stack of commits, not a branch. Each commit in your stack is independently reviewable. When you address feedback on commit 3, the tool automatically propagates that rebase down to commits 4, 5, and 6. No manual branch juggling.
jj (Jujutsu): the cleanest rethink
jj is not a Git wrapper. It’s a new VCS by a former Google engineer, with a Git backend. Your existing repo stays a valid Git repo. Everyone else on the team doesn’t need to know you’re using it.
The mental model shift: there is no staging area. Your working copy is always a commit. Every file change you make is automatically part of the current "change" (jj’s term for a commit). When you’re done, you just describe it and move on.
Install it:
# Linux (cargo)
cargo install --locked jujutsu
# macOS
brew install jujutsu
Initialize on an existing Git repo:
cd my-project
jj git init --colocate
The --colocate flag is key — it puts the jj store inside your existing .git directory. Your repo is still a valid Git repo. Other contributors don’t notice anything different.
The workflow in practice
# Check what's happening
jj st
# See your actual history (this is the killer feature)
jj log
# Create a new empty change (like "git checkout -b" but cleaner)
jj new -m "add user authentication middleware"
# Do your work... files are automatically tracked
# Describe the current change
jj describe -m "auth: add JWT validation middleware"
# Create another change stacked on top
jj new -m "auth: add rate limiting to login endpoint"
# Now you're working on change 2, change 1 is untouched below
The jj log output is where things get genuinely impressive:
@ xyzmno (no description set)
│
◉ qrstuv auth: add rate limiting to login endpoint
│
◉ abcdef auth: add JWT validation middleware
│
◉ zxcvbn main@origin: last commit on main
Every change has a short ID. You jump between them with jj edit <id>. No branch names required (though you can use bookmarks). When you edit a parent commit, jj automatically rebases all descendants. Conflicts are stored in the repo as first-class objects — you don’t get blocked mid-rebase.
Splitting a change
You realized commit abcdef does too much. Split it:
jj edit abcdef
jj split
# Interactive selector opens — pick which files go in the first half
This is where jj destroys git add -p in usability. The diff selection is interactive and you can split by file, hunk, or even line.
Sending to GitHub
jj doesn’t natively manage PR stacks (yet). You push bookmarks to Git and open PRs manually, or pair it with a tool like jj-stack or custom scripts. This is jj’s weakest point today — the GitHub integration story is still maturing compared to the other tools.
# Push a bookmark to GitHub
jj bookmark create auth-middleware -r abcdef
jj git push --bookmark auth-middleware
Gotcha: jj’s anonymous-branch model is powerful but disorienting for the first week. You’ll instinctively look for branch names. There aren’t any unless you create bookmarks. Lean into the log view and change IDs instead. After two weeks, going back to Git branches feels like regression.
Gotcha: jj git push behavior differs from git push. By default it pushes all tracked bookmarks. Use --bookmark to be explicit until you understand what’s tracked.
Sapling: stacked diffs with corporate lineage
Sapling is Meta’s open-sourced VCS. It started as a fork of Mercurial and spent years powering the world’s largest monorepo. It shows. The stacked diff workflow is native, not bolted on.
It speaks Git natively — clone a GitHub repo, push PRs, everything works. But the mental model is Mercurial-influenced: commits are the atomic unit, not branches. You have one long chain of commits. You navigate that chain.
Install:
# Linux (prebuilt binary from GitHub releases)
wget https://github.com/facebook/sapling/releases/latest/download/sapling_linux_x86_64.tar.xz
tar xf sapling_linux_x86_64.tar.xz
sudo mv sl /usr/local/bin/
# macOS
brew install sapling
Clone a Git repo:
sl clone https://github.com/your-org/your-repo
Smartlog — the feature that ruins regular git log forever
sl smartlog
# or just
sl sl
Output:
o a3f9b2 feat: add payment retry logic
│
o c8d1e4 feat: add payment service client
│
@ 7f2a0b feat: scaffold payment module (current)
│
o 1b3c5d main (remote/main)
The @ shows where you are. Remote tracking, draft vs public commits, branch status — all visible at a glance. This is what git log --graph --oneline --decorate wants to be when it grows up.
Working with stacks
# Make a commit (no staging area — all working copy changes go in)
sl commit -m "scaffold payment module"
# Start the next piece
# (you're automatically on a new "draft" commit on top)
sl commit -m "add payment service client"
# Jump to a specific commit to amend it
sl goto 7f2a0b
# make edits...
sl amend
# Rebase the whole stack onto updated main
sl pull --rebase
Amending a parent commit and rebasing descendants is automatic. No git rebase -i dancing required.
GitHub PR workflow
Sapling has a first-class GitHub integration:
# Submit the current commit as a PR
sl pr submit
# Submit the whole stack as separate, linked PRs
sl pr submit --stack
Each PR in the stack gets a header added automatically showing the chain:
## Stack
1. scaffold payment module (#142) ← current
2. add payment service client (#143)
3. add payment retry logic (#144)
Reviewers can merge #142, and #143 automatically rebases onto main. This is the workflow.
Gotcha: Sapling’s commit model means you don’t have "branches" in the Git sense. When you sl goto to an older commit to fix something, you’re in a detached state by default. Use sl goto -C or bookmarks to track named points.
Gotcha: sl commit with no arguments commits everything in your working copy. There’s no staging. This is intentional but you’ll fat-finger it once or twice. Use sl diff habitually before committing.
Gotcha: The Sapling binary is sl, which conflicts with some systems’ sl (the steam locomotive joke command). Check your PATH.
git-spice: stacked diffs without leaving Git
You don’t want to switch tools. Your team is on Git. Your CI is on Git. Your muscle memory is on Git. But you still want stacked PRs without the manual branch management hell.
git-spice is the answer. It’s a thin layer that manages a stack of Git branches, automates rebasing when you amend a parent, and submits stacked PRs to GitHub.
Install:
# Linux/macOS via Homebrew
brew install abhinav/tap/git-spice
# Or download from GitHub releases
Initialize in a repo:
cd my-project
gs repo init
Building a stack
# Start on main, create a branch for the first piece
gs branch create add-payment-module
# Make changes, then:
gs commit create -m "scaffold payment module"
# Create the next branch, stacked on the current one
gs branch create add-payment-client
# Make changes:
gs commit create -m "add payment service client"
# And another
gs branch create add-payment-retry
gs commit create -m "add payment retry logic"
Your Git history now looks like:
main → add-payment-module → add-payment-client → add-payment-retry
Each is a real Git branch. Other tools, CI, GitHub — they all see normal Git branches.
The key operation: stack rebase
You get review feedback on add-payment-module. Fix it:
gs branch checkout add-payment-module
# make fixes
git add -p # normal Git here
gs commit amend # or just git commit --amend
# Now rebase the whole stack above this point:
gs stack rebase
That last command is where git-spice earns its place. It automatically rebases add-payment-client and add-payment-retry onto the updated add-payment-module. No manual git rebase --onto invocations. No accidentally detaching HEAD.
Submitting to GitHub
# Submit just one branch as a PR
gs branch submit
# Submit the entire stack as linked PRs
gs stack submit
git-spice creates PRs with base branches set correctly — add-payment-client targets add-payment-module, not main. When add-payment-module merges, git-spice retargets the next PR in the stack automatically.
# After add-payment-module merges:
gs stack sync
# Detects the merge, rebases remaining stack onto main
Gotcha: git-spice stores stack metadata in your Git repo’s config. If you rebase or rename branches outside of git-spice commands, you can desync the internal state. Stick to gs commands for branch operations while using the tool. You can recover with gs stack sync --reset but it’s annoying.
Gotcha: The GitHub integration requires a token. Set GITHUB_TOKEN in your environment or use gh auth login first. git-spice uses the gh CLI’s token store.
Gotcha: git-spice handles one stack at a time per session. If you’re working on two separate feature stacks simultaneously, you’ll be switching between them with gs branch checkout. It works, but the mental overhead climbs. jj or Sapling handle this more elegantly.
Comparison: which one should you actually use
| jj | Sapling | git-spice | |
|---|---|---|---|
| Learning curve | High | Medium | Low |
| Team adoption required | No | No | No |
| Native stacked PR submission | No (immature) | Yes, excellent | Yes, solid |
| Works with existing Git tooling | Yes | Yes | Yes (it IS Git) |
| No staging area | Yes | Yes | No (still Git) |
| Best for | Individuals, power users | Teams migrating from Hg/big monorepos | Teams that want stacked PRs now |
Reach for jj if you want the cleanest mental model and you’re comfortable with a steeper initial curve. It’s the most correct rethink of version control semantics. The GitHub PR story will improve — the core VCS is already excellent.
Reach for Sapling if you work in or with a monorepo context, you’re moving from Mercurial, or you want the most polished stacked-PR-to-GitHub experience available today. The smartlog alone is worth the install.
Reach for git-spice if your team is staying on Git and you want stacked diffs tomorrow morning without any ceremony. You’ll still be running git log, git diff, and git blame. git-spice just handles the tedious branch plumbing you were doing manually.
Production considerations
All three tools serialize your stack state somewhere. jj uses .jj/ in your repo root (colocated) or ~/.local/share/jj/. Sapling uses .sl/. git-spice uses Git config. Back these up the same way you back up your repo — they’re just files.
For CI: all three produce real Git branches and commits by the time code hits your remote. Your CI pipeline doesn’t need to know any of these tools exist. You push branches, open PRs, CI runs on the branch. Nothing changes server-side.
For code review: stacked PRs only work smoothly if your reviewers know they’re reviewing a slice. Add a note in your PR template. Agree as a team that PR #2 can be reviewed before #1 merges — the tool will handle the rebase. If reviewers refuse to look at dependent PRs, the workflow breaks regardless of tooling.
The biggest organizational hurdle isn’t technical — it’s convincing people that reviewing a 200-line PR is faster than reviewing a 2,000-line PR. The tooling is easy. The culture change is where teams stall.
Where to go from here
- jj documentation: https://martinvonz.github.io/jj/
- Sapling documentation: https://sapling-scm.com/docs/
- git-spice documentation: https://abhinav.github.io/git-spice/
Start with git-spice if you want results this week. Install jj in a personal project if you want to understand where VCS is heading. The concepts you learn — thinking in stacks, amending history without fear, describing changes before pushing — transfer across all three tools and back to raw Git.
The workflow itself is the upgrade. The specific tool is secondary.