isomorphic-jj 1.11.0 → 1.12.0
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 +94 -0
- package/dist/index.cjs +260 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +260 -16
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +219 -10
- package/src/core/revset-engine.js +41 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,99 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.12.0 — 2026-09-27 — op-recording gaps left by #43/#45's fixes (#48)
|
|
4
|
+
|
|
5
|
+
Follow-up to #43/#45 (below). Walking every op backward then forward now
|
|
6
|
+
lands correctly in 49 of 60 restores (up from before); the 11 misses were
|
|
7
|
+
five distinct gaps, all in the operation-recording machinery those fixes
|
|
8
|
+
introduced or depend on, plus one pre-existing bug in `operations.list()`
|
|
9
|
+
discovered while writing regression tests for the others.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **The descendant rebase performed by `edit()`/`snapshot()`/
|
|
14
|
+
`autoSnapshotWorkingCopy()` was never itself recorded in the operation
|
|
15
|
+
(R1).** `_rebuildDescendants()` mutates every rebased descendant via
|
|
16
|
+
`graph.updateChange()`, but had no way to hand its callers each
|
|
17
|
+
descendant's pre-rebase state — so `undo()`/`operations.restore()` had
|
|
18
|
+
nothing to reverse the rebase with, even though the ANCESTOR's own
|
|
19
|
+
content change was (already) correctly captured. Added an optional 4th
|
|
20
|
+
parameter, `outChangeSnapshot`, populated with `{[descendantId]:
|
|
21
|
+
fullRecordBeforeRebuild}` for every descendant actually mutated; all
|
|
22
|
+
three call sites now pass their own `changeSnapshot` object through so
|
|
23
|
+
it lands in the same operation record.
|
|
24
|
+
- **Restoring to a `conflicts.resolve()` (or `merge()`/`converge()`/
|
|
25
|
+
`moveChange()`) op could restore the resolved file but leave the
|
|
26
|
+
change's conflict state unresolved (R2).** `computeGraphReversal()`
|
|
27
|
+
recovers "conflict state right after `landingOp`" by scanning for a
|
|
28
|
+
LATER op's own "before" snapshot — which only exists if some later op
|
|
29
|
+
also touches conflicts. Conflict resolution is usually a one-off event
|
|
30
|
+
with nothing later re-touching the `ConflictModel` at all, so landing
|
|
31
|
+
exactly on it found no candidate and silently left whatever conflict
|
|
32
|
+
state already happened to be live untouched (file content usually
|
|
33
|
+
recovered correctly anyway, by coincidence — some unrelated later op
|
|
34
|
+
touching the same file's `changeSnapshot`). `conflicts.resolve()`,
|
|
35
|
+
`merge()`, `converge()` (unresolved case), and `moveChange()` now also
|
|
36
|
+
record `conflictsSnapshotAfter` — the result of the mutation THEY
|
|
37
|
+
performed — and `computeGraphReversal()` falls back to it when nothing
|
|
38
|
+
later reveals the answer.
|
|
39
|
+
- **A file changed directly on disk and picked up by
|
|
40
|
+
`autoSnapshotWorkingCopy()` (not the explicit `snapshot()` API) had no
|
|
41
|
+
"before" copy recorded anywhere (R3) — described as "silent
|
|
42
|
+
re-snapshot" when it also rebases descendants.** `autoSnapshotWorkingCopy()`
|
|
43
|
+
— the implicit reconciliation `status()`/`read()`/`describe()`/etc.
|
|
44
|
+
trigger on every call — mutated the graph (and rebased descendants) with
|
|
45
|
+
no operation recorded at all. A later `undo()`/`operations.restore()`
|
|
46
|
+
had nothing to reverse it with, and since the same function runs again
|
|
47
|
+
on every subsequent call with no drift left to detect, a real,
|
|
48
|
+
one-time content change looked exactly like it had simply never
|
|
49
|
+
happened. Now records a real operation (mirroring the explicit
|
|
50
|
+
`snapshot()` API, `description: 'auto-snapshot working copy'`) whenever
|
|
51
|
+
it actually refreshes content — gated on the same `added`/`modified`/
|
|
52
|
+
`deleted` check it already uses, so a no-op call still records nothing.
|
|
53
|
+
- **After `edit()` moves `@` to an older change, `log()` could hide a real,
|
|
54
|
+
undescribed tip with real work in it, and `edit()` recorded only its own
|
|
55
|
+
target in `view.heads`.** `_computeHiddenOrphans`'s `autoCreated ||`
|
|
56
|
+
branch (needed for two undo-artifact cases — see its doc comment) has no
|
|
57
|
+
way to distinguish "a synthetic scaffolding change that's still
|
|
58
|
+
discardable" from "a real, live tip the user simply isn't checked out on
|
|
59
|
+
right now" once the FLAG is set once at creation and never cleared.
|
|
60
|
+
`edit()` recording only its own target in `view.heads` (correct back
|
|
61
|
+
when `edit()` was a pure checkout, wrong now that it also commits the
|
|
62
|
+
change being left) meant this package's own "explicit, maintained set of
|
|
63
|
+
live heads" proxy never saw the other, real tip at all. Fixed two ways:
|
|
64
|
+
`edit()`'s recorded `view.heads` now uses `computeCurrentHeads()` (every
|
|
65
|
+
non-abandoned change with no non-abandoned children) instead of
|
|
66
|
+
`[args.changeId]`; and `RevsetEngine` now takes the operation log as a
|
|
67
|
+
constructor param and `_computeHiddenOrphans` adds the latest op's
|
|
68
|
+
`view.heads` to its `trackedHeads` signal, alongside the working-copy
|
|
69
|
+
pointer and every bookmark/tag target — the "explicit, maintained set of
|
|
70
|
+
heads" this package's own doc comment already said real jj's View keeps,
|
|
71
|
+
now actually wired through.
|
|
72
|
+
- **`operations.list()` reversed its own internal array in place instead
|
|
73
|
+
of a copy, silently scrambling operation order on the SECOND call within
|
|
74
|
+
a session.** `oplog.list()` returns its live internal array by
|
|
75
|
+
reference (by design — `computeGraphReversal()` and others need the
|
|
76
|
+
real, oldest-first list); `operations.list()`'s own `.reverse()` (to
|
|
77
|
+
present newest-first) mutated that same array as a side effect. Calling
|
|
78
|
+
it twice — a completely reasonable thing to do (list history, do
|
|
79
|
+
something, list history again) — un-reversed it back to oldest-first,
|
|
80
|
+
and anything recorded via `oplog.recordOperation()` in between landed
|
|
81
|
+
appended to what was, at that moment, the WRONG end. Found while writing
|
|
82
|
+
a regression test for the fixes above (two `operations.list()` calls
|
|
83
|
+
around one `autoSnapshotWorkingCopy()`), not from the issue's own repro
|
|
84
|
+
— but real and, in principle, capable of corrupting `undo()`/`redo()`/
|
|
85
|
+
`operations.restore()` for any caller that also calls
|
|
86
|
+
`jj.operations.list()` earlier in the same session. Fixed by copying
|
|
87
|
+
before reversing.
|
|
88
|
+
|
|
89
|
+
See `tests/integration/issue-43-restore-reliability.test.js`'s new
|
|
90
|
+
"issue #48" suites (8 new tests across all five gaps above) and
|
|
91
|
+
`tests/unit/core/revset-branches-coverage.test.js`'s new oplog-signal
|
|
92
|
+
suite (2 tests) for the hidden-tips fix specifically. All verified with
|
|
93
|
+
a negative control: reverting just the source changes fails exactly
|
|
94
|
+
these new tests while every pre-existing test still passes, confirming
|
|
95
|
+
they exercise real behavior rather than being tautological.
|
|
96
|
+
|
|
3
97
|
## 1.11.0 — 2026-09-26 — edit() of an older change rebases its descendants (#45)
|
|
4
98
|
|
|
5
99
|
Part 5 of #43 (parts 1-4 shipped in 1.10.0 / #44), previously punted because
|
package/dist/index.cjs
CHANGED
|
@@ -2532,13 +2532,23 @@ class RevsetEngine {
|
|
|
2532
2532
|
* @param {any} [userConfig] - User configuration instance (optional)
|
|
2533
2533
|
* @param {any} [bookmarkStore] - Bookmark store instance (optional, v0.4)
|
|
2534
2534
|
* @param {any} [tagStore] - Tag store instance (optional, v1.5)
|
|
2535
|
+
* @param {any} [oplog] - Operation log instance (optional, issue #48) —
|
|
2536
|
+
* see `_computeHiddenOrphans`'s doc comment for why this is needed.
|
|
2535
2537
|
*/
|
|
2536
|
-
constructor(
|
|
2538
|
+
constructor(
|
|
2539
|
+
graph,
|
|
2540
|
+
workingCopy,
|
|
2541
|
+
userConfig = null,
|
|
2542
|
+
bookmarkStore = null,
|
|
2543
|
+
tagStore = null,
|
|
2544
|
+
oplog = null
|
|
2545
|
+
) {
|
|
2537
2546
|
this.graph = graph;
|
|
2538
2547
|
this.workingCopy = workingCopy;
|
|
2539
2548
|
this.userConfig = userConfig;
|
|
2540
2549
|
this.bookmarkStore = bookmarkStore;
|
|
2541
2550
|
this.tagStore = tagStore;
|
|
2551
|
+
this.oplog = oplog;
|
|
2542
2552
|
}
|
|
2543
2553
|
|
|
2544
2554
|
/**
|
|
@@ -3225,11 +3235,24 @@ class RevsetEngine {
|
|
|
3225
3235
|
* excludable. Real jj instead tracks an explicit, maintained set of
|
|
3226
3236
|
* heads in its View (updated as operations run — abandoning/squashing a
|
|
3227
3237
|
* change removes it, rebasing children onto a new parent removes the
|
|
3228
|
-
* old parent, etc.).
|
|
3229
|
-
*
|
|
3230
|
-
* pointer plus every bookmark/tag target
|
|
3231
|
-
*
|
|
3232
|
-
*
|
|
3238
|
+
* old parent, etc.).
|
|
3239
|
+
*
|
|
3240
|
+
* Issue #48: the working-copy pointer plus every bookmark/tag target,
|
|
3241
|
+
* alone, missed a real case this rule needs to cover: `edit()`-ing @ to
|
|
3242
|
+
* an OLDER change leaves the change you edited AWAY FROM still fully
|
|
3243
|
+
* live (nobody abandoned/squashed/rebased it away) — but it's a
|
|
3244
|
+
* DESCENDANT of the new @ (not an ancestor), so it's absent from
|
|
3245
|
+
* `reachable` no matter what. If that change also happens to be
|
|
3246
|
+
* `autoCreated` (e.g. it came from `new()`) and was never `describe()`d,
|
|
3247
|
+
* the `autoCreated ||` branch below — needed for the two undo-artifact
|
|
3248
|
+
* cases documented above it — incorrectly swept it up too, hiding a real
|
|
3249
|
+
* tip with real work in it. The operation log's `view.heads` (now
|
|
3250
|
+
* correctly threaded through via the `oplog` constructor param) is
|
|
3251
|
+
* exactly the "explicit, maintained set of heads" real jj's View keeps —
|
|
3252
|
+
* every mutating operation records the actual live heads as of right
|
|
3253
|
+
* after it ran (see `computeCurrentHeads()` in repository.js), so the
|
|
3254
|
+
* latest one is the accurate, low-cost substitute for a full View this
|
|
3255
|
+
* package doesn't otherwise maintain.
|
|
3233
3256
|
*
|
|
3234
3257
|
* @param {any[]} allChanges - `graph.getAll()`
|
|
3235
3258
|
* @returns {Promise<Set<string>>} change IDs to exclude
|
|
@@ -3253,6 +3276,18 @@ class RevsetEngine {
|
|
|
3253
3276
|
if (tag && tag.changeId) trackedHeads.add(tag.changeId);
|
|
3254
3277
|
}
|
|
3255
3278
|
}
|
|
3279
|
+
if (this.oplog) {
|
|
3280
|
+
try {
|
|
3281
|
+
const headOp = await this.oplog.getHeadOperation();
|
|
3282
|
+
if (headOp && headOp.view && Array.isArray(headOp.view.heads)) {
|
|
3283
|
+
for (const headId of headOp.view.heads) {
|
|
3284
|
+
if (headId) trackedHeads.add(headId);
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
} catch {
|
|
3288
|
+
// oplog not loaded/available — fine, just no op-based roots.
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3256
3291
|
|
|
3257
3292
|
const reachable = new Set();
|
|
3258
3293
|
for (const headId of trackedHeads) {
|
|
@@ -7994,6 +8029,13 @@ async function createJJ(options) {
|
|
|
7994
8029
|
* - changeSnapshot: { [changeId]: fullRecordBeforeThisOpRan }
|
|
7995
8030
|
* - view.fileSnapshot: on-disk file state right BEFORE this op ran
|
|
7996
8031
|
* - conflictsSnapshot: ConflictModel state right BEFORE this op ran
|
|
8032
|
+
* - conflictsSnapshotAfter: ConflictModel state right AFTER this op ran
|
|
8033
|
+
* (issue #48, R2) — conflicts.resolve()/merge()/converge()/
|
|
8034
|
+
* moveChange() all record this alongside the "before" value, purely
|
|
8035
|
+
* as a fallback for landing exactly ON that op with nothing LATER
|
|
8036
|
+
* also touching conflicts (see below) — every other kind of state
|
|
8037
|
+
* here only ever needs a "before" value, because it's recovered from
|
|
8038
|
+
* a LATER op's own before-snapshot, not from itself.
|
|
7997
8039
|
*
|
|
7998
8040
|
* Deliberately NOT reverted: changes an operation newly *created* (via
|
|
7999
8041
|
* graph.addChange() — new(), squash()'s synthetic empty working-copy
|
|
@@ -8065,6 +8107,20 @@ async function createJJ(options) {
|
|
|
8065
8107
|
}
|
|
8066
8108
|
}
|
|
8067
8109
|
|
|
8110
|
+
// Issue #48 (R2): the loop above recovers "conflicts state right after
|
|
8111
|
+
// landingOp" from a LATER op's own "before" snapshot — but that only
|
|
8112
|
+
// exists if some later op also touched conflicts. When landingOp is
|
|
8113
|
+
// itself the last op to ever touch conflicts (routine for
|
|
8114
|
+
// conflicts.resolve()/merge()/converge()/moveChange() — nothing
|
|
8115
|
+
// naturally re-touches the ConflictModel afterward), the loop finds
|
|
8116
|
+
// nothing and this used to silently leave conflict state exactly as it
|
|
8117
|
+
// already happened to be, which is only correct by coincidence. Fall
|
|
8118
|
+
// back to landingOp's OWN recorded `conflictsSnapshotAfter` (the actual
|
|
8119
|
+
// result of the mutation IT performed), when present.
|
|
8120
|
+
if (conflictsSnapshot === undefined && landingOp.conflictsSnapshotAfter != null) {
|
|
8121
|
+
conflictsSnapshot = landingOp.conflictsSnapshotAfter;
|
|
8122
|
+
}
|
|
8123
|
+
|
|
8068
8124
|
return { changeSnapshotToApply, fileSnapshot, conflictsSnapshot };
|
|
8069
8125
|
};
|
|
8070
8126
|
|
|
@@ -8269,7 +8325,24 @@ async function createJJ(options) {
|
|
|
8269
8325
|
const change = await graph.getChange(wcId);
|
|
8270
8326
|
if (change) {
|
|
8271
8327
|
const oldFileSnapshot = change.fileSnapshot ? structuredClone(change.fileSnapshot) : {};
|
|
8272
|
-
|
|
8328
|
+
// Issue #48 (R3 / "silent re-snapshot"): this used to mutate the
|
|
8329
|
+
// graph (and rebase descendants) with no operation recorded at
|
|
8330
|
+
// all — the comment above even called it out as reconciling
|
|
8331
|
+
// state "silently". That meant an out-of-band disk edit picked
|
|
8332
|
+
// up here was gone for good the moment a later undo()/redo()/
|
|
8333
|
+
// operations.restore() landed anywhere: there was no op in the
|
|
8334
|
+
// log capturing the transition, so nothing could reverse it, and
|
|
8335
|
+
// — since this is exactly what status()/describe()/read()/etc.
|
|
8336
|
+
// trigger on every call — it fired again on the very next such
|
|
8337
|
+
// call with no drift left to detect, making a real, one-time
|
|
8338
|
+
// content change look like it had simply never happened. Same
|
|
8339
|
+
// treatment as the explicit snapshot() API now gets: capture the
|
|
8340
|
+
// "before" state and record a real operation, so this is exactly
|
|
8341
|
+
// as reversible as calling jj.snapshot() by hand.
|
|
8342
|
+
/** @type {Record<string, any>} */
|
|
8343
|
+
const changeSnapshot = { [wcId]: structuredClone(change) };
|
|
8344
|
+
const newFileSnapshot = await snapshotFilesystem();
|
|
8345
|
+
change.fileSnapshot = newFileSnapshot;
|
|
8273
8346
|
await graph.updateChange(change);
|
|
8274
8347
|
|
|
8275
8348
|
// Issue #45: if the working-copy change isn't a leaf (e.g. we
|
|
@@ -8284,10 +8357,28 @@ async function createJJ(options) {
|
|
|
8284
8357
|
// in the same enclosing scope and this function only ever runs
|
|
8285
8358
|
// after `jj` is fully constructed, so the self-reference is safe
|
|
8286
8359
|
// (same pattern used elsewhere in this file, e.g. `jj.new(...)`).
|
|
8360
|
+
// Issue #48 (R1): pass `changeSnapshot` through as the 4th arg so
|
|
8361
|
+
// a rebased descendant's pre-rebase state is captured too — see
|
|
8362
|
+
// `_rebuildDescendants`'s doc comment.
|
|
8287
8363
|
const descendants = await jj._findDescendants(wcId, null);
|
|
8288
8364
|
if (descendants.length > 0) {
|
|
8289
|
-
await jj._rebuildDescendants(wcId, null, oldFileSnapshot);
|
|
8365
|
+
await jj._rebuildDescendants(wcId, null, oldFileSnapshot, changeSnapshot);
|
|
8290
8366
|
}
|
|
8367
|
+
|
|
8368
|
+
await oplog.recordOperation({
|
|
8369
|
+
timestamp: new Date().toISOString(),
|
|
8370
|
+
user: await getUserOplogInfo(),
|
|
8371
|
+
description: 'auto-snapshot working copy',
|
|
8372
|
+
parents: [],
|
|
8373
|
+
changeSnapshot,
|
|
8374
|
+
view: {
|
|
8375
|
+
bookmarks: {},
|
|
8376
|
+
remoteBookmarks: {},
|
|
8377
|
+
heads: await computeCurrentHeads(),
|
|
8378
|
+
workingCopy: wcId,
|
|
8379
|
+
fileSnapshot: newFileSnapshot,
|
|
8380
|
+
},
|
|
8381
|
+
});
|
|
8291
8382
|
}
|
|
8292
8383
|
} catch {
|
|
8293
8384
|
// Best-effort content refresh; tracking state is already updated.
|
|
@@ -8490,7 +8581,7 @@ async function createJJ(options) {
|
|
|
8490
8581
|
});
|
|
8491
8582
|
|
|
8492
8583
|
// Create revset engine with the middleware-wrapped graph (v0.4: added bookmarkStore)
|
|
8493
|
-
const revset = new RevsetEngine(graph, workingCopy, userConfig, bookmarks, tags);
|
|
8584
|
+
const revset = new RevsetEngine(graph, workingCopy, userConfig, bookmarks, tags, oplog);
|
|
8494
8585
|
|
|
8495
8586
|
/**
|
|
8496
8587
|
* Helper to resolve conflicts with different strategies (v0.5)
|
|
@@ -9261,6 +9352,21 @@ async function createJJ(options) {
|
|
|
9261
9352
|
await mkdirp(fs, join(dir, pathParts.slice(0, -1).join('/')));
|
|
9262
9353
|
}
|
|
9263
9354
|
await fs.promises.writeFile(fullPath, markerText, 'utf8');
|
|
9355
|
+
// Keep the working-copy tracker in sync with this out-of-band
|
|
9356
|
+
// write (issue #48, discovered via R2's fix) — otherwise the
|
|
9357
|
+
// NEXT autoSnapshotWorkingCopy() call (status()/read()/etc.)
|
|
9358
|
+
// sees a stale tracked mtime for this file, reports a false
|
|
9359
|
+
// "modified", and now (since autoSnapshotWorkingCopy() records
|
|
9360
|
+
// a real operation for that — see its own doc comment) records
|
|
9361
|
+
// a spurious extra op immediately after this one. Every other
|
|
9362
|
+
// direct-to-disk write in this file (write(), syncWorkingCopyFiles)
|
|
9363
|
+
// already does this; this was the one gap.
|
|
9364
|
+
const markerStats = await fs.promises.stat(fullPath);
|
|
9365
|
+
await workingCopy.trackFile(conflict.path, {
|
|
9366
|
+
mtime: markerStats.mtime,
|
|
9367
|
+
size: markerStats.size,
|
|
9368
|
+
mode: markerStats.mode,
|
|
9369
|
+
});
|
|
9264
9370
|
}
|
|
9265
9371
|
}
|
|
9266
9372
|
}
|
|
@@ -9269,6 +9375,15 @@ async function createJJ(options) {
|
|
|
9269
9375
|
change.parents = [newParent];
|
|
9270
9376
|
await graph.updateChange(change);
|
|
9271
9377
|
|
|
9378
|
+
// Issue #48 (R2): the AFTER state, for landing exactly on THIS op
|
|
9379
|
+
// when nothing later also touches conflicts — see
|
|
9380
|
+
// computeGraphReversal's doc comment and conflicts.resolve()'s
|
|
9381
|
+
// matching `conflictsSnapshotAfter`, which this mirrors.
|
|
9382
|
+
const conflictsSnapshotAfter = {
|
|
9383
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
9384
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
9385
|
+
};
|
|
9386
|
+
|
|
9272
9387
|
// Record operation
|
|
9273
9388
|
await oplog.recordOperation({
|
|
9274
9389
|
timestamp: new Date().toISOString(),
|
|
@@ -9277,6 +9392,7 @@ async function createJJ(options) {
|
|
|
9277
9392
|
parents: [],
|
|
9278
9393
|
changeSnapshot: { [changeId]: changeSnapshotBefore },
|
|
9279
9394
|
conflictsSnapshot,
|
|
9395
|
+
conflictsSnapshotAfter,
|
|
9280
9396
|
view: {
|
|
9281
9397
|
bookmarks: {},
|
|
9282
9398
|
remoteBookmarks: {},
|
|
@@ -9900,9 +10016,18 @@ async function createJJ(options) {
|
|
|
9900
10016
|
// matching real jj's automatic rebase-on-edit. edit() itself is a
|
|
9901
10017
|
// pure checkout and never mutates content, so this is the actual
|
|
9902
10018
|
// moment content changes and the right place to propagate.
|
|
10019
|
+
// Issue #48 (R1/R3): pass `changeSnapshot` through as the 4th arg
|
|
10020
|
+
// so a rebased descendant's pre-rebase state is captured into the
|
|
10021
|
+
// SAME op this method records below, not lost the moment
|
|
10022
|
+
// `_rebuildDescendants` mutates it.
|
|
9903
10023
|
const descendants = await this._findDescendants(wcId, null);
|
|
9904
10024
|
if (descendants.length > 0) {
|
|
9905
|
-
await this._rebuildDescendants(
|
|
10025
|
+
await this._rebuildDescendants(
|
|
10026
|
+
wcId,
|
|
10027
|
+
null,
|
|
10028
|
+
changeSnapshot[wcId].fileSnapshot,
|
|
10029
|
+
changeSnapshot
|
|
10030
|
+
);
|
|
9906
10031
|
}
|
|
9907
10032
|
}
|
|
9908
10033
|
} catch {
|
|
@@ -10265,12 +10390,20 @@ async function createJJ(options) {
|
|
|
10265
10390
|
// isn't a leaf (we'd edit()'d into an ancestor), its descendants
|
|
10266
10391
|
// still have the OLD content and need the same rebase-on-edit
|
|
10267
10392
|
// treatment applied at the other two commit sites.
|
|
10393
|
+
//
|
|
10394
|
+
// Issue #48 (R1): pass `changeSnapshot` itself as the 4th arg
|
|
10395
|
+
// so every rebased descendant's pre-rebase state lands in the
|
|
10396
|
+
// SAME object this method already records into the op below —
|
|
10397
|
+
// previously only `previousChangeId`'s own before-state was
|
|
10398
|
+
// captured, so undo()/operations.restore() had no way to
|
|
10399
|
+
// reverse the descendant rebase itself.
|
|
10268
10400
|
const descendants = await this._findDescendants(previousChangeId, null);
|
|
10269
10401
|
if (descendants.length > 0) {
|
|
10270
10402
|
await this._rebuildDescendants(
|
|
10271
10403
|
previousChangeId,
|
|
10272
10404
|
null,
|
|
10273
|
-
changeSnapshot[previousChangeId].fileSnapshot
|
|
10405
|
+
changeSnapshot[previousChangeId].fileSnapshot,
|
|
10406
|
+
changeSnapshot
|
|
10274
10407
|
);
|
|
10275
10408
|
}
|
|
10276
10409
|
}
|
|
@@ -10316,7 +10449,17 @@ async function createJJ(options) {
|
|
|
10316
10449
|
view: {
|
|
10317
10450
|
bookmarks: {},
|
|
10318
10451
|
remoteBookmarks: {},
|
|
10319
|
-
|
|
10452
|
+
// Issue #48: previously hardcoded to `[args.changeId]` -- correct
|
|
10453
|
+
// back when edit() was a pure checkout (the target WAS the only
|
|
10454
|
+
// thing that could plausibly have changed), but edit() now also
|
|
10455
|
+
// commits the change being left (see above), which can leave a
|
|
10456
|
+
// second, real, still-live tip that isn't the edit target at all
|
|
10457
|
+
// (e.g. `previousChangeId` itself, if it has no descendants).
|
|
10458
|
+
// Recording only the target here silently dropped that other
|
|
10459
|
+
// live head from `view.heads` -- see `_computeHiddenOrphans` in
|
|
10460
|
+
// revset-engine.js, which reads the latest op's `view.heads` as
|
|
10461
|
+
// one of its signals for "still actually in play."
|
|
10462
|
+
heads: await computeCurrentHeads(),
|
|
10320
10463
|
workingCopy: args.changeId,
|
|
10321
10464
|
fileSnapshot: fileSnapshotBeforeSwitch,
|
|
10322
10465
|
},
|
|
@@ -10652,7 +10795,20 @@ async function createJJ(options) {
|
|
|
10652
10795
|
*/
|
|
10653
10796
|
async list(opts = {}) {
|
|
10654
10797
|
await oplog.load();
|
|
10655
|
-
|
|
10798
|
+
// `oplog.list()` returns its OWN internal array by reference, not a
|
|
10799
|
+
// copy — discovered while fixing issue #48: calling this method
|
|
10800
|
+
// TWICE in the same session (e.g. once to capture a "before" op,
|
|
10801
|
+
// then again later to check what got recorded) used to reverse
|
|
10802
|
+
// that internal array a second time, silently un-reversing it and
|
|
10803
|
+
// scrambling the order any operation recorded in between landed
|
|
10804
|
+
// at. `.slice()` (with no args, i.e. a full copy) unconditionally,
|
|
10805
|
+
// before ever limiting or reversing, so `.reverse()` below always
|
|
10806
|
+
// mutates a throwaway copy — this method itself was the only
|
|
10807
|
+
// caller that needed to be non-destructive; `oplog.list()`'s own
|
|
10808
|
+
// contract (return the live list, oldest-first, for internal
|
|
10809
|
+
// reversal-computation callers like computeGraphReversal) is
|
|
10810
|
+
// unchanged.
|
|
10811
|
+
let ops = (await oplog.list()).slice();
|
|
10656
10812
|
|
|
10657
10813
|
if (opts.limit && opts.limit > 0) {
|
|
10658
10814
|
ops = ops.slice(-opts.limit);
|
|
@@ -11623,12 +11779,20 @@ async function createJJ(options) {
|
|
|
11623
11779
|
for (const conflict of detectedConflicts) {
|
|
11624
11780
|
await conflicts.addConflict(conflict);
|
|
11625
11781
|
}
|
|
11782
|
+
// Issue #48 (R2): the AFTER state — see conflicts.resolve()'s
|
|
11783
|
+
// matching `conflictsSnapshotAfter` and computeGraphReversal's doc
|
|
11784
|
+
// comment for why landing exactly on this op needs it.
|
|
11785
|
+
const conflictsSnapshotAfter = {
|
|
11786
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
11787
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
11788
|
+
};
|
|
11626
11789
|
await oplog.recordOperation({
|
|
11627
11790
|
timestamp: new Date().toISOString(),
|
|
11628
11791
|
user: await getUserOplogInfo(),
|
|
11629
11792
|
description: `converge ${changeId.slice(0, 8)} (unresolved)`,
|
|
11630
11793
|
parents: [],
|
|
11631
11794
|
conflictsSnapshot,
|
|
11795
|
+
conflictsSnapshotAfter,
|
|
11632
11796
|
view: {
|
|
11633
11797
|
bookmarks: {},
|
|
11634
11798
|
remoteBookmarks: {},
|
|
@@ -12530,8 +12694,24 @@ async function createJJ(options) {
|
|
|
12530
12694
|
* descendants are unaffected (their own immediate parent hasn't been
|
|
12531
12695
|
* mutated yet when this function reads it, so the live read is
|
|
12532
12696
|
* correct for them). Optional for backward compatibility.
|
|
12697
|
+
* @param {Record<string, any>} [outChangeSnapshot] - If provided,
|
|
12698
|
+
* populated with `{[descendantId]: fullRecordBeforeRebuild}` for every
|
|
12699
|
+
* descendant this call actually mutates, BEFORE the mutation —
|
|
12700
|
+
* matching the `oplog.recordOperation({changeSnapshot})` convention
|
|
12701
|
+
* used everywhere else in this file (see computeGraphReversal's doc
|
|
12702
|
+
* comment). Every caller mutates the graph via `graph.updateChange()`
|
|
12703
|
+
* here without this, so `undo()`/`redo()`/`operations.restore()` had
|
|
12704
|
+
* nothing to reverse a rebased descendant's content with — the
|
|
12705
|
+
* descendant rebase itself was simply never recorded in the operation
|
|
12706
|
+
* (issue #48, R1). Callers should merge this into their own
|
|
12707
|
+
* `changeSnapshot` before calling `oplog.recordOperation()`.
|
|
12533
12708
|
*/
|
|
12534
|
-
async _rebuildDescendants(
|
|
12709
|
+
async _rebuildDescendants(
|
|
12710
|
+
ancestorId,
|
|
12711
|
+
stopAtChangeId,
|
|
12712
|
+
originalAncestorSnapshot,
|
|
12713
|
+
outChangeSnapshot
|
|
12714
|
+
) {
|
|
12535
12715
|
// Find all descendants of ancestorId
|
|
12536
12716
|
const allChanges = graph.getAll();
|
|
12537
12717
|
|
|
@@ -12602,7 +12782,14 @@ async function createJJ(options) {
|
|
|
12602
12782
|
// Get parent's ORIGINAL (before absorb) state
|
|
12603
12783
|
const originalState = originalParentStates.get(descendantId);
|
|
12604
12784
|
if (!originalState || originalState.parentId !== descendant.parents[0]) {
|
|
12605
|
-
// Parent relationship changed or no original state, just use current
|
|
12785
|
+
// Parent relationship changed or no original state, just use current
|
|
12786
|
+
// parent. Not exercised by any current caller (each always builds
|
|
12787
|
+
// `originalParentStates` from a fresh, internally-consistent scan
|
|
12788
|
+
// immediately before this loop runs, with no mutation in between --
|
|
12789
|
+
// see the loop above) -- left as defensive dead code, matching its
|
|
12790
|
+
// pre-existing, already-untested state before issue #48 rather than
|
|
12791
|
+
// adding an `outChangeSnapshot` capture branch here that could never
|
|
12792
|
+
// be exercised either.
|
|
12606
12793
|
descendant.fileSnapshot = { ...parent.fileSnapshot };
|
|
12607
12794
|
await graph.updateChange(descendant);
|
|
12608
12795
|
continue;
|
|
@@ -12700,6 +12887,9 @@ async function createJJ(options) {
|
|
|
12700
12887
|
newSnapshot[filePath] = resultLines.join('\n');
|
|
12701
12888
|
}
|
|
12702
12889
|
|
|
12890
|
+
if (outChangeSnapshot && !(descendantId in outChangeSnapshot)) {
|
|
12891
|
+
outChangeSnapshot[descendantId] = structuredClone(descendant);
|
|
12892
|
+
}
|
|
12703
12893
|
descendant.fileSnapshot = newSnapshot;
|
|
12704
12894
|
await graph.updateChange(descendant);
|
|
12705
12895
|
|
|
@@ -13723,6 +13913,14 @@ async function createJJ(options) {
|
|
|
13723
13913
|
await conflicts.addConflict(conflict);
|
|
13724
13914
|
}
|
|
13725
13915
|
|
|
13916
|
+
// Issue #48 (R2): the AFTER state — see conflicts.resolve()'s
|
|
13917
|
+
// matching `conflictsSnapshotAfter` and computeGraphReversal's doc
|
|
13918
|
+
// comment for why landing exactly on this op needs it.
|
|
13919
|
+
const conflictsSnapshotAfter = {
|
|
13920
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
13921
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
13922
|
+
};
|
|
13923
|
+
|
|
13726
13924
|
// Record operation with conflicts snapshot
|
|
13727
13925
|
await oplog.recordOperation({
|
|
13728
13926
|
timestamp: new Date().toISOString(),
|
|
@@ -13736,6 +13934,7 @@ async function createJJ(options) {
|
|
|
13736
13934
|
workingCopy: currentChangeId,
|
|
13737
13935
|
},
|
|
13738
13936
|
conflictsSnapshot, // Store conflicts state before merge for undo
|
|
13937
|
+
conflictsSnapshotAfter,
|
|
13739
13938
|
});
|
|
13740
13939
|
|
|
13741
13940
|
return {
|
|
@@ -13845,6 +14044,23 @@ async function createJJ(options) {
|
|
|
13845
14044
|
|
|
13846
14045
|
// Write resolved content to file
|
|
13847
14046
|
await fs.promises.writeFile(join(dir, conflict.path), resolvedContent, 'utf8');
|
|
14047
|
+
// Keep the working-copy tracker in sync with this out-of-band
|
|
14048
|
+
// write (issue #48, discovered via R2's fix) — otherwise the NEXT
|
|
14049
|
+
// autoSnapshotWorkingCopy() call (status()/read()/etc.) sees a
|
|
14050
|
+
// stale tracked mtime for this file, reports a false "modified",
|
|
14051
|
+
// and records a spurious extra "auto-snapshot working copy"
|
|
14052
|
+
// operation immediately after this one (autoSnapshotWorkingCopy()
|
|
14053
|
+
// now records a real op for that — see its own doc comment).
|
|
14054
|
+
// write()/syncWorkingCopyFiles already do this for every OTHER
|
|
14055
|
+
// direct-to-disk write in this file; this was a gap.
|
|
14056
|
+
{
|
|
14057
|
+
const resolvedStats = await fs.promises.stat(join(dir, conflict.path));
|
|
14058
|
+
await workingCopy.trackFile(conflict.path, {
|
|
14059
|
+
mtime: resolvedStats.mtime,
|
|
14060
|
+
size: resolvedStats.size,
|
|
14061
|
+
mode: resolvedStats.mode,
|
|
14062
|
+
});
|
|
14063
|
+
}
|
|
13848
14064
|
|
|
13849
14065
|
// Mark conflict as resolved
|
|
13850
14066
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
@@ -13864,6 +14080,24 @@ async function createJJ(options) {
|
|
|
13864
14080
|
await graph.updateChange(wcChange);
|
|
13865
14081
|
}
|
|
13866
14082
|
|
|
14083
|
+
// Issue #48 (R2): `computeGraphReversal` recovers "the state right
|
|
14084
|
+
// after landingOp" for conflicts by scanning for a LATER op's
|
|
14085
|
+
// "before" snapshot — that only exists if some later op also
|
|
14086
|
+
// touched conflicts. Conflict resolution is usually a one-off
|
|
14087
|
+
// event with nothing later re-touching the ConflictModel at all, so
|
|
14088
|
+
// landing operations.restore()/undo()/redo() exactly on THIS op
|
|
14089
|
+
// found no candidate and left whatever conflict state happened to
|
|
14090
|
+
// already be live completely untouched — restoring the resolved
|
|
14091
|
+
// FILE content correctly (that part goes through the ordinary
|
|
14092
|
+
// changeSnapshot path, which usually does have a later toucher)
|
|
14093
|
+
// while leaving the change's conflict marked unresolved. Recording
|
|
14094
|
+
// the AFTER state directly closes that gap without needing a later
|
|
14095
|
+
// op to exist at all.
|
|
14096
|
+
const conflictsSnapshotAfter = {
|
|
14097
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
14098
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
14099
|
+
};
|
|
14100
|
+
|
|
13867
14101
|
await oplog.recordOperation({
|
|
13868
14102
|
timestamp: new Date().toISOString(),
|
|
13869
14103
|
user: await getUserOplogInfo(),
|
|
@@ -13871,6 +14105,7 @@ async function createJJ(options) {
|
|
|
13871
14105
|
parents: [],
|
|
13872
14106
|
changeSnapshot,
|
|
13873
14107
|
conflictsSnapshot: conflictsSnapshotBefore,
|
|
14108
|
+
conflictsSnapshotAfter,
|
|
13874
14109
|
view: {
|
|
13875
14110
|
bookmarks: {},
|
|
13876
14111
|
remoteBookmarks: {},
|
|
@@ -13917,7 +14152,16 @@ async function createJJ(options) {
|
|
|
13917
14152
|
const resolvedContent = _resolveWithStrategy(conflict, args.strategy);
|
|
13918
14153
|
|
|
13919
14154
|
// Write resolved content
|
|
13920
|
-
|
|
14155
|
+
const resolvedPath = join(dir, conflict.path);
|
|
14156
|
+
await fs.promises.writeFile(resolvedPath, resolvedContent, 'utf8');
|
|
14157
|
+
// Keep the working-copy tracker in sync — see resolve()'s
|
|
14158
|
+
// matching comment (issue #48).
|
|
14159
|
+
const resolvedStats = await fs.promises.stat(resolvedPath);
|
|
14160
|
+
await workingCopy.trackFile(conflict.path, {
|
|
14161
|
+
mtime: resolvedStats.mtime,
|
|
14162
|
+
size: resolvedStats.size,
|
|
14163
|
+
mode: resolvedStats.mode,
|
|
14164
|
+
});
|
|
13921
14165
|
|
|
13922
14166
|
// Mark as resolved
|
|
13923
14167
|
await conflicts.resolveConflict(conflict.conflictId, args.strategy);
|