isomorphic-jj 1.8.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,376 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.2 — 2026-09-26 — auto-snapshot mtime comparison (#41)
4
+
5
+ ### Fixed
6
+
7
+ - **`getModifiedFiles()`/`snapshot()` no longer treat every previously-tracked
8
+ file as "modified" on every call (#41).** Both compared `fs.stat().mtime`
9
+ values with `!==`. `mtime` is a `Date`, and `fs.promises.stat()` returns a
10
+ fresh `Date` instance on every call — two stats of the same, unchanged file
11
+ are never `===`/`!==`-equal even when the underlying timestamp is
12
+ identical (object identity, not value equality). Fixed by normalizing both
13
+ sides through a new `mtimeMs()` helper before comparing, and by storing
14
+ tracked mtimes as plain epoch-ms numbers going forward (numbers round-trip
15
+ through the JSON-based `Storage` persistence exactly, unlike `Date`, which
16
+ serializes to an ISO string and never comes back as a `Date` on reload).
17
+ `mtimeMs()` deliberately duck-types the Date case (checks for a `.getTime`
18
+ method) rather than using `instanceof Date`: under Jest's
19
+ `--experimental-vm-modules`, each test file's `Date` constructor can be a
20
+ different realm than the one `fs`'s internal `Stats` object was built
21
+ with, so a real `Date` from `fs.promises.stat()` can fail `instanceof
22
+ Date` — silently reintroducing the exact bug this fix is for, only inside
23
+ the test suite that's supposed to catch it.
24
+
25
+ **Real-world impact, not just a wrong return value.** Every repository
26
+ created with a Git backend wraps `graph.updateChange()` in middleware that
27
+ syncs to Git and mints a new commit (`syncChangeToGit()`). Because
28
+ `autoSnapshotWorkingCopy()` — which runs before `status()`/`describe()`/
29
+ `diff()`/`read()`/`file.*` — treated the file list as "always modified", it
30
+ called `graph.updateChange()` on essentially every one of those calls even
31
+ when nothing on disk had changed, silently minting a spurious Git commit
32
+ and changing the working-copy change's `commitId` each time. This is
33
+ exactly what forced the `{ autoSnapshot: false }` workaround added in
34
+ `tests/integration/undo-restore-residuals.test.js` for issue #37(c) (see
35
+ PR #40) — that test's own before/after `commitId` comparisons were being
36
+ contaminated by these spurious commits. That workaround has been removed
37
+ now that the underlying bug is fixed; the test passes with auto-snapshot
38
+ enabled.
39
+
40
+ **Why existing tests never caught this.** The in-memory `MockFS` fixture
41
+ used across almost this whole test suite stores `mtime` as a plain
42
+ `Date.now()` number, not a `Date` instance — numbers compare by value with
43
+ `!==`, so `MockFS`-backed tests never exercised the object-identity bug a
44
+ real filesystem's `Date`-returning `stat()` hits on every call. Added
45
+ regression tests in `tests/unit/core/working-copy.test.js` using a
46
+ `RealDateStatFS` wrapper that returns a fresh `Date` instance per `stat()`
47
+ call (mirroring real `fs` semantics) and asserting that two consecutive
48
+ `getModifiedFiles()`/`snapshot()` calls with no real file changes in
49
+ between report zero modified/added/deleted files. Verified against a
50
+ negative control: reverting just the comparison fix reproduces exactly the
51
+ "every tracked file looks modified" symptom described in #41.
52
+
53
+ ### Testing
54
+
55
+ 1839 tests passing (2 new regression tests for #41), including the
56
+ previously-`autoSnapshot: false`-gated issue #37(c) test now passing with
57
+ auto-snapshot enabled; lint (0 errors), format:check, typecheck, and build
58
+ all green; all 12 `examples/*.mjs` run clean.
59
+
60
+ ## 1.9.1 — 2026-09-26 — undo/restore/abandon/squash follow-ups
61
+
62
+ Three bug-fix follow-ups to 1.9.0's undo/abandon/squash work (#29/#30),
63
+ found by continued use of the same graph/working-copy machinery. Grouped
64
+ into one patch release because all three touch overlapping code
65
+ (`computeGraphReversal()`/`applyGraphReversal()`, the `all()` revset, and
66
+ the synthetic-empty-change pattern `new()`/`squash()` share).
67
+
68
+ ### Fixed
69
+
70
+ - **`restore()`/`undo()` now record their own `changeSnapshot`, so undoing
71
+ a restore (or undoing an undo) actually reverts something (#37a).**
72
+ 1.9.0 taught `describe()`/`squash()`/`abandon()` to snapshot the
73
+ ChangeGraph state they were about to overwrite so a later `undo()` could
74
+ put it back — but `undo()` and `operations.restore()` themselves never
75
+ did the same for THEIR OWN oplog entries. Repro: `describe("old")` →
76
+ `describe("new")` → `restore(<op before the second describe>)` (now
77
+ "old") → `undo()`. Expected: back to "new". Actual: still "old", because
78
+ the restore's own oplog entry carried no `changeSnapshot` for
79
+ `computeGraphReversal()` to find — undoing it moved the working-copy
80
+ pointer/files but left the change record exactly as the restore had
81
+ left it. Both `undo()` and `restore()` now capture ground-truth "what
82
+ am I about to overwrite" (the same way `undo()` already did for
83
+ `redo()`'s benefit, under `redoChangeSnapshot`) and record it as their
84
+ own `changeSnapshot`. Extended `redo()` the same way for symmetry, since
85
+ it shares the identical gap.
86
+ - **`log()` no longer lists changes orphaned by an undone `new()`/`squash()`
87
+ (#37b).** `undo()`/`redo()`/`operations.restore()` deliberately never
88
+ delete a change record a reverted `new()`/`squash()` created — they only
89
+ un-reference it (matching real jj's "hidden, not gone" model for
90
+ unreachable commits, and this package's own history-editing tests rely
91
+ on rebasing back onto one still working) — but `log()`'s default
92
+ `all()` revset was a flat `graph.getAll()`, so the orphan kept showing
93
+ up as an ordinary visible change forever. `all()`/`builtin_log()` now
94
+ exclude changes that are BOTH unreachable from every tracked head
95
+ (working copy, bookmarks, tags — the closest available proxy for real
96
+ jj's explicitly-maintained View heads, since this package doesn't thread
97
+ the operation log's `view.heads` through to the revset engine) AND
98
+ "discardable" (no file diff versus its own parent, and undescribed —
99
+ including this package's own `'(no description)'`/`'(no description
100
+ set)'` sentinel strings, which `new()`/`squash()` write when no message
101
+ is given). The change record itself is still resolvable directly by its
102
+ change id, matching real jj's hidden-not-gone semantics for unreachable
103
+ commits. Merge commits are conservatively never treated as "empty" by
104
+ this rule.
105
+ - **`undo()`/`restore()` now restore the ORIGINAL commit id instead of
106
+ minting a new one (#37c).** Reverting a `changeSnapshot` went through
107
+ the same `graph.updateChange()` every normal mutation uses, which fires
108
+ the git-sync middleware and creates a brand-new commit from the
109
+ *current* tree — even though the snapshot being reapplied already
110
+ carried the correct, original commit id from before the reverted
111
+ operation ran. Since undo/restore are pure reverts, not edits, this
112
+ undermined the "change id survives, commit id only changes on content
113
+ edit" story for the revert itself. `applyGraphReversal()` (used by both
114
+ `undo()` and `restore()`) and `redo()`'s equivalent now write the
115
+ snapshot verbatim via the un-wrapped graph store, bypassing the git-sync
116
+ hook entirely and restoring the original commit id byte-for-byte.
117
+ - **`abandon()` of the working-copy change now moves `@` off it (#38).**
118
+ `abandon(<changeId of @>)` correctly marked the change abandoned but
119
+ left `@` pointing at it — `status()`/`log()` kept showing the working
120
+ copy sitting on a change flagged abandoned. Real jj instead moves `@` to
121
+ a new empty change on the abandoned change's own parent(s). `abandon()`
122
+ now detects that case and calls `new({ parents: change.parents })` —
123
+ reusing `new()`'s own "create an empty change with given parents" logic
124
+ rather than duplicating it — which is exactly the workaround callers
125
+ previously had to reach for themselves.
126
+ - **`squash()` of the working-copy change no longer gives the new `@` an
127
+ empty `fileSnapshot` (#39).** 1.9.0 fixed `squash()` to actually move
128
+ the source's file content into the destination (#30), but when the
129
+ squashed source WAS the working-copy change, the new `@` squash creates
130
+ afterward was still hard-coded to `fileSnapshot: {}` instead of starting
131
+ from the destination's (now-merged) tree — so `status()`/`snapshot()`
132
+ saw every on-disk file as untracked/new until something rewrote it and
133
+ triggered a fresh auto-snapshot. The new `@` now starts as a copy of the
134
+ destination's `fileSnapshot`, matching real `jj squash` (the new
135
+ working-copy change looks identical to its new parent until you touch
136
+ something), and the working directory is synced to match too (a no-op
137
+ in the common case where source's parent already was the destination,
138
+ but necessary whenever they aren't adjacent).
139
+
140
+ ### Testing
141
+
142
+ 1837 tests passing, including 12 new regression tests added by this pass;
143
+ lint (0 errors), format:check, typecheck, and build all still green; all
144
+ 12 `examples/*.mjs` run clean. New test files:
145
+ `tests/integration/undo-restore-residuals.test.js` (#37a/b/c),
146
+ `tests/integration/abandon-working-copy.test.js` (#38),
147
+ `tests/integration/squash-working-copy-file-snapshot.test.js` (#39). Each
148
+ fix was verified against a negative control (reverting just that fix
149
+ reproduces the exact wrong behavior described in its issue) before moving
150
+ to the next.
151
+
152
+ ## 1.9.0 — merge the scoped and unscoped version lineages
153
+
154
+ No code change — a deliberate, one-time exception to this family's usual
155
+ "version restarts at 0.0.0 on a scope move" convention, forced by making
156
+ the unscoped `isomorphic-jj` mirror permanent (see the unscoped-mirror
157
+ workflow's own history) rather than a one-off "final release" bridge:
158
+ `isomorphic-jj`'s unscoped name already had real published history through
159
+ **1.8.0** from before the `@johnhenry/*` rename, while the scoped package
160
+ had separately restarted at 0.0.0 and reached 0.4.0. Both names publish
161
+ the same version on every release from here on, so the shared version has
162
+ to sit above BOTH names' currently-published maximum, or the unscoped
163
+ mirror's `npm install isomorphic-jj` would move npm's `latest` tag
164
+ *backward* in version number the moment it published. `1.9.0` is one
165
+ minor above the unscoped side's existing 1.8.0 ceiling — chosen over a
166
+ patch bump because this release also carries real new functionality
167
+ (0.3.0/0.4.0's `jj converge`, N-way divergence resolution, and the four
168
+ other jj-v0.45 fixes), not just the version-numbering fix itself.
169
+
170
+ Going forward, `publish.yml`/`publish-unscoped.yml` both preflight-check
171
+ that the tag being published is strictly greater than whatever's
172
+ currently live under EITHER name before publishing either one, so this
173
+ can't silently regress again.
174
+
175
+ ## 0.4.0 — `converge()` resolves any number of divergent copies
176
+
177
+ Follow-up to 0.3.0's `converge()` (#32): it originally capped automatic
178
+ resolution at exactly two divergent copies, throwing `CONVERGE_AMBIGUOUS`
179
+ for a third. That cap is gone — `converge()` now resolves any number of
180
+ copies of one change id in a single call. Real jj's own CLI still only
181
+ converges one *change id* per invocation as of v0.45.1 (an explicit TODO
182
+ in its own source: "consider adding logic to deal with more than one
183
+ divergent change-id in one invocation"), which is what this package's
184
+ changeId-at-a-time API already matched — this follow-up instead closes
185
+ the gap in what happens *within* that one change id, where real jj's
186
+ underlying engine (`TruncatedEvolutionGraph`/`find_divergent_changes`)
187
+ already resolves every divergent revision together, not just a pair.
188
+
189
+ ### Changed
190
+
191
+ - **`converge()` is N-way, not pairwise.** The per-path resolution rule
192
+ generalizes directly: for each file, collect every DISTINCT value across
193
+ all copies that differs from the shared base. Zero distinct changed
194
+ values means nobody touched it; exactly one means only one side (or
195
+ several copies that happened to make the identical change) touched it,
196
+ and that value wins; two or more is a genuine conflict between that many
197
+ copies. `CONVERGE_AMBIGUOUS` no longer exists — there is nothing left for
198
+ it to guard against.
199
+ - **A conflict with more than two disagreeing copies carries a new `sides`
200
+ shape.** Every other conflict in this codebase (from `merge()`/
201
+ `rebase()`) is inherently two-sided and keeps `sides: { base, left,
202
+ right }`. An N-way `converge()` conflict instead gets `sides: { base,
203
+ versions }`, where `versions` is `Array<{ commitId, content }>` — one
204
+ entry per distinct disagreeing value, so a caller can tell which
205
+ divergent copy contributed which content without decoding a fixed
206
+ left/right pair. Added `ConflictModel.createNWayConflict()` as the one
207
+ place that builds this shape.
208
+
209
+ ## 0.3.0 — Track jj through v0.45.1
210
+
211
+ Five fixes/additions found via real usage of isomorphic-jj in a browser
212
+ showcase app (ORRERY), spanning the browser entry, op-log/undo, and
213
+ graph-rewrite (squash/rebase/abandon) machinery — grouped into one release
214
+ because several of them touch the same shared machinery. Also closes the
215
+ version gap to jj v0.45.1 (v0.45.0 added `jj converge`; v0.45.1 was
216
+ patch-only, no JS-relevant changes).
217
+
218
+ ### Fixed
219
+
220
+ - **The `/browser` entry can now actually run in a bundle (#28).**
221
+ `createBrowserFS()` called `require('@isomorphic-git/lightning-fs')`,
222
+ which doesn't exist in an ESM browser bundle; it's now an async
223
+ `import()`, and also accepts an injected `fs` (an instance, or a
224
+ LightningFS-shaped constructor) so callers can bring `memfs` or their
225
+ own. The four protobuf-backed stores (`JJCheckout`, `JJTreeState`,
226
+ `JJOperationStore`, `JJViewStore`) loaded their `.proto` schemas via
227
+ `protobuf.load()` against a `__dirname`-derived filesystem path, which
228
+ has no meaning in a bundle; they now build a `protobufjs` `Root` from
229
+ precompiled reflection JSON (`src/protos/*.json.js`, regenerated by
230
+ `npm run build:protos`, wired into `npm run build`) via the
231
+ dependency-free `protobufjs/light.js` entry point. Bundling a real
232
+ scratch app (`esbuild --platform=browser`) against the fix surfaced
233
+ several more Node-only assumptions that block a real browser run, fixed
234
+ the same way (isomorphic, not bundler-specific hacks): a bare
235
+ `import path from 'path'` (→ `src/utils/posix-path.js`, a tiny
236
+ POSIX-only join/dirname/resolve — this also fixes a latent Windows bug,
237
+ since Node's `path.join` uses backslashes there and git/jj paths must
238
+ never); `import crypto from 'crypto'` / dynamic `import('crypto')` (→
239
+ `randomHex()` in `id-generation.js`, built on the `crypto.getRandomValues`
240
+ global that file already used elsewhere); direct `Buffer.from`/
241
+ `Buffer.isBuffer` calls (→ `src/utils/bytes.js`, which only ever needs
242
+ `Uint8Array` and prefers a real `Buffer` when present so existing Node
243
+ behavior is unchanged); `fs.promises.mkdir(p, { recursive: true })`
244
+ (LightningFS silently ignores `recursive` and throws `ENOENT` on a
245
+ missing parent — `src/utils/mkdirp.js` walks and creates each segment
246
+ instead); and a few `readFile`/`writeFile` calls using the hyphenated
247
+ `'utf-8'` spelling (LightningFS only accepts the exact string `'utf8'`).
248
+ Verified end-to-end by bundling a scratch app and running it in real
249
+ headless Chromium: createBrowserFS → createJJ → git.init → write →
250
+ describe → log all completed successfully against IndexedDB-backed
251
+ LightningFS.
252
+ - **`undo()`/`operations.restore()` now revert graph rewrites, and `undo()`
253
+ walks progressively further back instead of undoing itself (#29).**
254
+ `squash()`/`moveChange()` (rebase)/`abandon()` never recorded a
255
+ changeSnapshot, so undo() had nothing to restore their in-place
256
+ ChangeGraph mutations — the DAG stayed rewritten after "undoing" them.
257
+ They now do, the same mechanism issue #12 introduced for `describe()`.
258
+ Calling `undo()` twice also only undid the undo: since `undo()` is
259
+ itself recorded as a new operation, a second `undo()` naively targeting
260
+ the log's raw parent chain landed back on the just-undone operation
261
+ instead of stepping further into the past — contradicting real jj's
262
+ documented behavior (`jj-undo(1)`: "If jj undo is used repeatedly, it
263
+ will restore increasingly older operations"). A new `resolveUndoTarget()`
264
+ recognizes 'undo'/'redo' bookkeeping operations in the log and steps
265
+ past them. `operations.restore()` previously only touched bookmarks and
266
+ the working-copy pointer; it now shares the same
267
+ `computeGraphReversal()`/`applyGraphReversal()` primitive as `undo()`/
268
+ `redo()` to fully revert the graph, files, and conflict state to any
269
+ earlier operation. Also fixed in passing: `undo()` used to
270
+ unconditionally clear ALL conflicts whenever the operation being undone
271
+ hadn't recorded its own conflict snapshot (true for every op except
272
+ `merge()`) — undoing an unrelated `describe()` silently wiped conflicts
273
+ from an earlier, unrelated `merge()`.
274
+ - **`squash()` merges file content, not just descriptions (#30).** It
275
+ combined the two descriptions into the destination but never moved the
276
+ source change's file contents into it — the squashed change's tree was
277
+ left completely unchanged. It now computes what source actually changed
278
+ relative to its own parent (added/modified/deleted paths) and folds just
279
+ that diff onto dest, rather than overwriting dest's whole snapshot with
280
+ source's.
281
+ - **`abandon(changeId)` re-parents children (#30).** It set the abandoned
282
+ flag but left children pointing at the abandoned commit; it now
283
+ re-parents every child onto the abandoned change's own parent(s)
284
+ (matching real `jj abandon`), including making a child a root when the
285
+ abandoned change had no parent.
286
+ - **`edit()`/`new()` fully sync the working copy (#30).** They wrote the
287
+ target change's files but only ever overlaid them on top of whatever was
288
+ already on disk, so a file present in the previously-checked-out change
289
+ but absent from the target leaked into the next snapshot. Both now go
290
+ through a shared `syncWorkingCopyFiles()` helper that deletes every
291
+ currently-tracked file NOT in the target snapshot in addition to writing
292
+ everything the target has. `new()` previously didn't touch the working
293
+ directory at all when targeting a change other than the current head's
294
+ default parent; it now checks the target out too.
295
+ - **`rebase()` performs a real three-way merge and detects conflicts
296
+ (#31).** Rebasing a change onto a sibling that edited the same lines
297
+ kept the rebased change's file contents verbatim and recorded no
298
+ conflict. `moveChange()` (which `rebase()` delegates to) now runs the
299
+ same `detectConflicts()` three-way merge `merge()` already used, against
300
+ (old base = the change's own parent before reparenting, old commit = the
301
+ change's own content, new base = the new parent's content), recording
302
+ any detected conflict the same way `merge()` does.
303
+
304
+ ### Added
305
+
306
+ - **`converge(changeId)` — auto-resolve divergent commits (#32, jj
307
+ v0.45.0).** A change is divergent when more than one visible commit
308
+ shares its change id; this package's `divergent()` revset already
309
+ modeled that (`c.divergent === true`, plus a changeId-occurrence count
310
+ across the graph), but nothing ever actually produced a divergent
311
+ change — the flag was set nowhere, and `ChangeGraph.nodes` being a
312
+ `Map<changeId, change>` made two entries genuinely sharing a changeId
313
+ structurally impossible via `addChange()`. `ChangeGraph.addDivergentCopy()`
314
+ is the actual producer: it stores a second commit under a synthetic
315
+ internal key while keeping its own `changeId` field equal to the
316
+ original's, which is what activates `divergent()` for real (previously
317
+ dead code) and gives `converge()` something to resolve.
318
+ `converge({ changeId })` (a bare changeId string also works) attempts
319
+ automatic resolution using the same three-way-merge/conflict-detection
320
+ machinery `rebase()` uses (#31) — reused, not duplicated a third time in
321
+ this codebase. On success, it creates one replacement commit (a fresh
322
+ commit id, the merged file content, combined description) under the same
323
+ change id and drops the superseded copy. On a genuine per-path conflict,
324
+ it's reported as data (`{ resolved: false, conflicts }`), matching
325
+ `merge()`'s own non-throwing convention for conflicts, and the conflict
326
+ is recorded the normal way. Throws `NOT_DIVERGENT` when there's nothing
327
+ to converge, and `CONVERGE_AMBIGUOUS` when more than two visible copies
328
+ exist — automatic resolution only attempts a pairwise merge, matching
329
+ real jj's non-interactive mode aborting resolution it can't disambiguate
330
+ rather than guessing.
331
+ - **`ChangeGraph.deleteChange()`** — outright removal of a change record,
332
+ as distinct from `abandon()`'s soft-delete (`abandoned: true`) flag.
333
+ Added for undo()/redo()/operations.restore() to reverse an `addChange()`
334
+ when winding the graph back (#29), though in practice undo() never calls
335
+ it — see the doc comment on `computeGraphReversal()` for why a newly
336
+ created change is deliberately left in the graph (unreferenced, not
337
+ deleted) rather than torn down, matching real jj's "hidden, not gone"
338
+ model for unreachable commits.
339
+
340
+ ### Judgment calls worth flagging
341
+
342
+ - **`write()`'s commit id/fileSnapshot staying stale until the next
343
+ snapshot is intentional, not a bug (#30).** jj has no staging area, but
344
+ real jj also doesn't recompute a tree/commit hash on every file write —
345
+ only when an actual `jj` command runs and snapshots the working copy.
346
+ `write()` is the equivalent of editing a file with your OS's own tools
347
+ outside of jj; `describe()`/`new()`/`edit()`/`status()`/etc. are the
348
+ equivalent of running a jj command. Documented rather than changed.
349
+ - **Divergent copies are modeled as "another commit sharing a changeId",
350
+ not "two independent changeIds grouped together" (#32)** — matching what
351
+ the pre-existing (if previously non-functional) `divergent()` revset
352
+ already implied, rather than inventing a parallel representation.
353
+ Converge only handles the common case where the two copies share the
354
+ same parent(s) (a real divergence — two rewrites of one prior position
355
+ in history) and are leaves (no children of their own); this package's
356
+ graph model references parents by changeId, not by a more granular
357
+ commit-level pointer the way real jj's does, so a divergent copy's own
358
+ independent descendants aren't distinguishable from the primary copy's —
359
+ documented as a known limitation of the simplified graph model rather
360
+ than a full rewrite of how parent/child edges are stored.
361
+
362
+ ### Testing
363
+
364
+ 1823 tests passing (was 1732 at the start of this pass); lint (0 errors),
365
+ format:check, typecheck, and build all still green. New test files:
366
+ `tests/integration/undo-redo-graph-rewrites.test.js`,
367
+ `tests/integration/squash-merge-abandon-reparent-file-sync.test.js`,
368
+ `tests/integration/rebase-conflict-detection.test.js`,
369
+ `tests/integration/converge.test.js`, plus new unit coverage for
370
+ `src/utils/posix-path.js`, `src/utils/bytes.js`, `src/utils/mkdirp.js`,
371
+ and `ChangeGraph`'s `deleteChange()`/`addDivergentCopy()`/
372
+ `getDivergentSiblings()`/`deleteDivergentCopy()`.
373
+
3
374
  ## 0.2.0 — 2026-09-02
4
375
 
5
376
  The first `@johnhenry/isomorphic-jj` release since 0.1.0, and the first
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 [fullname]
3
+ Copyright (c) 2025 John Henry
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # @johnhenry/isomorphic-jj
2
2
 
3
- > **This package has moved to `@johnhenry/isomorphic-jj`.** This is the final release under the old name.
4
-
5
3
  > **Jujutsu version control for JavaScript**—stable change IDs, fearless undo, and no staging area. Works in Node.js and browsers.
6
4
 
7
5
  Full documentation: [opensource.johnhenry.me/isomorphic-jj](https://opensource.johnhenry.me/isomorphic-jj/)
@@ -39,6 +37,29 @@ await jj.undo();
39
37
 
40
38
  ---
41
39
 
40
+ ## Contents
41
+
42
+ - [Try it in 60 seconds](#try-it-in-60-seconds)
43
+ - [Quick Start](#quick-start)
44
+ - [Examples](#examples)
45
+ - [What's New in v1.0](#whats-new-in-v10)
46
+ - [How is this Different?](#how-is-this-different)
47
+ - [Features](#features)
48
+ - [Use Cases](#use-cases)
49
+ - [API Reference](#api-reference)
50
+ - [Why isomorphic-jj?](#why-isomorphic-jj)
51
+ - [Architecture](#architecture)
52
+ - [Project Status](#project-status)
53
+ - [Installation & Environment](#installation--environment)
54
+ - [Security model](#security-model)
55
+ - [Contributing](#contributing)
56
+ - [FAQ](#faq)
57
+ - [Family](#family)
58
+ - [License](#license)
59
+ - [Acknowledgments](#acknowledgments)
60
+
61
+ ---
62
+
42
63
  ## Try it in 60 seconds
43
64
 
44
65
  ### CLI
@@ -86,7 +107,7 @@ console.log(await jj.log({ limit: 5 }));
86
107
  import { createJJ } from '@johnhenry/isomorphic-jj';
87
108
  import { createBrowserFS } from '@johnhenry/isomorphic-jj/browser';
88
109
 
89
- const fs = createBrowserFS({ name: 'my-repo' });
110
+ const fs = await createBrowserFS({ name: 'my-repo' });
90
111
  const jj = await createJJ({ fs, dir: '/repo' });
91
112
  // Same API as Node from here on — see examples/12-browser.mjs.
92
113
  ```
@@ -189,7 +210,7 @@ isomorphic-jj v1.0 now implements virtually all commonly-used JJ CLI commands:
189
210
  See [ROADMAP.md](./ROADMAP.md) for the release-by-release feature comparison and what's still planned.
190
211
 
191
212
  ### Testing & Quality
192
- - **1730 tests** passing — ~97% statement / 91% branch coverage
213
+ - **1823 tests** passing — ~97% statement / 91% branch coverage
193
214
  - **100% backward compatible** - All existing code continues to work
194
215
  - **Zero breaking changes** - Deprecated features show warnings but still function
195
216
 
@@ -254,7 +275,7 @@ import git from 'isomorphic-git';
254
275
  import http from 'isomorphic-git/http/web';
255
276
 
256
277
  // Create browser filesystem (uses IndexedDB)
257
- const fs = createBrowserFS({ name: 'my-repo' });
278
+ const fs = await createBrowserFS({ name: 'my-repo' });
258
279
 
259
280
  const jj = await createJJ({ fs, dir: '/repo', git, http });
260
281
  await jj.git.init({ userName: 'User', userEmail: 'user@example.com' });
@@ -732,7 +753,7 @@ import { createBrowserFS, requestPersistentStorage } from '@johnhenry/isomorphic
732
753
  const persistent = await requestPersistentStorage();
733
754
 
734
755
  // Create filesystem with IndexedDB backend
735
- const fs = createBrowserFS({ backend: 'idb', name: 'my-repo' });
756
+ const fs = await createBrowserFS({ backend: 'idb', name: 'my-repo' });
736
757
 
737
758
  // Check browser capabilities
738
759
  import { detectCapabilities } from '@johnhenry/isomorphic-jj/browser';
@@ -905,8 +926,8 @@ const jj: JJ = await createJJ(options: CreateJJOptions);
905
926
  - **Repository**: `init()`, `status()`, `stats()`
906
927
  - **Files**: `write()`, `read()`, `cat()`, `move()` (deprecated for history), `remove()`, `listFiles()` | **Namespace**: `file.write()`, `file.show()`, `file.list()`, `file.move()`, `file.remove()`
907
928
  - **Changes**: `describe()`, `new()`, `amend()`, `commit()`, `edit()`, `show()`
908
- - **History**: `log()`, `obslog()`, `squash()`, `split()`, `rebase()`, `abandon()`, `unabandon()`
909
- - **Operations**: `undo()`, `operations.list()`, `operations.at()`
929
+ - **History**: `log()`, `obslog()`, `squash()`, `split()`, `rebase()`, `abandon()`, `unabandon()`, `converge()`
930
+ - **Operations**: `undo()`, `redo()`, `operations.list()`, `operations.at()`, `operations.restore()`
910
931
  - **Bookmarks**: `bookmark.list()`, `bookmark.set()`, `bookmark.move()`, `bookmark.delete()`
911
932
  - **Git**: `git.init()`, `git.fetch()`, `git.push()`, `git.import()`, `git.export()`
912
933
  - **Remotes**: `remote.add()`, `remote.fetch()`, `remote.push()`
@@ -999,10 +1020,10 @@ repo/
999
1020
 
1000
1021
  ## Project Status
1001
1022
 
1002
- **Current Version**: 0.2.0 (`@johnhenry/isomorphic-jj`; same library/API lineage as the old `isomorphic-jj`, which reached v1.7.0 before the rename — see the provenance note at the top of this file)
1003
- **Test Coverage**: 1730 tests passing, ~97% statements / 91% branches
1004
- **JJ CLI parity**: tracks Jujutsu through v0.44
1005
- **Status**: Actively developed; broad JJ CLI semantic parity, used in the examples/apps in this repo and in [JJHub](#related-projects). Not yet 1.0 — API surface can still shift; pin a version and read the CHANGELOG before upgrading.
1023
+ **Current Version**: 0.3.0 (`@johnhenry/isomorphic-jj`; same library/API lineage as the old `isomorphic-jj`, which reached v1.7.0 before the rename — see the provenance note at the top of this file)
1024
+ **Test Coverage**: 1823 tests passing, ~97% statements / 91% branches
1025
+ **JJ CLI parity**: tracks Jujutsu through v0.45.1
1026
+ **Status**: Actively developed; broad JJ CLI semantic parity, used in the examples/apps in this repo and in [JJHub](#family). Not yet 1.0 — API surface can still shift; pin a version and read the CHANGELOG before upgrading.
1006
1027
 
1007
1028
  **Completed:**
1008
1029
  - ✅ v0.1: Core JJ experience (stable IDs, undo, bookmarks, revsets)
@@ -1014,6 +1035,7 @@ repo/
1014
1035
  - ✅ 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](./CHANGELOG.md).
1015
1036
  - ✅ 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](./CHANGELOG.md) for what was deliberately left for a maintainer call (`jj run`, fetch-time remote tag/bookmark import).
1016
1037
  - ✅ 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](./CHANGELOG.md).
1038
+ - ✅ 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](./CHANGELOG.md).
1017
1039
 
1018
1040
  See [ROADMAP.md](./ROADMAP.md) for detailed plans, and [CHANGELOG.md](./CHANGELOG.md) for the release history.
1019
1041
 
@@ -1023,7 +1045,7 @@ See [ROADMAP.md](./ROADMAP.md) for detailed plans, and [CHANGELOG.md](./CHANGELO
1023
1045
 
1024
1046
  ### Requirements
1025
1047
 
1026
- - **Node.js**: 20.0.0 or higher (uses the Web Crypto global, stabilized in Node 20; Node 18 is EOL)
1048
+ - **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)
1027
1049
  - **Browsers**: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
1028
1050
  - **Optional**: Git CLI (for JJ CLI interop testing)
1029
1051
 
@@ -1044,6 +1066,58 @@ See [ROADMAP.md](./ROADMAP.md) for detailed plans, and [CHANGELOG.md](./CHANGELO
1044
1066
 
1045
1067
  ---
1046
1068
 
1069
+ ## Security model
1070
+
1071
+ isomorphic-jj is a headless reimplementation of JJ's model, not a wrapper
1072
+ around the `jj` or `git` binaries — that architectural choice is also its
1073
+ main security property. It does not run a sandbox and does not vet the
1074
+ repositories you point it at.
1075
+
1076
+ **What isomorphic-jj guarantees:**
1077
+
1078
+ - **No shell-out, ever.** The library never spawns a child process to run
1079
+ `git`, `jj`, or any other binary — repository paths, branch/bookmark
1080
+ names, and commit messages are handled as plain JS values through
1081
+ `isomorphic-git`'s and `protobufjs`'s pure-JS encoders, never interpolated
1082
+ into a shell command string. This removes the whole class of
1083
+ shell-injection risk that a CLI-wrapping library (like `simple-git`) has
1084
+ to defend against by construction, not by escaping.
1085
+ - **Storage writes are atomic.** `jj-operation-store.js`, `jj-view-store.js`,
1086
+ and `jj-tree-state.js` route every write through a shared
1087
+ `atomicWriteFile()` temp+rename helper, and same-process concurrent writes
1088
+ to the same path are serialized through a path-keyed async mutex — a
1089
+ crash or a racing autosnapshot mid-write cannot leave a truncated or
1090
+ undecodable store file. Fixed in [PR #19](https://github.com/johnhenry/isomorphic-jj/pull/19) (0.2.0); scoped to
1091
+ same-process concurrency — cross-process locking is not yet implemented
1092
+ (see "still yours" below).
1093
+
1094
+ **What is still yours:**
1095
+
1096
+ - **Remote authentication and transport trust.** `git.push()`/`git.fetch()`
1097
+ delegate to `isomorphic-git`'s HTTP client (and, in the browser, whatever
1098
+ CORS proxy you configure) — isomorphic-jj neither stores nor validates
1099
+ credentials, and does not verify a remote server's identity beyond what
1100
+ that HTTP layer does. A misconfigured or malicious CORS proxy sees
1101
+ everything a normal Git remote would.
1102
+ - **Custom merge drivers run arbitrary code with full process access.**
1103
+ `mergeDrivers.register()` lets you register a function per file
1104
+ pattern (`'*.json'`, `'package.json'`, …); a registered driver — yours or
1105
+ a dependency's — executes with the same privileges as the rest of your
1106
+ process during every merge that touches a matching path. isomorphic-jj
1107
+ does not sandbox driver execution; treat a third-party merge driver the
1108
+ same as any other third-party code you'd `require()`.
1109
+ - **Cross-process locking is not implemented.** The same-process mutex
1110
+ above does not protect a repository opened by two separate OS processes
1111
+ at once (e.g. two CLI invocations, or a CLI run concurrently with a
1112
+ long-running server process) — don't rely on isomorphic-jj to serialize
1113
+ those for you.
1114
+ - **No allowlisting of untrusted repository content.** Cloning or importing
1115
+ a Git history runs its objects through the same parsing code regardless
1116
+ of whether you trust the source, matching `isomorphic-git`'s own model —
1117
+ this library adds no additional vetting layer.
1118
+
1119
+ ---
1120
+
1047
1121
  ## Contributing
1048
1122
 
1049
1123
  We welcome contributions! Here's how to help:
@@ -1085,7 +1159,7 @@ A: Yes! Colocated repos expose normal Git commits. Git users never see JJ metada
1085
1159
  A: Comparable to isomorphic-git for Git operations. JJ metadata (JSON) is fast in Node, acceptable in browsers. Large histories need pagination.
1086
1160
 
1087
1161
  **Q: Does this support all JJ features?**
1088
- A: Broad semantic parity (tracks Jujutsu through v0.44), not every CLI/UX detail — it's a library, not a terminal UI. See [ROADMAP.md](./ROADMAP.md) for what's planned and what's deliberately out of scope.
1162
+ 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](./ROADMAP.md) for what's planned and what's deliberately out of scope.
1089
1163
 
1090
1164
  **Q: Can I migrate my Git repo?**
1091
1165
  A: Yes! `jj.git.init()` works on existing Git repositories.
@@ -1095,22 +1169,35 @@ A: JJ's model genuinely improves common workflows—stable change IDs, fearless
1095
1169
 
1096
1170
  ---
1097
1171
 
1098
- ## Related Projects
1099
-
1100
- ### JJHub
1101
-
1102
- [JJHub](https://jjhub.erisera.com) is a Jujutsu-native overlay on GitHub, built
1103
- on this library (it uses `createJJ` directly): stable change IDs and stacked
1104
- PRs on top of your existing GitHub repos, platform-wide undo, and an MCP
1105
- server so coding agents get the same fearless-undo, no-staging-area workflow.
1106
- Install the CLI with `npm i -g jjhub`.
1107
-
1108
- ### Others
1109
-
1110
- - [isomorphic-git](https://isomorphic-git.org/) - Pure JS Git implementation (our foundation)
1111
- - [Jujutsu](https://jj-vcs.github.io/jj/) - The original JJ version control system
1112
- - [simple-git](https://github.com/steveukx/git-js) - Git wrapper for Node.js
1113
- - [Git plumbing vs porcelain](https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain) - Architecture pattern we follow
1172
+ ## Family
1173
+
1174
+ isomorphic-jj has no other package in the `@johnhenry` npm scope yet — it
1175
+ consumes and is consumed by projects outside that scope. Those relationships
1176
+ are still concrete enough to document precisely, so they're recorded here
1177
+ rather than under a vaguer "see also".
1178
+
1179
+ - **[JJHub](https://jjhub.erisera.com)** — a Jujutsu-native overlay on GitHub,
1180
+ and the closest thing this library has to a downstream consumer: it calls
1181
+ `createJJ()` directly (not a wrapped or forked copy) to get stable change
1182
+ IDs and stacked PRs on top of existing GitHub repos, platform-wide undo,
1183
+ and an MCP server exposing the same fearless-undo, no-staging-area
1184
+ workflow to coding agents. JJHub is a private Erisera product, not a
1185
+ `@johnhenry/*` package — install its CLI with `npm i -g jjhub`.
1186
+ - **[`isomorphic-git`](https://github.com/isomorphic-git/isomorphic-git)** —
1187
+ a real, non-optional dependency: this library's Git backend support
1188
+ (`jj.git.init/clone/push/fetch`) is built directly on isomorphic-git's
1189
+ pure-JS Git object model, not a fork or reimplementation of it. Peer
1190
+ dependency, not bundled — see [Installation & Environment](#installation--environment).
1191
+
1192
+ ### Prior art
1193
+
1194
+ - [Jujutsu](https://jj-vcs.github.io/jj/) — the original JJ version control
1195
+ system this library reimplements the semantics of (headlessly, in JS; not
1196
+ a wrapper around the `jj` binary).
1197
+ - [simple-git](https://github.com/steveukx/git-js) — a Git CLI wrapper for
1198
+ Node.js; unlike isomorphic-jj, it shells out to the `git` binary rather
1199
+ than reimplementing the object model in JS.
1200
+ - [Git plumbing vs porcelain](https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain) — the architecture pattern this library's own layering follows.
1114
1201
 
1115
1202
  ---
1116
1203