npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

isomorphic-jj

v1.14.0

Published

Jujutsu (jj) version control semantics in pure JavaScript — stable change IDs, fearless undo, no staging area — for Node.js and browsers

Readme

@johnhenry/isomorphic-jj

Jujutsu version control for JavaScript—stable change IDs, fearless undo, and no staging area. Works in Node.js and browsers.

Full documentation: opensource.johnhenry.me/isomorphic-jj

Previously published as isomorphic-jj (final release 1.8.0, deprecated in favor of this package). Renamed to @johnhenry/isomorphic-jj and restarted at 0.0.0 on import into the @johnhenry family — a new address and era, not a maturity signal.

npm version CI license

What makes it different:

  • 🎯 Stable change IDs that survive rebases/squashes
  • ↩️ Complete undo for any operation (not just commits)
  • 🚫 No staging area—your working copy IS a commit
  • 🌳 Conflicts as data—merge now, resolve later
  • 🌐 True isomorphic—same API in Node, browsers, and Web Workers
import { createJJ } from '@johnhenry/isomorphic-jj';
import git from 'isomorphic-git';
import fs from 'fs';

const jj = await createJJ({ fs, dir: './my-repo', git });
await jj.git.init({ userName: 'You', userEmail: '[email protected]' });

// Edit files, then describe (no staging!)
await jj.describe({ message: 'Add feature' });

// Made a mistake? Undo it.
await jj.undo();

Contents


Try it in 60 seconds

CLI

Every method is reachable from the isojj CLI (bin/isojj.js). Run it with npx, or install it globally (npm i -g @johnhenry/isomorphic-jj) and call isojj directly:

npx @johnhenry/isomorphic-jj init
echo "# hello" > README.md
npx @johnhenry/isomorphic-jj describe -m "Initial commit"
npx @johnhenry/isomorphic-jj new -m "Add a feature"
npx @johnhenry/isomorphic-jj log
Change History:

  cb7b4e93fd23  Add a feature
             You  9/2/2026, 7:20:30 AM

  a2f6f5af8107  Initial commit
             You  9/2/2026, 7:20:30 AM

Run npx @johnhenry/isomorphic-jj --help for the full command list.

Node.js

import { createJJ } from '@johnhenry/isomorphic-jj';
import fs from 'fs';

const jj = await createJJ({ fs, dir: './my-repo' });
await jj.init({ userName: 'You', userEmail: '[email protected]' });
await jj.write({ path: 'README.md', data: '# hello\n' });
await jj.describe({ message: 'Initial commit' });
console.log(await jj.log({ limit: 5 }));

Browser

import { createJJ } from '@johnhenry/isomorphic-jj';
import { createBrowserFS } from '@johnhenry/isomorphic-jj/browser';

const fs = await createBrowserFS({ name: 'my-repo' });
const jj = await createJJ({ fs, dir: '/repo' });
// Same API as Node from here on — see examples/12-browser.mjs.

Quick Start

Coming from isomorphic-git? Read the Migration Guide to see how isomorphic-jj simplifies your JavaScript version control workflow.

Want to see what it can do? Run the comprehensive demo:

node demo.mjs

This showcases all 17 feature categories (including v1.0 enhancements) in ~3 seconds with beautiful output.


Examples

The examples/ directory has 12 numbered, runnable, self-asserting scripts — each creates a repo in a temp dir, demonstrates one area, and cleans up after itself. Run one with npm run example:01 (etc.), or the whole set (the same loop CI runs) with:

npm run examples

| # | What it shows | |---|----------------| | 01 | The core loop: init → write → describe() → new() → log() | | 02 | Stable change IDs across a 3-layer stack; edit()+amend() a bottom layer | | 03 | split(), squash({ into }), abandon()/unabandon(), duplicate() | | 04 | Diverging without branches; content merge vs. true merge changes | | 05 | First-class conflicts: dry-run preview, conflicts.list()/resolve() | | 06 | The revset query language, end to end | | 07 | Bookmarks, tags, and remote tracking | | 08 | Real git.clone()/push()/fetch() against a local fixture, zero network | | 09 | Config layers: persisted, programmatic, deep-merge, reset | | 10 | undo()/redo(), operations.at() time travel, obslog() | | 11 | The file.* namespace: historical show(), annotate(), search() | | 12 | Browser usage (IndexedDB fs, persistence, CORS proxy) as a reference |

See examples/README.md for details and conventions.

Three fuller example applications live under example/ — each exercises the library from a different angle and documents the bugs it found:

  • jj-review-tool — a code-review CLI built on stacked changes and bookmarks.
  • jj-storage-server — a REST API using isomorphic-jj as a versioned document store.
  • jj-wiki — a collaborative wiki exercising workspaces and conflict resolution.

What's New in v1.0

isomorphic-jj v1.0 achieves complete JJ CLI semantic compatibility while maintaining 100% backward compatibility:

New Convenience Functions

  • commit() - Combines describe() + new() in one step for common workflow
  • file.* namespace - Complete organized file operations: file.write(), file.show(), file.list(), file.move(), file.remove()
  • rebase() - Proper JJ CLI semantics for rebasing changes (replaces move() for history ops)

