isomorphic-jj
@johnhenry/isomorphic-jj is a pure-JavaScript reimplementation of
Jujutsu (jj)’s version-control model:
stable change IDs that survive rewrites, an operation log that makes
anything undoable, no staging area, and conflicts as data instead of
blockers. It never shells out — Git compatibility comes from
isomorphic-git underneath — so the same API
runs in Node.js, browsers, and workers. Ships a CLI (isojj), a /browser
entry, and full TypeScript definitions.
Previously published as
isomorphic-jj(last release 1.7.0, now deprecated). Renamed to@johnhenry/isomorphic-jjon import into the @johnhenry family and restarted at 0.0.0 — but you’ll find it at 0.1.0, because a jj-v0.44 parity pass (tag.track()/untrack(), thebuiltin_log()revset,file.search({ nameOnly })) landed together with the adoption. Same library, same API lineage; the version restart is a new address, not a maturity signal.
Install
Section titled “Install”npm install @johnhenry/isomorphic-jj isomorphic-gitisomorphic-git is an optional peer dependency — skip it and you get a
storage-only repo with jj semantics but no .git directory and no remotes.
For browsers, add @isomorphic-git/lightning-fs. Node 20+.
Quick start
Section titled “Quick start”import { createJJ } from '@johnhenry/isomorphic-jj';import git from 'isomorphic-git';import http from 'isomorphic-git/http/node';import fs from 'node:fs';
const jj = await createJJ({ fs, dir: '/path/to/repo', git, http });
await jj.write({ path: 'README.md', data: '# hello' });await jj.describe({ message: 'First change' }); // no add, no stagingawait jj.new({ message: 'Next change' }); // start the next change
const log = await jj.log({ revset: 'all()' });await jj.undo(); // any operation, not just commitsThe working copy IS a change — internalize this before anything else
Section titled “The working copy IS a change — internalize this before anything else”There is no staging area and no “dirty” state. The working copy is itself a
change with a stable changeId (spelled @ in revsets). describe() names
the current change; new() seals it and starts the next one. If you never
call new(), every subsequent write and describe() keeps mutating the
same change — including via commit(), which will happily rename an
already-described change if you haven’t moved on. commit({ message, nextMessage }) is exactly describe() + new().
This is jj’s model, not the jj CLI
Section titled “This is jj’s model, not the jj CLI”It reimplements the semantics — change graph, oplog, revsets, conflicts —
in JS. It is not a binding to the jj binary and doesn’t replicate the
terminal UX, templating engine, or anything that requires spawning
processes (jj run is deliberately absent: there’s no isomorphic way to
“run a subprocess per revision” in a browser). Repos are stored as .jj/
JSON metadata beside a normal .git/, so Git tools see ordinary commits.
The Git boundary is explicit
Section titled “The Git boundary is explicit”Everything Git-shaped — real commits, clone/fetch/push — is delegated to
isomorphic-git and requires the git (and, for remotes, http) options at
createJJ() time. Without them you’re in storage-only mode: fully
functional jj semantics, no Git objects. Two consequences worth knowing on
day one: duration revsets (last(7d), since(...)) filter on committer
timestamps that only exist once a Git backend makes real commits, and
git.clone() produces a git-level clone — running init() inside it
re-roots refs/heads/main onto a fresh change, orphaning the fetched
history. Details in Bookmarks & remotes.
Browser storage is real but evictable
Section titled “Browser storage is real but evictable”The /browser entry gives you an IndexedDB-backed filesystem
(createBrowserFS()), capability detection, and quota introspection. Until
you call requestPersistentStorage() (and the browser grants it), your
repos live in best-effort storage the browser may silently clear. Remote
operations from a page also need a CORS proxy for most Git hosts. See
Getting started.
The pages here
Section titled “The pages here”- Getting started — first repo to first commits, Node and browser
- Repositories — repo lifecycle, storage layout, backends, the working-copy model
- History — changes, log, revsets (and where they diverge from real jj), diff, evolution, undo, conflicts
- Bookmarks & remotes — bookmarks, tags and v0.44 tracking, Git fetch/push interop, auth
- Configuration — config files, layers, programmatic overrides
- Migration from isomorphic-git — side-by-side API comparison and an honest architecture comparison
- Examples — the repo’s 12 runnable, self-asserting examples, annotated
Status
Section titled “Status”Tracks Jujutsu through v0.44 (as of 0.1.0): the v0.31–v0.43 revset and
command batch landed in the 1.5 lineage, and the v0.44 pass added tag
tracking, builtin_log(), and file.search({ nameOnly }) while documenting
git_refs()/git_head() as deprecated (real jj removed them in v0.43;
here they still work). 1714 tests passing, ~97% statement / 91% branch
coverage. Ready for experimentation and prototyping; some edges (fetch-time
remote-tracking import, push conflict guards) are consciously deferred —
the repo’s CHANGELOG says which and why.
Source: github.com/johnhenry/isomorphic-jj
· API reference in the repo’s API.md; every example in
examples/
runs in CI.