jj-fork
Maintain a fork as independent jj series on top of upstream, and keep them current as upstream moves.
The model
upstream main ──┬── patch/a ──┬──────────────┐
├── patch/b ──┼── glue/a+b ──┼── fork/main
└── tooling/x ───────────────┘
- Series (
patch/*,tooling/*, configurable) are bookmarks whose commits sit directly on upstream. Each one is independent and could be proposed upstream on its own. - Glues (
glue/<a>+<b>) are merges of two or more series that hold only the resolution of their conflicts. When a series moves, its glues are restacked onto the new tip, carrying the resolution. A glue over a superset (glue/a+b+c) merges the glue over the subset (glue/a+b). - The fork branch (
fork/main) is a generated merge of upstream, every series, and every glue. It never holds hand-made changes, so rebuilding it is always safe.
Commands
jj fork init [--upstream URL] prepare a clone: remotes, full history, jj, tracking, revset aliases
jj fork check report each series: up-to-date, clean, conflict, or broken
jj fork sync [--push] rebase clean series onto upstream and assemble the fork branch
jj fork assemble [--push] restack glues, then build, check, and move the fork branch
jj fork alias add `aliases.fork` to your jj config
check replays every stale series onto upstream in a throwaway worktree and runs the configured patch checks. Each problem gets a difficulty tier ([tier=low|medium|high]) from the size of the conflict, or from the check that failed, so an automated fixer can pick a matching model.
sync changes nothing unless every stale series is clean. It then rebases them with jj, verifies each result has the same tree as the checked replay, fast-forwards the mirror branch, and assembles.
assemble moves the fork branch only after a conflict-free merge passes the configured fork checks. When the merge conflicts, it names each conflicting pair and the glue that would resolve it. --push fetches again first and refuses to push if someone else pushed a series, glue, or the fork branch during the run.
Before anything else, every command reconciles local bookmarks in the fork's namespaces (fork branch, mirror, series, glue) against the fork remote, with or without --no-fetch, so a stale clone (an old snapshot, or a plain git fetch that jj never saw) cannot resurrect or overwrite remote state. Each change is a reconciled: line naming its rule: (1) local behind the remote moves to it; (2) a conflicted bookmark is set to the remote's commit; (3) local ahead of or diverged from the remote is kept as unpushed work and reported, except (3b) a diverged commit that is already on a remote ref (the remote restacked or rebased it) takes the remote's commit; (4) a bookmark missing on the remote whose commit is reachable from a remote ref was deleted after publishing, so it is forgotten locally; (5) any other bookmark missing on the remote is new work and kept. Nothing on the remote is deleted, and --push skips any bookmark that is behind its remote.
Exit codes: 0 nothing to do or success, 10 (check) every stale series is clean, 20 a series or merge needs a person or an agent, 1 error. Reports go to stdout, progress to stderr.
Install
jj does not discover jj-<name> binaries on its own, so the alias runs jj util exec -- jj-fork.
Configuration
Repository facts live in a committed .jj-fork.toml, so every clone, CI job, and agent sandbox sees them. jj fork init --upstream URL writes a starter file. Personal overrides live in jj config under jj-fork.* with the same structure, for example jj config set --user jj-fork.fork.remote mine.
[]
= "https://github.com/owner/project.git"
# remote = "upstream"
# branch = "main"
[]
# remote = "origin"
# branch = "fork/main"
# series_prefixes = ["patch/"]
# glue_prefix = "glue/"
# mirror_branch = "main" # fast-forward a fork branch that mirrors upstream
[]
# Run on each stale series replayed onto upstream.
= [
{ = "build", = "go build ./...", = "medium", = 2 },
{ = "test", = "go test -count=1 {go_packages}", = "go_packages", = "go-test", = "high" },
]
# Run on the fork-branch candidate before it moves.
= [
{ = "test", = "go test ./...", = "go-test" },
]
[] # optional
= ["api/*/zz_generated.deepcopy.go"]
= ["api/**"] # patch checks regenerate only when these change
= "make generate"
[] # optional; upper bounds at the first conflicting commit
= { = 2, = 3, = 60, = 3 }
= { = 12, = 20, = 400, = 10 }
[] # optional; applied below 8 GiB of RAM
= { = "-p={jobs}", = "{memory_limit_mib}MiB" }
Check placeholders: {go_packages} expands to the Go package directories a series changes, and {jobs} to the parallelism for this machine. A check with kind = "go-test" retries a failing test once, then runs it on bare upstream; a test that also fails upstream is reported as a note and does not block.
See examples/ai-gateway.toml for a complete configuration.
Tests
The end-to-end tests need jj and git on PATH. scripts/install-jj [DIR] installs the jj release that CI uses.
License
MIT