Enhanced APIs

  • new() - Added insertAfter/insertBefore for precise change placement
  • squash() - Added into parameter, smart defaults (source=@, dest=parent)
  • abandon() - Now defaults to @ (working copy) when no changeId specified
  • split() - Added paths parameter for selective file splitting

Renamed for Correctness

  • unabandon() - Renamed from restore() to match JJ CLI semantics

Enhanced Revsets

  • @- / @-- - Navigate to parent/grandparent (like Git's HEAD~1, HEAD~2)
  • @+ / @++ - Navigate to children/grandchildren
  • bookmark(name) - Exact bookmark lookup (vs bookmarks() for patterns)

Complete Workspace Operations

  • workspace.rename() - Rename workspaces by ID or name
  • workspace.root() - Get workspace root directory
  • workspace.updateStale() - Update workspaces pointing to abandoned changes

Complete JJ CLI Parity (~95% coverage!)

isomorphic-jj v1.0 now implements virtually all commonly-used JJ CLI commands:

  • git.clone() - Clone Git repositories with JJ metadata
  • git.remote.* - Complete remote management (list, add, remove, rename, setUrl)
  • bookmark.rename() - Rename bookmarks
  • bookmark.track/untrack/forget() - Remote bookmark management
  • tag.track/untrack() - Remote tag management (jj v0.44)
  • config.* - Configuration management (get, set, list)
  • diff() - Show file differences between revisions
  • next() / prev() - Navigate between parent/child revisions
  • duplicate() - Create copies of changes
  • restore() - Restore files from another revision
  • file.annotate() - Git-blame equivalent (line-by-line history)
  • operations.show/diff/restore() - Advanced operation log features
  • remote.* - Convenience aliases for git operations (push, fetch, add)

See ROADMAP.md for the release-by-release feature comparison and what's still planned.

Testing & Quality

  • 1823 tests passing — ~97% statement / 91% branch coverage
  • 100% backward compatible - All existing code continues to work
  • Zero breaking changes - Deprecated features show warnings but still function

Installation

npm install @johnhenry/isomorphic-jj isomorphic-git

For browsers, also install:

npm install @isomorphic-git/lightning-fs

Basic Workflow

import { createJJ } from '@johnhenry/isomorphic-jj';
import git from 'isomorphic-git';
import fs from 'fs';
import http from 'isomorphic-git/http/node';

// Create repository
const jj = await createJJ({
  fs,
  dir: './repo',
  git,
  http
});

// Initialize (creates both .git and .jj directories)
await jj.git.init({
  userName: 'Your Name',
  userEmail: '[email protected]'
});

// Write files and describe changes (no staging!)
await jj.write({ path: 'README.md', data: '# My Project' });
await jj.describe({ message: 'Initial commit' });

// Create a new change on top
await jj.new({ message: 'Add feature' });
await jj.write({ path: 'feature.js', data: 'export const feature = () => {}' });
await jj.amend({ message: 'Add feature implementation' });

// Oops! Undo the last operation
await jj.undo();

// View history
const log = await jj.log({ limit: 10 });
console.log(log);

Browser Usage

import { createJJ } from '@johnhenry/isomorphic-jj';
import { createBrowserFS } from '@johnhenry/isomorphic-jj/browser';
import git from 'isomorphic-git';
import http from 'isomorphic-git/http/web';

// Create browser filesystem (uses IndexedDB)
const fs = await createBrowserFS({ name: 'my-repo' });

const jj = await createJJ({ fs, dir: '/repo', git, http });
await jj.git.init({ userName: 'User', userEmail: '[email protected]' });
// ... same API as Node.js!

How is this Different?

JJ vs Git Concepts

| Concept | Git | JJ / isomorphic-jj | |---------|-----|-------------------| | Primary ID | Commit SHA (changes on rewrite) | Change ID (stable) + Commit ID (mutable) | | Working state | Working tree + staging index | Working copy IS a commit | | Branches | Required for work | Optional bookmarks for sync only | | "Dirty" state | Blocks many operations | Doesn't exist—always committed | | Undo | git reflog (per-ref, manual) | jj.undo() (complete repo state) | | Conflicts | Text markers that block workflow | Structured data you can commit | | Rewriting history | Manual rebase -i, can lose work | Edit any change, descendants auto-update |

Mental Model Shift

Git thinking:

# Edit files
git add .                    # Stage changes
git commit -m "message"      # Create commit
git rebase -i HEAD~3         # Manually rewrite history
# Hope you didn't mess up!

JJ thinking:

// Edit files (no staging!)
await jj.describe({ message: 'message' });  // Describe current change

// Edit any change in history
await jj.edit({ changeId: 'abc123' });
// Make changes...
await jj.amend();
// Descendants automatically rebased!

// Made a mistake? Just undo.
await jj.undo();

Understanding Working Copy vs Parent

A common source of confusion: the working copy and its parent are different changes.

// After init, you have a working copy change (with changeId)
const status1 = await jj.status();
console.log(status1.workingCopy.changeId);  // e.g., "abc123..."
console.log(status1.workingCopy.description);  // "(no description set)"

// When you describe(), you're updating the PARENT change
await jj.describe({ message: 'Add feature' });

// The working copy changeId is STILL the same!
const status2 = await jj.status();
console.log(status2.workingCopy.changeId);  // SAME: "abc123..."
console.log(status2.workingCopy.description);  // UPDATED: "Add feature"

// When you new(), you create a NEW working copy
await jj.new({ message: 'Next change' });

const status3 = await jj.status();
console.log(status3.workingCopy.changeId);  // DIFFERENT: "def456..."
console.log(status3.workingCopy.description);  // "Next change"

Key points:

  • Working copy IS a change with its own changeId (always @ in revsets)
  • describe() updates the parent's description, not the working copy's changeId
  • status() returns the working copy changeId (which may or may not have a description)
  • new() creates a new working copy change and makes the previous one the parent
  • Reading metadata immediately after write shows working copy state, not parent state

Common pattern:

// 1. Edit files
await jj.write({ path: 'file.js', data: 'content' });

// 2. Describe the change (updates parent)
await jj.describe({ message: 'Add file' });

// 3. Create new working copy for next change
await jj.new({ message: 'Next feature' });

// Or use commit() to combine describe() + new()
await jj.commit({ message: 'Add file', nextMessage: 'Next feature' });

Features

Core Operations

File Operations

// Write files
await jj.write({ path: 'file.txt', data: 'content' });

// Read from working copy or any change
const content = await jj.read({ path: 'file.txt' });
const oldVersion = await jj.read({ path: 'file.txt', changeId: 'abc123' });

// ✨ NEW in v1.0: Complete file.* namespace (matches JJ CLI structure)
// Recommended: Use file.* for all file operations
await jj.file.write({ path: 'new.txt', data: 'content' });
const fileContent = await jj.file.show({ path: 'file.txt' });
const fileList = await jj.file.list();
await jj.file.move({ from: 'old.txt', to: 'new.txt' });
await jj.file.remove({ path: 'unwanted.txt' });

// Read from historical changes
const historicalFile = await jj.file.show({ path: 'file.txt', changeId: 'abc123' });

// 100% Backward compatible: top-level methods still work
await jj.write({ path: 'file.txt', data: 'content' });
const sameContent = await jj.read({ path: 'file.txt' });
await jj.move({ from: 'old.txt', to: 'new.txt' });
await jj.remove({ path: 'file.txt' });
const files = await jj.listFiles();

Change Management

// Describe current change (creates Git commit automatically)
await jj.describe({ message: 'Implement feature X' });

// Create new change on top of working copy
await jj.new({ message: 'Start feature Y' });

// ✨ NEW in v1.0: commit() convenience function
// Combines describe() + new() in one operation
await jj.commit({
  message: 'Complete feature X',
  nextMessage: 'Start feature Y'
});

// Amend current change
await jj.amend({ message: 'Fix typo in feature X' });

// Edit historical changes
await jj.edit({ changeId: 'abc123' });
// Make changes...
await jj.amend();
// Descendants are automatically rebased!

// Check status
const status = await jj.status();
console.log(status.modified, status.added, status.removed);

History Editing

// ✨ NEW in v1.0: Enhanced squash with 'into' parameter and smart defaults
await jj.squash({ into: 'change2' });  // Squashes @ into change2
await jj.squash();  // Smart default: squashes @ into parent(@)

// ✨ NEW in v1.0: Enhanced new() with precise placement
await jj.new({
  message: 'Inserted change',
  insertAfter: 'abc123',  // Insert after specific change
  insertBefore: 'def456'  // Or insert before specific change
});

// ✨ NEW in v1.0: Split with paths parameter
await jj.split({
  changeId: 'abc123',
  description1: 'Part 1: Docs',
  description2: 'Part 2: Tests',
  paths: ['docs/*.md']  // Files for first split
});

// ✨ NEW in v1.0: abandon() defaults to working copy
await jj.abandon();  // Abandons @ (no changeId needed)
await jj.abandon({ changeId: 'experimental' });  // Or specify explicit change

// ✨ RENAMED in v1.0: unabandon() (was restore() - correct JJ semantics)
await jj.unabandon({ changeId: 'experimental' });

// ✨ NEW in v1.0: rebase() - Proper JJ CLI semantics for history operations
await jj.rebase({
  changeId: 'feature',
  newParent: 'updated-main',
  paths: ['file.js']  // Optional: only rebase specific files
});

// DEPRECATED: move() for history operations (use rebase() instead)
// move() still works but shows deprecation warning for history operations
// move() will be file-only in v2.0
await jj.move({ changeId: 'feature', newParent: 'updated-main' });  // Works but deprecated

Revsets - Powerful Queries

// Simple revsets
await jj.log({ revset: '@' });                    // working copy
await jj.log({ revset: 'all()' });                // all commits
await jj.log({ revset: 'none()' });               // empty set (v1.0)
await jj.log({ revset: 'roots()' });              // root commits

// ✨ NEW in v1.0: bookmark(name) for exact bookmark lookup
await jj.log({ revset: 'bookmark(main)' });       // single bookmark by exact name
await jj.log({ revset: 'bookmarks(feat*)' });     // pattern matching (multiple)

// Filter by author or description
await jj.log({ revset: 'author(alice)' });
await jj.log({ revset: 'description(fix)' });

// File-based queries
await jj.log({ revset: 'file(*.js)' });           // commits touching JS files
await jj.log({ revset: 'mine()' });               // my commits

// ✨ NEW in v1.0: Navigation functions
await jj.log({ revset: 'parents(@)' });           // direct parents
await jj.log({ revset: 'children(@)' });          // direct children
await jj.log({ revset: 'parents(all())' });       // all commits with children

// ✨ NEW in v1.0: Shorthand operators
await jj.log({ revset: '@-' });                   // parent (like HEAD~1)
await jj.log({ revset: '@--' });                  // grandparent (like HEAD~2)
await jj.log({ revset: '@+' });                   // children
await jj.log({ revset: '@++' });                  // grandchildren

// ✨ NEW in v0.5: Time-based queries
await jj.log({ revset: 'last(5)' });              // last 5 commits
await jj.log({ revset: 'last(7d)' });             // last 7 days
await jj.log({ revset: 'last(24h)' });            // last 24 hours
await jj.log({ revset: 'since(2025-01-01)' });    // since date
await jj.log({ revset: 'between(2025-01-01, 2025-02-01)' }); // date range

// ✨ NEW in v0.5: Graph analytics
await jj.log({ revset: 'descendants(abc123)' });   // all descendants
await jj.log({ revset: 'descendants(abc123, 2)' }); // max 2 levels deep
await jj.log({ revset: 'common_ancestor(rev1, rev2)' }); // merge base
await jj.log({ revset: 'range(base..tip)' });      // commits in range
await jj.log({ revset: 'diverge_point(rev1, rev2)' }); // where branches split
await jj.log({ revset: 'connected(rev1, rev2)' }); // check if path exists

// ✨ NEW in v0.5: Set operations
await jj.log({ revset: 'last(7d) & file(*.js)' }); // recent JS changes
await jj.log({ revset: 'mine() | author(bob)' });  // mine or Bob's
await jj.log({ revset: 'all() ~ mine()' });        // everything except mine

// Traditional graph analysis
await jj.log({ revset: 'roots(all())' });         // commits with no parents
await jj.log({ revset: 'heads(all())' });         // commits with no children
await jj.log({ revset: 'latest(mine(), 5)' });    // my 5 latest commits

// Repository analytics
const stats = await jj.stats();
console.log(`Total: ${stats.changes.total}, Mine: ${stats.changes.mine}`);

Complete Undo/Redo

// View operation history
const ops = await jj.obslog({ limit: 20 });

// Undo last operation (works for ANY operation)
await jj.undo();

// Undo multiple operations
await jj.undo({ count: 3 });

// Time travel to any past state
const historical = await jj.operations.at({ operation: ops[5].id });
const oldLog = await historical.log({ revset: 'all()' });

Git Interoperability

// Initialize colocated repository (both .git and .jj)
await jj.git.init({
  userName: 'Your Name',
  userEmail: '[email protected]'
});

// Fetch from Git remotes
await jj.git.fetch({ remote: 'origin' });

// Push to Git remotes
await jj.git.push({ remote: 'origin', refs: ['main'] });

// Import Git refs as bookmarks
await jj.git.import();

// Git users see normal commits
// JJ users get superior UX
// Full bidirectional compatibility!

Git interop works seamlessly:

cd my-repo
git log --oneline          # See commits from JJ
git show HEAD              # View latest change
git branch -a              # See bookmarks as branches
git status                 # .jj directory is ignored

First-Class Conflicts

// Merge creates conflicts but doesn't fail
const result = await jj.merge({
  source: 'feature-branch',
  dest: 'main'
});
console.log(`Detected ${result.conflicts.length} conflicts`);

// Continue working on something else
await jj.new({ message: 'Unrelated work' });

// Later, resolve conflicts
const conflicts = await jj.conflicts.list();
for (const conflict of conflicts) {
  await jj.conflicts.resolve({
    conflictId: conflict.conflictId,
    resolution: { side: 'ours' }  // or provide custom content
  });
}

// Undo restores conflict state if needed
await jj.undo();

Custom Merge Drivers (v0.5)

Merge drivers enable smart merging of structured files like JSON, package.json, and YAML:

import { jsonDriver, packageJsonDriver, yamlDriver, markdownDriver } from '@johnhenry/isomorphic-jj';

// Register merge drivers for different file types
jj.mergeDrivers.register({
  'package.json': packageJsonDriver,  // Smart merge for package.json
  '*.json': jsonDriver,                // Generic JSON merge
  '*.yaml': yamlDriver,                // YAML merge
  '*.md': markdownDriver,              // Markdown merge
});

// Merge with automatic driver resolution
await jj.merge({ source: 'feature' });
// Drivers automatically merge files when possible

// Per-merge driver override
await jj.merge({
  source: 'feature',
  drivers: {
    'config.json': customDriver,
  }
});

// Create custom merge driver
const customDriver = {
  name: 'my-custom-driver',
  canMerge: (base, ours, theirs) => {
    // Return true if driver can handle this merge
    return true;
  },
  merge: (base, ours, theirs) => {
    // Return merged content or null if conflict
    return mergedContent;
  }
};

// Built-in drivers:
// - packageJsonDriver: Smart merge for package.json (union merge for dependencies)
// - jsonDriver: Generic JSON merge (object-level merging)
// - yamlDriver: YAML structure-aware merge
// - markdownDriver: Section-aware merge for Markdown

Conflict Resolution Enhancements (v0.5)

New conflict resolution capabilities make handling conflicts easier:

// ✨ Dry-run merge preview
const preview = await jj.merge({
  source: 'feature',
  dryRun: true  // Preview conflicts without applying
});
console.log(`Would create ${preview.conflicts.length} conflicts`);
preview.conflicts.forEach(c => {
  console.log(`  ${c.path}: ${c.type}`);
});

// ✨ Bulk resolution with strategies
await jj.conflicts.resolveAll({
  strategy: 'ours',  // Keep our version
});

await jj.conflicts.resolveAll({
  strategy: 'theirs',  // Take their version
});

await jj.conflicts.resolveAll({
  strategy: 'union',  // Combine both sides
});

// ✨ Filtered bulk resolution
await jj.conflicts.resolveAll({
  strategy: 'ours',
  filter: { path: '*.json' }  // Only JSON files
});

await jj.conflicts.resolveAll({
  strategy: 'theirs',
  filter: { path: 'src/config/*' }  // Specific directory
});

// ✨ Resolve with merge driver
await jj.conflicts.resolve({
  conflictId: conflict.conflictId,
  driver: 'package.json',  // Use registered driver
});

// ✨ Get Git-style conflict markers
const markers = await jj.conflicts.markers({
  conflictId: conflict.conflictId
});
console.log(markers);
// <<<<<<< ours
// our content
// =======
// their content
// >>>>>>> theirs

// Manual resolution
await jj.conflicts.resolve({
  conflictId: conflict.conflictId,
  resolution: 'manually merged content',
});

// Strategy-based resolution
await jj.conflicts.resolve({
  conflictId: conflict.conflictId,
  strategy: 'ours',  // or 'theirs', 'union'
});

Bookmarks (Not Branches)

In JJ, bookmarks are for remote sync, not local navigation:

// Most work doesn't need bookmarks
await jj.new();
await jj.describe({ message: 'Anonymous change' });

// Bookmarks when pushing to remotes
await jj.bookmark.set({ name: 'feature-x', target: '@' });
await jj.remote.push({ remote: 'origin', refs: ['feature-x'] });

// List bookmarks
const bookmarks = await jj.bookmark.list();

Browser Support

import { createBrowserFS, requestPersistentStorage } from '@johnhenry/isomorphic-jj/browser';

// Request persistent storage (prevents eviction)
const persistent = await requestPersistentStorage();

// Create filesystem with IndexedDB backend
const fs = await createBrowserFS({ backend: 'idb', name: 'my-repo' });

// Check browser capabilities
import { detectCapabilities } from '@johnhenry/isomorphic-jj/browser';
const caps = detectCapabilities();
if (caps.indexedDB && caps.serviceWorker) {
  // Enable offline support
}

// Get storage quota
import { getStorageQuota } from '@johnhenry/isomorphic-jj/browser';
const quota = await getStorageQuota();
console.log(`Using ${quota.percentage}% of available storage`);

Advanced Features

Multiple Working Copies (Workspaces)

// Work on multiple changes simultaneously
const workspace = await jj.workspace.add({
  path: './feature-branch',
  name: 'feature-work',
  changeId: someChangeId
});

const all = await jj.workspace.list();

// ✨ NEW in v1.0: Complete workspace operations
// Rename workspace
await jj.workspace.rename({
  workspace: workspace.id,  // or workspace name
  newName: 'renamed-feature'
});

// Get workspace root directory
const root = await jj.workspace.root({ workspace: 'renamed-feature' });
console.log(`Workspace path: ${root}`);

// Update stale workspaces (pointing to abandoned changes)
const staleResult = await jj.workspace.updateStale();
console.log(`Updated ${staleResult.updated} stale workspace(s)`);

// Update specific workspace only
await jj.workspace.updateStale({ workspace: 'feature-work' });

await jj.workspace.remove({ id: workspace.id });

Background Operations (Node.js)

// Enable file watching and auto-snapshots
await jj.background.start();
await jj.background.enableAutoSnapshot({ debounceMs: 1000 });

// Queue async operations
await jj.background.queue(async () => {
  await jj.git.fetch({ remote: 'origin' });
});

Event System

// JJ extends EventTarget - listen to repository events
jj.addEventListener('change:creating', (event) => {
  console.log('Creating change:', event.detail.description);
  // event.preventDefault() to cancel operation
});

jj.addEventListener('change:created', (event) => {
  console.log('Change created:', event.detail.changeId);
  // Informational only, cannot cancel
});

jj.addEventListener('change:updating', (event) => {
  console.log('Updating change:', event.detail.changeId);
  // Can run validation and preventDefault() if needed
});

jj.addEventListener('change:updated', (event) => {
  console.log('Change updated:', event.detail.changeId);
});

// Events fire automatically during describe(), new(), amend(), etc.
await jj.describe({ message: 'Fix bug' });  // Fires events!

Shallow Clones

// Fetch with depth limit for faster clones
await jj.git.fetch({
  remote: 'origin',
  depth: 1,           // Only latest commit
  singleBranch: true,
  noTags: true
});

Use Cases

Stacked Changes (Like Stacked PRs)

// Create dependent changes
await jj.write({ path: 'core.js', data: '...' });
const core = await jj.describe({ message: 'Refactor core' });

await jj.new({ message: 'Feature A using new core' });
await jj.write({ path: 'feature-a.js', data: '...' });
const featureA = await jj.amend();

await jj.new({ message: 'Feature B using feature A' });
await jj.write({ path: 'feature-b.js', data: '...' });

// Edit the middle change - descendants auto-rebase!
await jj.edit({ changeId: featureA.changeId });
await jj.amend({ message: 'Updated feature A' });
// Feature B is automatically updated!

Experimentation Without Fear

// Try something risky
await jj.new({ message: 'Experimental refactor' });
// ... make major changes ...
await jj.describe({ message: 'Attempt 1' });

// Didn't work? Just undo
await jj.undo();

// Or try a different approach
await jj.new({ message: 'Better approach' });
// Operation log has complete history

Code Review Workflow

// Changes have stable IDs across iterations
const changeId = await jj.describe({ message: 'Initial implementation' });

// Reviewer comments applied
await jj.edit({ changeId: changeId });
await jj.amend({ message: 'Address review comments' });
// Same changeId, different commitId

// Push for review
await jj.bookmark.set({ name: 'review/feature-x', target: changeId });
await jj.remote.push({ remote: 'origin', refs: ['review/feature-x'] });

API Reference

Full API documentation available in TypeScript definitions.

Main Interface

import { createJJ, type JJ, type CreateJJOptions } from '@johnhenry/isomorphic-jj';

const jj: JJ = await createJJ(options: CreateJJOptions);

Core Methods

  • Repository: init(), status(), stats()
  • Files: write(), read(), cat(), move() (deprecated for history), remove(), listFiles() | Namespace: file.write(), file.show(), file.list(), file.move(), file.remove()
  • Changes: describe(), new(), amend(), commit(), edit(), show()
  • History: log(), obslog(), squash(), split(), rebase(), abandon(), unabandon(), converge()
  • Operations: undo(), redo(), operations.list(), operations.at(), operations.restore()
  • Bookmarks: bookmark.list(), bookmark.set(), bookmark.move(), bookmark.delete()
  • Git: git.init(), git.fetch(), git.push(), git.import(), git.export()
  • Remotes: remote.add(), remote.fetch(), remote.push()
  • Workspaces: workspace.add(), workspace.list(), workspace.remove(), workspace.rename(), workspace.root(), workspace.updateStale()
  • Conflicts: merge(), conflicts.list(), conflicts.resolve(), conflicts.resolveAll() (v0.5), conflicts.markers() (v0.5)
  • Merge Drivers (v0.5): mergeDrivers.register(), mergeDrivers.get(), Built-in drivers: jsonDriver, packageJsonDriver, yamlDriver, markdownDriver
  • Background (Node.js): background.start(), background.stop(), background.enableAutoSnapshot()

See complete API documentation for detailed signatures and options.


Why isomorphic-jj?

JJ's Model is Better for Everyday Work

  • Stable change IDs survive rebases/squashes—like "review comments that follow the code"
  • Operation log means you can undo anything, not just commits
  • No staging area eliminates a major source of confusion
  • First-class conflicts let you merge now, resolve later
  • Anonymous changes simplify experimental work and stacked changes

We Want This in JavaScript, Everywhere

  • isomorphic-git proved Git can run in Node and browsers
  • isomorphic-jj extends this to JJ semantics while maintaining Git compatibility
  • True isomorphic: Same API in Node, browsers, Web Workers, Service Workers
  • Git interop is table stakes—fetch/push to GitHub/GitLab just works

Git Compatibility Matters

  • Colocated repositories work with both Git and JJ tools
  • Git users see normal commits; JJ users get superior UX
  • Fetch/push to Git remotes using proven isomorphic-git infrastructure
  • Transparent collaboration between Git and JJ workflows

Architecture

isomorphic-jj follows a three-layer architecture:

┌─────────────────────────────────────────────┐
│  Your App / UI Layer                        │
│  (Web UI, CLI, VS Code extension)          │
└────────────────┬────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────┐
│  isomorphic-jj (Porcelain Layer)            │
│  ┌───────────────────────────────────────┐  │
│  │ Core: ChangeGraph, OpLog, Revsets    │  │
│  │ Operations: describe, new, squash     │  │
│  │ Conflicts: First-class conflict model │  │
│  └───────────────────────────────────────┘  │
└────────────────┬────────────────────────────┘
                 │ Backend Interface (pluggable)
                 ▼
┌─────────────────────────────────────────────┐
│  Backend Adapter (Plumbing Layer)           │
│  • isomorphic-git (default)                 │
│  • Mock backend (testing)                   │
└────────────────┬────────────────────────────┘
                 │
                 ▼
         Git objects + remotes

Design Principles:

  • Emulate JJ semantics, not implementation
  • Backend agnostic with pluggable adapters
  • Isomorphic by design (Node + browser)
  • JSON storage for JJ metadata (.jj/graph.json, .jj/oplog.jsonl)
  • Operation-first, not commit-first

Storage:

repo/
├── .git/                    # Git objects (via backend)
└── .jj/
    ├── graph.json           # Change graph with stable IDs
    ├── oplog.jsonl          # Append-only operation log
    ├── bookmarks.json       # Bookmarks
    ├── conflicts/           # Conflict descriptors
    └── working-copy.json    # Working copy state

Project Status

Current Version: 1.14.0 (@johnhenry/isomorphic-jj; same library/API lineage as the old isomorphic-jj, which reached v1.8.0 before the rename — see the provenance note at the top of this file, and the CHANGELOG's 1.9.0 entry for how the scoped/unscoped version lineages were merged back together) Test Coverage: 1882 tests passing, ~97% statements / 90% branches JJ CLI parity: tracks Jujutsu through v0.45.1 Status: Actively developed; broad JJ CLI semantic parity, used in the examples/apps in this repo and in JJHub. Not yet 1.0 in the API-stability sense (the version number is past 1.0 only because merging the scoped/unscoped lineages forced it above the unscoped side's prior ceiling, not because the API is frozen) — API surface can still shift; pin a version and read the CHANGELOG before upgrading.

Completed:

  • ✅ v0.1: Core JJ experience (stable IDs, undo, bookmarks, revsets)
  • ✅ v0.2: History editing (squash, split, abandon, unabandon, move)
  • ✅ v0.3: Git backend, conflicts, workspaces, browser support
  • ✅ v0.4: Shallow clones, advanced revsets, event system
  • ✅ v0.5: Custom merge drivers, enhanced revsets (time-based, graph analytics), conflict resolution enhancements
  • ✅ v1.0: Full JJ CLI semantic parity, complete file.*/operations.*/git.* namespaces
  • ✅ v1.5: Parity refresh tracking jj through v0.43 — new revsets (change_id, commit_id, subject, author_name/email, committer*, signed, divergent, merges, forks, first_parent, first_ancestors, fork_point, merge_point, exactly, present, coalesce, remote_tags, ancestors(x, depth)), working tags()/remote_tags() revsets, and new commands: revert(), redo(), sign()/unsign(), file.search(), bookmark.advance(), tag.set(). See CHANGELOG.md.
  • ✅ 0.1.0 (post-rename): Parity refresh tracking jj through v0.44 — tag.track()/tag.untrack(), the builtin_log() revset alias, and file.search({ nameOnly: true }); git_refs()/git_head() documented as deprecated (jj removed both in v0.43). See CHANGELOG.md for what was deliberately left for a maintainer call (jj run, fetch-time remote tag/bookmark import).
  • ✅ 0.2.0: Storage-layer audit fixes — same-process storage locking, undo() reverting in-place graph/tag mutations, ambiguous change-id detection, categorized push() error codes, a conflict-marker newline fix, atomic protobuf writes, and open-ended revset ranges (..b, a.., ::). See CHANGELOG.md.
  • ✅ 0.3.0: Parity refresh tracking jj through v0.45.1 — a working /browser entry (async createBrowserFS(), bundle-safe protobuf schema loading, no stray Node-only imports), undo()/operations.restore() reverting graph rewrites with real progressive undo/redo, squash()/abandon()/edit()/new() fully syncing content instead of only metadata, rebase() performing a real three-way merge and recording conflicts, and the new converge() command (jj v0.45.0) to auto-resolve divergent commits. See CHANGELOG.md.
  • ✅ 0.4.0: converge() generalized from pairwise to N-way — any number of divergent copies of one change id resolve in a single call, matching how jj's own underlying engine already treats divergence internally (issue #32). See CHANGELOG.md.
  • ✅ 1.9.0: Scoped (@johnhenry/isomorphic-jj) and unscoped (isomorphic-jj) version lineages merged into one shared version going forward, with both names dual-published on every release.
  • ✅ 1.14.0: describe()/metaedit() honor changeId; operations.restore() restores the view (changes created after the target op are hidden from log()). See CHANGELOG.md.
  • ✅ 1.13.0: Write-path fixes — converge() works on a fresh instance, edit() no longer deletes new files write() created in the change it leaves, and moveChange()/rebase() derives the rebased change's content (new parent + the change's own edits) instead of keeping its tree verbatim. Not done: a rebase re-derives only the moved change's content, not its descendants', and conflicts are detected per whole file, not per line. See CHANGELOG.md.
  • ✅ 1.9.1 – 1.12.0: Operation-log reliability pass — undo()/operations.restore() correctness across write/snapshot/squash/rebase/abandon/edit, log() visibility fixes, and recording gaps in descendant rebases and conflict-resolution ops (issues #37–#39, #41, #43, #45, #48). See CHANGELOG.md for the full per-fix breakdown.

See ROADMAP.md for detailed plans, and CHANGELOG.md for the release history.


Installation & Environment

Requirements

  • Node.js: 26.0.0 or higher, matching engines.node and CI (uses the Web Crypto global, stabilized in Node 20 — the family floor is higher than the functional minimum)
  • Browsers: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
  • Optional: Git CLI (for JJ CLI interop testing)

Dependencies

Required:

  • protobufjs - For JJ repository format encoding

Peer Dependencies (optional):

  • isomorphic-git - For Git backend support
  • @isomorphic-git/lightning-fs - For browser filesystem

Browser Considerations

  • Uses IndexedDB or OPFS for storage
  • Remote operations require CORS proxy for most Git hosts
  • Memory limits apply (use limit options for large repos)

Security model

isomorphic-jj is a headless reimplementation of JJ's model, not a wrapper around the jj or git binaries — that architectural choice is also its main security property. It does not run a sandbox and does not vet the repositories you point it at.

What isomorphic-jj guarantees:

  • No shell-out, ever. The library never spawns a child process to run git, jj, or any other binary — repository paths, branch/bookmark names, and commit messages are handled as plain JS values through isomorphic-git's and protobufjs's pure-JS encoders, never interpolated into a shell command string. This removes the whole class of shell-injection risk that a CLI-wrapping library (like simple-git) has to defend against by construction, not by escaping.
  • Storage writes are atomic. jj-operation-store.js, jj-view-store.js, and jj-tree-state.js route every write through a shared atomicWriteFile() temp+rename helper, and same-process concurrent writes to the same path are serialized through a path-keyed async mutex — a crash or a racing autosnapshot mid-write cannot leave a truncated or undecodable store file. Fixed in PR #19 (0.2.0); scoped to same-process concurrency — cross-process locking is not yet implemented (see "still yours" below).

What is still yours:

  • Remote authentication and transport trust. git.push()/git.fetch() delegate to isomorphic-git's HTTP client (and, in the browser, whatever CORS proxy you configure) — isomorphic-jj neither stores nor validates credentials, and does not verify a remote server's identity beyond what that HTTP layer does. A misconfigured or malicious CORS proxy sees everything a normal Git remote would.
  • Custom merge drivers run arbitrary code with full process access. mergeDrivers.register() lets you register a function per file pattern ('*.json', 'package.json', …); a registered driver — yours or a dependency's — executes with the same privileges as the rest of your process during every merge that touches a matching path. isomorphic-jj does not sandbox driver execution; treat a third-party merge driver the same as any other third-party code you'd require().
  • Cross-process locking is not implemented. The same-process mutex above does not protect a repository opened by two separate OS processes at once (e.g. two CLI invocations, or a CLI run concurrently with a long-running server process) — don't rely on isomorphic-jj to serialize those for you.
  • No allowlisting of untrusted repository content. Cloning or importing a Git history runs its objects through the same parsing code regardless of whether you trust the source, matching isomorphic-git's own model — this library adds no additional vetting layer.

Contributing

We welcome contributions! Here's how to help:

Before opening a PR:

  1. Open an issue to discuss your use case
  2. Follow "porcelain over plumbed backends" design
  3. Include tests and TypeScript types
  4. Update documentation

Development setup:

git clone https://github.com/johnhenry/isomorphic-jj
cd isomorphic-jj
npm install
npm test              # Run tests
npm run typecheck     # Check types
npm run lint          # Check code style

Areas needing help:

  • Revset parser/evaluator enhancements
  • Conflict resolution algorithms
  • Browser storage optimizations
  • Documentation and examples
  • VS Code extension

FAQ

Q: Do I need to learn JJ to use this? A: Basic familiarity helps. Key concepts: working copy IS a commit (no staging), changes have stable IDs, operation log enables fearless undo, bookmarks are for pushing not local navigation.

Q: Can Git users collaborate with me? A: Yes! Colocated repos expose normal Git commits. Git users never see JJ metadata.

Q: What's the performance like? A: Comparable to isomorphic-git for Git operations. JJ metadata (JSON) is fast in Node, acceptable in browsers. Large histories need pagination.

Q: Does this support all JJ features? A: Broad semantic parity (tracks Jujutsu through v0.45.1), not every CLI/UX detail — it's a library, not a terminal UI. See ROADMAP.md for what's planned and what's deliberately out of scope.

Q: Can I migrate my Git repo? A: Yes! jj.git.init() works on existing Git repositories.

Q: Why not just use Git? A: JJ's model genuinely improves common workflows—stable change IDs, fearless undo, no staging confusion, conflicts as data instead of blockers.


Family

isomorphic-jj has no other package in the @johnhenry npm scope yet — it consumes and is consumed by projects outside that scope. Those relationships are still concrete enough to document precisely, so they're recorded here rather than under a vaguer "see also".

  • JJHub — a Jujutsu-native overlay on GitHub, and the closest thing this library has to a downstream consumer: it calls createJJ() directly (not a wrapped or forked copy) to get stable change IDs and stacked PRs on top of existing GitHub repos, platform-wide undo, and an MCP server exposing the same fearless-undo, no-staging-area workflow to coding agents. JJHub is a private Erisera product, not a @johnhenry/* package — install its CLI with npm i -g jjhub.
  • isomorphic-git — a real, non-optional dependency: this library's Git backend support (jj.git.init/clone/push/fetch) is built directly on isomorphic-git's pure-JS Git object model, not a fork or reimplementation of it. Peer dependency, not bundled — see Installation & Environment.

Prior art

  • Jujutsu — the original JJ version control system this library reimplements the semantics of (headlessly, in JS; not a wrapper around the jj binary).
  • simple-git — a Git CLI wrapper for Node.js; unlike isomorphic-jj, it shells out to the git binary rather than reimplementing the object model in JS.
  • Git plumbing vs porcelain — the architecture pattern this library's own layering follows.

License

MIT © John Henry


Acknowledgments

Built on the shoulders of: