jj-fork 0.1.0

Maintain a fork as jj series and glues on top of upstream, and keep them current
jj-fork-0.1.0 is not a library.

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

cargo install --git https://github.com/ajac-zero/jj-fork
jj-fork alias                 # makes `jj fork` run jj-fork

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.

[upstream]
url = "https://github.com/owner/project.git"
# remote = "upstream"
# branch = "main"

[fork]
# remote = "origin"
# branch = "fork/main"
# series_prefixes = ["patch/"]
# glue_prefix = "glue/"
# mirror_branch = "main"          # fast-forward a fork branch that mirrors upstream

[checks]
# Run on each stale series replayed onto upstream.
patch = [
  { name = "build", run = "go build ./...", tier = "medium", low_if_errors_at_most = 2 },
  { name = "test", run = "go test -count=1 {go_packages}", when = "go_packages", kind = "go-test", tier = "high" },
]
# Run on the fork-branch candidate before it moves.
fork = [
  { name = "test", run = "go test ./...", kind = "go-test" },
]

[generated]                        # optional
paths = ["api/*/zz_generated.deepcopy.go"]
inputs = ["api/**"]                # patch checks regenerate only when these change
regenerate = "make generate"

[tiers]                            # optional; upper bounds at the first conflicting commit
low_max = { files = 2, hunks = 3, lines = 60, commits = 3 }
medium_max = { files = 12, hunks = 20, lines = 400, commits = 10 }

[low_memory]                       # optional; applied below 8 GiB of RAM
env = { GOFLAGS = "-p={jobs}", GOMEMLIMIT = "{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

cargo test           # unit tests
cargo build          # tests/e2e.sh runs target/debug/jj-fork; cargo test does not rebuild it
tests/e2e.sh         # end-to-end scenarios against throwaway local repositories

The end-to-end tests need jj and git on PATH. scripts/install-jj [DIR] installs the jj release that CI uses.

License

MIT