Skip to content

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-jj on 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(), the builtin_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.

Terminal window
npm install @johnhenry/isomorphic-jj isomorphic-git

isomorphic-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+.

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.git.init({ userName: 'You', userEmail: '[email protected]' });
await jj.write({ path: 'README.md', data: '# hello' });
await jj.describe({ message: 'First change' }); // no add, no staging
await jj.new({ message: 'Next change' }); // start the next change
const log = await jj.log({ revset: 'all()' });
await jj.undo(); // any operation, not just commits

The 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().

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.

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.

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.

  • 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

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.