isomorphic-jj 1.9.2 → 1.10.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 +126 -0
- package/dist/index.cjs +320 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +320 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +291 -10
- package/src/core/revset-engine.js +29 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,131 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.10.0 — 2026-09-26 — operations.restore() reliability (#43)
|
|
4
|
+
|
|
5
|
+
A substantial follow-up to 1.9.1/1.9.2's undo/restore work (#37/#38/#39/#41):
|
|
6
|
+
five distinct gaps that all fed the same root symptom — `operations.restore()`
|
|
7
|
+
(and `undo()`/`redo()`) not reliably reproducing the repository state at an
|
|
8
|
+
arbitrary past operation. Grouped into one minor release because all five
|
|
9
|
+
touch the same reversal machinery (`computeGraphReversal()`/
|
|
10
|
+
`applyGraphReversal()`, the `all()` revset's orphan filter, and the conflict
|
|
11
|
+
model), and because two of the fixes are real behavioral changes (`log()`'s
|
|
12
|
+
default visibility, and what `rebase()` actually writes into a conflicted
|
|
13
|
+
file) rather than pure bugfixes.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`write()` and `snapshot()` now record their own operation (#43 part 1).**
|
|
18
|
+
Both mutated the working copy (disk content, and for `snapshot()` the
|
|
19
|
+
working-copy change's `fileSnapshot` too) without ever calling
|
|
20
|
+
`oplog.recordOperation()`. An edit typed into a change's working copy via
|
|
21
|
+
either was therefore completely invisible to `undo()`/`operations.restore()`
|
|
22
|
+
— the change kept its post-edit content forever regardless of which
|
|
23
|
+
operation you restored to, because there was no oplog entry to target and
|
|
24
|
+
no "before" snapshot for `computeGraphReversal()` to find. Both now follow
|
|
25
|
+
the same "snapshot the before-state, then record" pattern every other
|
|
26
|
+
mutator in this file already uses (see `describe()`), including a
|
|
27
|
+
`changeSnapshot` for `snapshot()` (which mutates a `ChangeGraph` node in
|
|
28
|
+
place) and a `view.fileSnapshot` for both.
|
|
29
|
+
- **`squash()`, `rebase()`/`moveChange()`, and `abandon()` now record a
|
|
30
|
+
"files before" snapshot as part of their own operation (#43 part 1).**
|
|
31
|
+
`computeGraphReversal()` reconstructs "the state right after operation X"
|
|
32
|
+
by scanning forward from X for the first LATER operation that recorded a
|
|
33
|
+
`view.fileSnapshot` "before" value. These three operations never recorded
|
|
34
|
+
one, so restoring to a point right before one of them ran had nothing
|
|
35
|
+
accurate to fall back on: the scan would skip straight over the gap and
|
|
36
|
+
either find nothing (leaving disk untouched) or land on a much later,
|
|
37
|
+
unrelated operation's "before" value — the wrong point in time entirely.
|
|
38
|
+
Reproduced via a stack-walk (build a chain of describe/new/write/squash/
|
|
39
|
+
describe operations, `operations.restore()` to every operation in it, and
|
|
40
|
+
compare on-disk files against an independently-tracked expectation) — this
|
|
41
|
+
is what surfaces as "a forward restore to an earlier op loses a file that
|
|
42
|
+
was added between a squash and the target". All three now capture disk
|
|
43
|
+
state before mutating anything and include it in their own `view`.
|
|
44
|
+
- **`undo()`/`redo()`/`operations.restore()` now record disk state right
|
|
45
|
+
before THEMSELVES ran, not the landing/target operation's own "before"
|
|
46
|
+
value (#43 part 1).** All three used to record `view: landingView` (or
|
|
47
|
+
`redoneView`/`targetOp.view`) verbatim — reusing that OTHER operation's
|
|
48
|
+
own recorded `fileSnapshot`, which represents disk state before THAT
|
|
49
|
+
operation ran, not before this undo/redo/restore call mutated anything.
|
|
50
|
+
A later `restore()`/`undo()` scanning forward past one of these would then
|
|
51
|
+
pick up that stale, wrong snapshot. Each now captures its own disk state
|
|
52
|
+
immediately before applying its reversal and merges it into the pointer
|
|
53
|
+
fields (`workingCopy`/`heads`/`bookmarks`) it still legitimately inherits
|
|
54
|
+
from the landing operation.
|
|
55
|
+
- **`view.heads` no longer silently drops live heads (#43 part 1).** Every
|
|
56
|
+
operation that recorded `view.heads` hand-wrote it as either `[]` or a
|
|
57
|
+
single changeId — whatever that specific call happened to touch —
|
|
58
|
+
instead of the repository's actual current head set. `squash()` in
|
|
59
|
+
particular recorded the destination changeId even when squashing the
|
|
60
|
+
working copy creates a fresh new tip on top of it (so `heads` pointed at
|
|
61
|
+
the parent, not the tip), and any operation that didn't itself touch an
|
|
62
|
+
untouched sibling branch silently reported only one head where two
|
|
63
|
+
existed. Added `computeCurrentHeads()` (every non-abandoned change with no
|
|
64
|
+
non-abandoned child) and switched `squash()`/`rebase()`/`moveChange()`/
|
|
65
|
+
`abandon()`/`conflicts.resolve()` to use it.
|
|
66
|
+
- **`log()` now hides orphans that are purely internal rewrite/undo
|
|
67
|
+
artifacts, even once they differ from their reverted parent (#43 part 2).**
|
|
68
|
+
The #37(b) fix hid an orphan only while it stayed exactly empty relative
|
|
69
|
+
to its CURRENT parent. Two shapes slip past that once undo() is involved:
|
|
70
|
+
(a) `squash()`'s synthetic post-squash `@`, if that squash is later undone
|
|
71
|
+
and the same undo reverts the destination's content out from under it —
|
|
72
|
+
the orphan's frozen (post-squash) `fileSnapshot` no longer matches the
|
|
73
|
+
destination's (reverted, pre-squash) one, even though nothing about the
|
|
74
|
+
orphan itself changed; (b) `new()`'s freshly created change, if something
|
|
75
|
+
writes into it before an `undo()` unwinds back past the `new()`. Both
|
|
76
|
+
`squash()`'s synthetic working-copy change and `new()`'s created change
|
|
77
|
+
are now tagged `autoCreated: true` at creation, and the revset engine's
|
|
78
|
+
`_computeHiddenOrphans()` hides an unreachable, undescribed change if it's
|
|
79
|
+
EITHER still empty relative to its parent OR `autoCreated` — the latter
|
|
80
|
+
doesn't need to re-diff against a parent that may no longer resemble what
|
|
81
|
+
the change was actually created from.
|
|
82
|
+
- **`rebase()`/`moveChange()` now writes real conflict-marker text into the
|
|
83
|
+
working copy instead of leaving the pre-rebase ("ours") content verbatim
|
|
84
|
+
(#43 part 3) — behavioral change.** Rebase already recorded a detected
|
|
85
|
+
conflict correctly as data (via `conflicts.addConflict()`), but never
|
|
86
|
+
touched the file's actual content: `change.fileSnapshot` (and, when the
|
|
87
|
+
rebased change was checked out, the on-disk file) kept showing whatever
|
|
88
|
+
content it had before the rebase, with no indication a conflict existed
|
|
89
|
+
short of calling `conflicts.list()` separately. It now calls
|
|
90
|
+
`jj.conflicts.markers()` for each detected conflict (reusing that
|
|
91
|
+
existing marker-formatting logic rather than re-deriving it a second way)
|
|
92
|
+
and writes the result into both `change.fileSnapshot` and, when
|
|
93
|
+
applicable, the working copy on disk.
|
|
94
|
+
- **`conflicts.resolve()` now records an operation, so a resolution can be
|
|
95
|
+
undone (#43 part 4).** It wrote the resolved content straight to disk and
|
|
96
|
+
mutated the `ConflictModel`'s persisted state directly, with no
|
|
97
|
+
`oplog.recordOperation()` call at all — resolving a conflict was
|
|
98
|
+
completely invisible to `undo()`/`operations.restore()`. It now captures
|
|
99
|
+
a `changeSnapshot` of the working-copy change, a `conflictsSnapshot` of
|
|
100
|
+
the pre-resolve conflict state (deep-cloned — `resolveConflict()` mutates
|
|
101
|
+
the same conflict object in place, so a naive `Object.fromEntries()` over
|
|
102
|
+
the live `Map` would otherwise leave the "before" snapshot aliased to the
|
|
103
|
+
post-mutation object), and a `view.fileSnapshot`, then records them.
|
|
104
|
+
|
|
105
|
+
### Not fixed (punted)
|
|
106
|
+
|
|
107
|
+
- **#43 part 5 (minor): `edit()` of a non-leaf change doesn't rebase its
|
|
108
|
+
descendants' file content.** An initial fix (propagate the edited
|
|
109
|
+
ancestor's content onto descendants when moving away from it) was
|
|
110
|
+
implemented and then reverted: it directly conflicts with `absorb()`'s own
|
|
111
|
+
job of folding a working-copy edit back into the ancestor it actually
|
|
112
|
+
belongs to — with the auto-rebase in place, `edit()` + `write()` +
|
|
113
|
+
`edit()`-away already changes the descendant before `absorb()` ever runs,
|
|
114
|
+
so `absorb()` finds nothing left to do (caught by the existing
|
|
115
|
+
`tests/integration/absorb.test.js` "should work after edit()" test).
|
|
116
|
+
Fixing this properly needs to account for that interaction; left for a
|
|
117
|
+
follow-up.
|
|
118
|
+
|
|
119
|
+
### Testing
|
|
120
|
+
|
|
121
|
+
1859 tests passing (12 new regression tests, matched by a new negative
|
|
122
|
+
control confirming that 12 of them fail against the pre-fix code — the
|
|
123
|
+
other 3 are supplementary correctness checks that already held, e.g.
|
|
124
|
+
"a descendant's own edit is never clobbered"); lint (0 errors),
|
|
125
|
+
format:check, typecheck, and build all still green; all 12 `examples/*.mjs`
|
|
126
|
+
run clean; branch coverage 90.07% (gate: 90%). New test file:
|
|
127
|
+
`tests/integration/issue-43-restore-reliability.test.js`.
|
|
128
|
+
|
|
3
129
|
## 1.9.2 — 2026-09-26 — auto-snapshot mtime comparison (#41)
|
|
4
130
|
|
|
5
131
|
### Fixed
|
package/dist/index.cjs
CHANGED
|
@@ -3283,11 +3283,39 @@ class RevsetEngine {
|
|
|
3283
3283
|
return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
|
|
3284
3284
|
};
|
|
3285
3285
|
|
|
3286
|
+
// issue #43(2): the original #37(b) rule above (unreachable + isEmpty +
|
|
3287
|
+
// undescribed) only hides an orphan while it still happens to have
|
|
3288
|
+
// zero file diff versus its CURRENT parent. Two real shapes slip past
|
|
3289
|
+
// that once undo()/operations.restore() are involved, because both are
|
|
3290
|
+
// purely internal rewrite/undo artifacts that were never "empty" in
|
|
3291
|
+
// any way a user would recognize — they simply never held real user
|
|
3292
|
+
// content of their own, and a later revert of something else can make
|
|
3293
|
+
// isEmpty() disagree even though nothing about THIS change changed:
|
|
3294
|
+
//
|
|
3295
|
+
// 1. squash() creates a fresh empty `@` on top of `dest` when
|
|
3296
|
+
// squashing the working copy. If that squash is later undone,
|
|
3297
|
+
// the fresh `@` becomes unreferenced — but if `dest` itself gets
|
|
3298
|
+
// reverted back to its pre-squash content by the SAME undo,
|
|
3299
|
+
// isEmpty() now compares the orphan's (frozen, post-squash)
|
|
3300
|
+
// fileSnapshot against dest's (reverted, pre-squash) one and
|
|
3301
|
+
// finds a mismatch, even though the orphan is exactly as
|
|
3302
|
+
// "discardable" as it was the instant it was created.
|
|
3303
|
+
// 2. new() creates a fresh empty change on top of the previous `@`.
|
|
3304
|
+
// If something writes into it (write()/snapshot()) before an
|
|
3305
|
+
// undo() unwinds back past the new(), the orphan is left with
|
|
3306
|
+
// real file content that no longer matches its parent, even
|
|
3307
|
+
// though the user never gave it a real description.
|
|
3308
|
+
//
|
|
3309
|
+
// Changes created by squash()/new() are tagged `autoCreated: true` at
|
|
3310
|
+
// creation (see squash()/new() in repository.js) specifically so this
|
|
3311
|
+
// filter can hide them on that basis alone — still gated on being
|
|
3312
|
+
// unreachable and undescribed, so a change the user actually described
|
|
3313
|
+
// (or that's still live) is never hidden by this rule.
|
|
3286
3314
|
const hidden = new Set();
|
|
3287
3315
|
for (const change of allChanges) {
|
|
3288
3316
|
if (
|
|
3289
3317
|
!reachable.has(change.changeId) &&
|
|
3290
|
-
isEmpty(change) &&
|
|
3318
|
+
(isEmpty(change) || change.autoCreated) &&
|
|
3291
3319
|
UNDESCRIBED.has(change.description)
|
|
3292
3320
|
) {
|
|
3293
3321
|
hidden.add(change.changeId);
|
|
@@ -8087,6 +8115,38 @@ async function createJJ(options) {
|
|
|
8087
8115
|
// an earlier, unrelated merge(). Fixed as part of issue #29.)
|
|
8088
8116
|
};
|
|
8089
8117
|
|
|
8118
|
+
/**
|
|
8119
|
+
* Compute the full current set of "head" changeIds — every non-abandoned
|
|
8120
|
+
* change with no non-abandoned child. Used to record an accurate
|
|
8121
|
+
* `view.heads` when an operation runs (issue #43).
|
|
8122
|
+
*
|
|
8123
|
+
* Before this helper existed, several oplog-recording call sites
|
|
8124
|
+
* (squash(), rebase()/moveChange(), abandon()) hand-wrote a single-
|
|
8125
|
+
* element or empty `heads` array covering only the changeId(s) that
|
|
8126
|
+
* specific call happened to touch. That silently dropped any OTHER
|
|
8127
|
+
* live head the operation didn't itself touch (e.g. a sibling branch
|
|
8128
|
+
* left untouched by an abandon()/squash() of a different branch — "two
|
|
8129
|
+
* sibling changes report only one head between them"), and squash() in
|
|
8130
|
+
* particular recorded the OLD destination changeId instead of the fresh
|
|
8131
|
+
* working-copy change it creates on top of it when squashing @ ("after
|
|
8132
|
+
* a squash [heads] points at the parent instead of the new tip").
|
|
8133
|
+
*
|
|
8134
|
+
* @returns {Promise<string[]>}
|
|
8135
|
+
*/
|
|
8136
|
+
const computeCurrentHeads = async () => {
|
|
8137
|
+
await graph.load();
|
|
8138
|
+
const allChanges = await graph.getAllChanges();
|
|
8139
|
+
const nonAbandoned = allChanges.filter((/** @type {any} */ c) => !c.abandoned);
|
|
8140
|
+
const nonAbandonedIds = new Set(nonAbandoned.map((/** @type {any} */ c) => c.changeId));
|
|
8141
|
+
const heads = [];
|
|
8142
|
+
for (const change of nonAbandoned) {
|
|
8143
|
+
const children = graph.getChildren(change.changeId);
|
|
8144
|
+
const hasLiveChild = children.some((/** @type {string} */ id) => nonAbandonedIds.has(id));
|
|
8145
|
+
if (!hasLiveChild) heads.push(change.changeId);
|
|
8146
|
+
}
|
|
8147
|
+
return heads;
|
|
8148
|
+
};
|
|
8149
|
+
|
|
8090
8150
|
/**
|
|
8091
8151
|
* Re-parent every child of `changeId` onto `newParents` instead — used
|
|
8092
8152
|
* when a change is being elided from the graph (abandon(), issue #30;
|
|
@@ -8118,6 +8178,18 @@ async function createJJ(options) {
|
|
|
8118
8178
|
}
|
|
8119
8179
|
};
|
|
8120
8180
|
|
|
8181
|
+
// NOTE (issue #43(5), punted — see PR description): an earlier version of
|
|
8182
|
+
// this file added a `rebaseDescendantsOntoUpdatedParent()` helper, called
|
|
8183
|
+
// from edit(), to propagate an edited ancestor's content onto descendants
|
|
8184
|
+
// automatically. It was reverted: it directly conflicts with absorb()'s
|
|
8185
|
+
// job (folding a working-copy edit back into an ancestor it actually
|
|
8186
|
+
// belongs to) — with the auto-rebase in place, edit()+write()+edit()-away
|
|
8187
|
+
// already changes the descendant before absorb() ever runs, so absorb()
|
|
8188
|
+
// finds nothing left to do (see tests/integration/absorb.test.js
|
|
8189
|
+
// "should work after edit()", which regressed against this). Fixing
|
|
8190
|
+
// #43(5) properly needs to account for that interaction rather than
|
|
8191
|
+
// duplicate it; left for a follow-up.
|
|
8192
|
+
|
|
8121
8193
|
/**
|
|
8122
8194
|
* Determine which operation a fresh undo() call should revert, matching
|
|
8123
8195
|
* real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
|
|
@@ -8553,6 +8625,17 @@ async function createJJ(options) {
|
|
|
8553
8625
|
});
|
|
8554
8626
|
}
|
|
8555
8627
|
|
|
8628
|
+
await workingCopy.load();
|
|
8629
|
+
|
|
8630
|
+
// Capture disk state right before this write mutates it, and record
|
|
8631
|
+
// an operation for it (issue #43(1)): write() previously recorded NO
|
|
8632
|
+
// operation at all, so an edit typed into a change's working copy
|
|
8633
|
+
// could never be rewound by undo()/operations.restore() regardless
|
|
8634
|
+
// of which op you targeted — the change kept its post-edit content
|
|
8635
|
+
// forever. Mirrors the "snapshot before, record after" pattern every
|
|
8636
|
+
// other mutator in this file uses (see describe(), moveChange()).
|
|
8637
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
8638
|
+
|
|
8556
8639
|
const fullPath = `${dir}/${args.path}`;
|
|
8557
8640
|
|
|
8558
8641
|
// Ensure directory exists
|
|
@@ -8568,7 +8651,6 @@ async function createJJ(options) {
|
|
|
8568
8651
|
await fs.promises.writeFile(fullPath, data, 'utf8');
|
|
8569
8652
|
|
|
8570
8653
|
// Track the file in working copy
|
|
8571
|
-
await workingCopy.load();
|
|
8572
8654
|
const stats = await fs.promises.stat(fullPath);
|
|
8573
8655
|
await workingCopy.trackFile(args.path, {
|
|
8574
8656
|
mtime: stats.mtime,
|
|
@@ -8576,6 +8658,20 @@ async function createJJ(options) {
|
|
|
8576
8658
|
mode: stats.mode,
|
|
8577
8659
|
});
|
|
8578
8660
|
|
|
8661
|
+
await oplog.recordOperation({
|
|
8662
|
+
timestamp: new Date().toISOString(),
|
|
8663
|
+
user: await getUserOplogInfo(),
|
|
8664
|
+
description: `write ${args.path}`,
|
|
8665
|
+
parents: [],
|
|
8666
|
+
view: {
|
|
8667
|
+
bookmarks: {},
|
|
8668
|
+
remoteBookmarks: {},
|
|
8669
|
+
heads: await computeCurrentHeads(),
|
|
8670
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
8671
|
+
fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
|
|
8672
|
+
},
|
|
8673
|
+
});
|
|
8674
|
+
|
|
8579
8675
|
// Return useful information about the written file
|
|
8580
8676
|
return {
|
|
8581
8677
|
path: args.path,
|
|
@@ -9004,6 +9100,15 @@ async function createJJ(options) {
|
|
|
9004
9100
|
async moveChange(args) {
|
|
9005
9101
|
await graph.load();
|
|
9006
9102
|
|
|
9103
|
+
// Capture disk state right before this rebase mutates anything, so
|
|
9104
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
9105
|
+
// #43(1): rebase()/moveChange() previously recorded no "files
|
|
9106
|
+
// before" snapshot at all, so restoring to a point right before a
|
|
9107
|
+
// rebase ran had no on-disk state to fall back to and would instead
|
|
9108
|
+
// pick up whatever a LATER operation happened to record (the wrong
|
|
9109
|
+
// point in time).
|
|
9110
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
9111
|
+
|
|
9007
9112
|
// Support multiple parameter names for flexibility
|
|
9008
9113
|
const changeId = args.changeId || args.from || args.source;
|
|
9009
9114
|
const newParent = args.newParent || args.to || args.destination;
|
|
@@ -9116,6 +9221,32 @@ async function createJJ(options) {
|
|
|
9116
9221
|
await conflicts.addConflict(conflict);
|
|
9117
9222
|
}
|
|
9118
9223
|
|
|
9224
|
+
// Materialize real conflict-marker text into the moved change's file
|
|
9225
|
+
// content — issue #43(3): rebase()/moveChange() used to record the
|
|
9226
|
+
// conflict correctly as DATA (via conflicts.addConflict() above) but
|
|
9227
|
+
// left the file's content exactly as it was pre-rebase ("ours"),
|
|
9228
|
+
// never writing marker text into change.fileSnapshot or, when this
|
|
9229
|
+
// change is the checked-out working copy, onto disk. Reuses
|
|
9230
|
+
// conflicts.markers()'s own marker-formatting logic (the same format
|
|
9231
|
+
// jj.conflicts.markers() already returns) instead of re-deriving
|
|
9232
|
+
// marker text a second way.
|
|
9233
|
+
if (detectedConflicts.length > 0) {
|
|
9234
|
+
change.fileSnapshot = { ...(change.fileSnapshot || {}) };
|
|
9235
|
+
const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
|
|
9236
|
+
for (const conflict of detectedConflicts) {
|
|
9237
|
+
const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
|
|
9238
|
+
change.fileSnapshot[conflict.path] = markerText;
|
|
9239
|
+
if (isCheckedOut) {
|
|
9240
|
+
const fullPath = join(dir, conflict.path);
|
|
9241
|
+
const pathParts = conflict.path.split('/');
|
|
9242
|
+
if (pathParts.length > 1) {
|
|
9243
|
+
await mkdirp(fs, join(dir, pathParts.slice(0, -1).join('/')));
|
|
9244
|
+
}
|
|
9245
|
+
await fs.promises.writeFile(fullPath, markerText, 'utf8');
|
|
9246
|
+
}
|
|
9247
|
+
}
|
|
9248
|
+
}
|
|
9249
|
+
|
|
9119
9250
|
// Update parent
|
|
9120
9251
|
change.parents = [newParent];
|
|
9121
9252
|
await graph.updateChange(change);
|
|
@@ -9131,8 +9262,9 @@ async function createJJ(options) {
|
|
|
9131
9262
|
view: {
|
|
9132
9263
|
bookmarks: {},
|
|
9133
9264
|
remoteBookmarks: {},
|
|
9134
|
-
heads:
|
|
9265
|
+
heads: await computeCurrentHeads(),
|
|
9135
9266
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
9267
|
+
fileSnapshot: fileSnapshotBefore,
|
|
9136
9268
|
},
|
|
9137
9269
|
});
|
|
9138
9270
|
|
|
@@ -9587,6 +9719,16 @@ async function createJJ(options) {
|
|
|
9587
9719
|
description: args.message || '(no description)',
|
|
9588
9720
|
timestamp: new Date().toISOString(),
|
|
9589
9721
|
fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
|
|
9722
|
+
// Issue #43(2): marks this as an internal, synthetic artifact
|
|
9723
|
+
// rather than something the user actually created, so the revset
|
|
9724
|
+
// engine's orphan-visibility filter (_computeHiddenOrphans) can
|
|
9725
|
+
// still hide it after it's un-referenced by a later undo()/
|
|
9726
|
+
// operations.restore(), even if some out-of-band mutation (e.g.
|
|
9727
|
+
// write()+snapshot() before the undo unwinds this far) has since
|
|
9728
|
+
// made it look "non-empty" relative to its parent. A real
|
|
9729
|
+
// description (args.message, or a later describe()) still makes
|
|
9730
|
+
// it visible regardless, via the UNDESCRIBED check.
|
|
9731
|
+
autoCreated: true,
|
|
9590
9732
|
};
|
|
9591
9733
|
|
|
9592
9734
|
// Dispatch change:creating event (preventable)
|
|
@@ -9707,17 +9849,51 @@ async function createJJ(options) {
|
|
|
9707
9849
|
await graph.load();
|
|
9708
9850
|
await workingCopy.load();
|
|
9709
9851
|
const result = await workingCopy.snapshot();
|
|
9852
|
+
|
|
9853
|
+
// Disk content itself is not mutated by this call (it's a read,
|
|
9854
|
+
// not a write) — the same value is therefore valid both as the
|
|
9855
|
+
// refreshed change.fileSnapshot AND as the recorded operation's
|
|
9856
|
+
// `view.fileSnapshot` "before" value (nothing on disk changes
|
|
9857
|
+
// between the two).
|
|
9858
|
+
const wcId = workingCopy.getCurrentChangeId();
|
|
9859
|
+
const currentDiskSnapshot = await snapshotFilesystem();
|
|
9860
|
+
|
|
9710
9861
|
// Refresh the working-copy change's content snapshot from disk.
|
|
9862
|
+
/** @type {Record<string, any>} */
|
|
9863
|
+
const changeSnapshot = {};
|
|
9711
9864
|
try {
|
|
9712
|
-
const wcId = workingCopy.getCurrentChangeId();
|
|
9713
9865
|
const change = await graph.getChange(wcId);
|
|
9714
9866
|
if (change) {
|
|
9715
|
-
|
|
9867
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore()
|
|
9868
|
+
// can reverse this content refresh — see issue #43(1):
|
|
9869
|
+
// snapshot() previously mutated `change.fileSnapshot` in place
|
|
9870
|
+
// with no "before" capture and recorded no operation at all, so
|
|
9871
|
+
// computeGraphReversal() had nothing to find for this changeId
|
|
9872
|
+
// if a restore()/undo() landed between an out-of-band edit and
|
|
9873
|
+
// the next real command.
|
|
9874
|
+
changeSnapshot[wcId] = structuredClone(change);
|
|
9875
|
+
change.fileSnapshot = currentDiskSnapshot;
|
|
9716
9876
|
await graph.updateChange(change);
|
|
9717
9877
|
}
|
|
9718
9878
|
} catch {
|
|
9719
9879
|
// Best-effort content refresh.
|
|
9720
9880
|
}
|
|
9881
|
+
|
|
9882
|
+
await oplog.recordOperation({
|
|
9883
|
+
timestamp: new Date().toISOString(),
|
|
9884
|
+
user: await getUserOplogInfo(),
|
|
9885
|
+
description: 'snapshot working copy',
|
|
9886
|
+
parents: [],
|
|
9887
|
+
changeSnapshot,
|
|
9888
|
+
view: {
|
|
9889
|
+
bookmarks: {},
|
|
9890
|
+
remoteBookmarks: {},
|
|
9891
|
+
heads: await computeCurrentHeads(),
|
|
9892
|
+
workingCopy: wcId,
|
|
9893
|
+
fileSnapshot: currentDiskSnapshot,
|
|
9894
|
+
},
|
|
9895
|
+
});
|
|
9896
|
+
|
|
9721
9897
|
return result;
|
|
9722
9898
|
},
|
|
9723
9899
|
|
|
@@ -10181,8 +10357,20 @@ async function createJJ(options) {
|
|
|
10181
10357
|
const current = await graph.getChange(changeId);
|
|
10182
10358
|
if (current) redoChangeSnapshot[changeId] = current;
|
|
10183
10359
|
}
|
|
10360
|
+
// Capture disk state right before this undo() runs. Used both as
|
|
10361
|
+
// `redoFileSnapshot` (for a later redo() — only meaningful when this
|
|
10362
|
+
// reversal actually touches files) AND, unconditionally, as THIS
|
|
10363
|
+
// undo operation's own `view.fileSnapshot` "before" value below —
|
|
10364
|
+
// see issue #43(1): undo()/redo()/operations.restore() used to
|
|
10365
|
+
// record `view: landingView` verbatim, i.e. reusing the LANDING
|
|
10366
|
+
// operation's own recorded fileSnapshot (disk state before THAT op
|
|
10367
|
+
// ran) instead of disk state before THIS undo ran. A later
|
|
10368
|
+
// restore()/undo() scanning forward past this one would then pick
|
|
10369
|
+
// up that stale, wrong snapshot instead of what was actually on disk
|
|
10370
|
+
// right before this undo executed.
|
|
10371
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
10184
10372
|
const redoFileSnapshot =
|
|
10185
|
-
reversal.fileSnapshot !== undefined ?
|
|
10373
|
+
reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
|
|
10186
10374
|
let redoConflictsSnapshot;
|
|
10187
10375
|
if (reversal.conflictsSnapshot !== undefined) {
|
|
10188
10376
|
await conflicts.load();
|
|
@@ -10223,7 +10411,7 @@ async function createJJ(options) {
|
|
|
10223
10411
|
redoFileSnapshot,
|
|
10224
10412
|
redoConflictsSnapshot,
|
|
10225
10413
|
parents: [],
|
|
10226
|
-
view: landingView,
|
|
10414
|
+
view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10227
10415
|
});
|
|
10228
10416
|
|
|
10229
10417
|
const restoredFileCount =
|
|
@@ -10317,6 +10505,15 @@ async function createJJ(options) {
|
|
|
10317
10505
|
await baseGraph.updateChange(snapshot);
|
|
10318
10506
|
}
|
|
10319
10507
|
}
|
|
10508
|
+
|
|
10509
|
+
// Capture disk state right before THIS redo() mutates it — see issue
|
|
10510
|
+
// #43(1)/the matching fix in undo(): `view: redoneView` below reuses
|
|
10511
|
+
// the ORIGINAL (re-applied) operation's own recorded pointers, but
|
|
10512
|
+
// its `fileSnapshot` sub-field must reflect disk state before THIS
|
|
10513
|
+
// redo ran, not before that original operation ran, or a later
|
|
10514
|
+
// restore()/undo() scanning forward past this redo would pick up a
|
|
10515
|
+
// stale, wrong snapshot.
|
|
10516
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
10320
10517
|
if (undoOp.redoFileSnapshot !== undefined) {
|
|
10321
10518
|
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
10322
10519
|
}
|
|
@@ -10337,7 +10534,7 @@ async function createJJ(options) {
|
|
|
10337
10534
|
redoOf: undoOp.id,
|
|
10338
10535
|
changeSnapshot: undoChangeSnapshot,
|
|
10339
10536
|
parents: [],
|
|
10340
|
-
view: redoneView,
|
|
10537
|
+
view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10341
10538
|
});
|
|
10342
10539
|
|
|
10343
10540
|
const restoredFileCount =
|
|
@@ -10643,6 +10840,17 @@ async function createJJ(options) {
|
|
|
10643
10840
|
if (current) changeSnapshot[changeId] = current;
|
|
10644
10841
|
}
|
|
10645
10842
|
|
|
10843
|
+
// Capture disk state right before THIS restore() mutates it — see
|
|
10844
|
+
// issue #43(1)/the matching fix in undo()/redo(): recording
|
|
10845
|
+
// `view: targetOp.view` verbatim reused targetOp's OWN recorded
|
|
10846
|
+
// `fileSnapshot` (disk state before targetOp ran), not disk state
|
|
10847
|
+
// before THIS restore ran. A later restore()/undo() scanning
|
|
10848
|
+
// forward past this one would then pick up that stale, wrong
|
|
10849
|
+
// snapshot instead of what was actually on disk right before this
|
|
10850
|
+
// restore executed — this is exactly the "forward restore to
|
|
10851
|
+
// 'edit tip' lost style.css" repro.
|
|
10852
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
10853
|
+
|
|
10646
10854
|
await applyGraphReversal(reversal);
|
|
10647
10855
|
|
|
10648
10856
|
// Record this restoration as a new operation
|
|
@@ -10652,7 +10860,7 @@ async function createJJ(options) {
|
|
|
10652
10860
|
description: `restore to operation ${args.operation}`,
|
|
10653
10861
|
parents: [],
|
|
10654
10862
|
changeSnapshot,
|
|
10655
|
-
view: targetOp.view,
|
|
10863
|
+
view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10656
10864
|
});
|
|
10657
10865
|
|
|
10658
10866
|
return {
|
|
@@ -10888,6 +11096,15 @@ async function createJJ(options) {
|
|
|
10888
11096
|
await workingCopy.load();
|
|
10889
11097
|
await userConfig.load();
|
|
10890
11098
|
|
|
11099
|
+
// Capture disk state right before this squash mutates anything, so
|
|
11100
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
11101
|
+
// #43(1): squash() previously recorded no "files before" snapshot at
|
|
11102
|
+
// all, so restoring to a point right before a squash ran had no
|
|
11103
|
+
// recorded on-disk state and would fall through to whatever a LATER
|
|
11104
|
+
// operation happened to record instead (the repro: a forward
|
|
11105
|
+
// restore to "edit tip" lost `style.css`).
|
|
11106
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
11107
|
+
|
|
10891
11108
|
// Support 'into' as alias for 'dest' (matches JJ CLI)
|
|
10892
11109
|
const dest = args.into || args.dest;
|
|
10893
11110
|
|
|
@@ -11019,6 +11236,18 @@ async function createJJ(options) {
|
|
|
11019
11236
|
// every on-disk file as untracked/new until something re-wrote
|
|
11020
11237
|
// it and triggered a fresh auto-snapshot.
|
|
11021
11238
|
fileSnapshot: { ...destChange.fileSnapshot },
|
|
11239
|
+
// Marks this as an internal, synthetic artifact rather than
|
|
11240
|
+
// something the user actually created — issue #43(2): if this
|
|
11241
|
+
// squash is later undone, this change becomes unreferenced, and
|
|
11242
|
+
// by then `destChange` may have been reverted to pre-squash
|
|
11243
|
+
// content, making this change look "non-empty" relative to its
|
|
11244
|
+
// (now-reverted) parent even though it never held any real user
|
|
11245
|
+
// content of its own. The revset engine's orphan-visibility
|
|
11246
|
+
// filter (_computeHiddenOrphans) treats `autoCreated` as
|
|
11247
|
+
// sufficient on its own (given it's also unreferenced and
|
|
11248
|
+
// undescribed) rather than re-diffing against a parent that may
|
|
11249
|
+
// no longer resemble what this change was actually created from.
|
|
11250
|
+
autoCreated: true,
|
|
11022
11251
|
};
|
|
11023
11252
|
|
|
11024
11253
|
await graph.addChange(newChange); // Middleware will sync to Git
|
|
@@ -11041,8 +11270,18 @@ async function createJJ(options) {
|
|
|
11041
11270
|
view: {
|
|
11042
11271
|
bookmarks: {},
|
|
11043
11272
|
remoteBookmarks: {},
|
|
11044
|
-
|
|
11273
|
+
// issue #43(1): this used to hard-code `[destChangeId]` — wrong
|
|
11274
|
+
// whenever squash() created a fresh working-copy change on top
|
|
11275
|
+
// of dest (the real new tip was `newWorkingCopyId`, not
|
|
11276
|
+
// `destChangeId` itself), and incomplete whenever a sibling head
|
|
11277
|
+
// elsewhere in the graph existed and wasn't touched by this
|
|
11278
|
+
// squash at all ("two sibling changes report only one head
|
|
11279
|
+
// between them"). computeCurrentHeads() reflects the WHOLE
|
|
11280
|
+
// repo's actual head set after this squash, not just the
|
|
11281
|
+
// changeId(s) this call happened to touch.
|
|
11282
|
+
heads: await computeCurrentHeads(),
|
|
11045
11283
|
workingCopy: newWorkingCopyId,
|
|
11284
|
+
fileSnapshot: fileSnapshotBefore,
|
|
11046
11285
|
},
|
|
11047
11286
|
});
|
|
11048
11287
|
|
|
@@ -11114,6 +11353,12 @@ async function createJJ(options) {
|
|
|
11114
11353
|
// abandoning any OTHER change must leave @ exactly where it was.
|
|
11115
11354
|
const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
|
|
11116
11355
|
|
|
11356
|
+
// Capture disk state right before this abandon mutates anything, so
|
|
11357
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
11358
|
+
// #43(1): abandon() previously recorded no "files before" snapshot
|
|
11359
|
+
// at all.
|
|
11360
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
11361
|
+
|
|
11117
11362
|
// Dispatch change:abandoning event (preventable)
|
|
11118
11363
|
await dispatchEventAsync(jj, 'change:abandoning', {
|
|
11119
11364
|
operation: 'abandon',
|
|
@@ -11148,8 +11393,16 @@ async function createJJ(options) {
|
|
|
11148
11393
|
view: {
|
|
11149
11394
|
bookmarks: {},
|
|
11150
11395
|
remoteBookmarks: {},
|
|
11151
|
-
|
|
11396
|
+
// issue #43(1): this used to hard-code `[]`, dropping every live
|
|
11397
|
+
// head from the recorded operation (including any untouched
|
|
11398
|
+
// sibling branch — "two sibling changes report only one head
|
|
11399
|
+
// between them"). computeCurrentHeads() reflects the actual
|
|
11400
|
+
// repo-wide head set right after this abandon (and its
|
|
11401
|
+
// reparenting) ran, before the compensating jj.new() below (if
|
|
11402
|
+
// any) records its own separate operation.
|
|
11403
|
+
heads: await computeCurrentHeads(),
|
|
11152
11404
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11405
|
+
fileSnapshot: fileSnapshotBefore,
|
|
11153
11406
|
},
|
|
11154
11407
|
});
|
|
11155
11408
|
|
|
@@ -13411,6 +13664,32 @@ async function createJJ(options) {
|
|
|
13411
13664
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
13412
13665
|
}
|
|
13413
13666
|
|
|
13667
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore()
|
|
13668
|
+
// can reverse this resolution — issue #43(4): conflicts.resolve()
|
|
13669
|
+
// previously recorded no operation at all, so resolving a conflict
|
|
13670
|
+
// could never be undone: no changeSnapshot of the working-copy
|
|
13671
|
+
// change's content, no conflictsSnapshot of the pre-resolve
|
|
13672
|
+
// conflict state, and no fileSnapshot of the on-disk content
|
|
13673
|
+
// before the resolved content was written.
|
|
13674
|
+
await graph.load();
|
|
13675
|
+
await workingCopy.load();
|
|
13676
|
+
// structuredClone matters here (unlike the read-only conflictsSnapshot
|
|
13677
|
+
// capture in moveChange()/merge()): resolveConflict() below mutates
|
|
13678
|
+
// the SAME conflict object in place (`conflict.resolved = true`),
|
|
13679
|
+
// and Object.fromEntries() on the live Map would otherwise leave
|
|
13680
|
+
// this "before" snapshot holding a reference to that very object —
|
|
13681
|
+
// corrupting it into already showing `resolved: true` by the time
|
|
13682
|
+
// it's recorded.
|
|
13683
|
+
const conflictsSnapshotBefore = structuredClone({
|
|
13684
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
13685
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
13686
|
+
});
|
|
13687
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
13688
|
+
const wcId = workingCopy.getCurrentChangeId();
|
|
13689
|
+
const wcChange = await graph.getChange(wcId);
|
|
13690
|
+
/** @type {Record<string, any>} */
|
|
13691
|
+
const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
|
|
13692
|
+
|
|
13414
13693
|
let resolvedContent;
|
|
13415
13694
|
|
|
13416
13695
|
// v0.5: Support different resolution methods
|
|
@@ -13454,6 +13733,36 @@ async function createJJ(options) {
|
|
|
13454
13733
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
13455
13734
|
await conflicts.save();
|
|
13456
13735
|
|
|
13736
|
+
// Refresh the working-copy change's own fileSnapshot to match the
|
|
13737
|
+
// resolved on-disk content (conflicted files always live under
|
|
13738
|
+
// the checked-out working copy — see the writeFile above), so
|
|
13739
|
+
// undo()/restore() has an accurate "after" state to compare
|
|
13740
|
+
// against, and log()/read() see the resolution immediately rather
|
|
13741
|
+
// than waiting for the next auto-snapshot.
|
|
13742
|
+
if (wcChange) {
|
|
13743
|
+
wcChange.fileSnapshot = {
|
|
13744
|
+
...(wcChange.fileSnapshot || {}),
|
|
13745
|
+
[conflict.path]: resolvedContent,
|
|
13746
|
+
};
|
|
13747
|
+
await graph.updateChange(wcChange);
|
|
13748
|
+
}
|
|
13749
|
+
|
|
13750
|
+
await oplog.recordOperation({
|
|
13751
|
+
timestamp: new Date().toISOString(),
|
|
13752
|
+
user: await getUserOplogInfo(),
|
|
13753
|
+
description: `resolve conflict ${args.conflictId} (${conflict.path})`,
|
|
13754
|
+
parents: [],
|
|
13755
|
+
changeSnapshot,
|
|
13756
|
+
conflictsSnapshot: conflictsSnapshotBefore,
|
|
13757
|
+
view: {
|
|
13758
|
+
bookmarks: {},
|
|
13759
|
+
remoteBookmarks: {},
|
|
13760
|
+
heads: await computeCurrentHeads(),
|
|
13761
|
+
workingCopy: wcId,
|
|
13762
|
+
fileSnapshot: fileSnapshotBefore,
|
|
13763
|
+
},
|
|
13764
|
+
});
|
|
13765
|
+
|
|
13457
13766
|
return { resolved: true };
|
|
13458
13767
|
},
|
|
13459
13768
|
|