isomorphic-jj 1.9.2 → 1.11.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 +239 -0
- package/dist/index.cjs +448 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +448 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +419 -21
- package/src/core/revset-engine.js +29 -1
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
|
|
@@ -8196,8 +8268,26 @@ async function createJJ(options) {
|
|
|
8196
8268
|
const wcId = workingCopy.getCurrentChangeId();
|
|
8197
8269
|
const change = await graph.getChange(wcId);
|
|
8198
8270
|
if (change) {
|
|
8271
|
+
const oldFileSnapshot = change.fileSnapshot ? structuredClone(change.fileSnapshot) : {};
|
|
8199
8272
|
change.fileSnapshot = await snapshotFilesystem();
|
|
8200
8273
|
await graph.updateChange(change);
|
|
8274
|
+
|
|
8275
|
+
// Issue #45: if the working-copy change isn't a leaf (e.g. we
|
|
8276
|
+
// edit()'d into an older change earlier in the session), its
|
|
8277
|
+
// descendants' fileSnapshot still reflects the OLD content and
|
|
8278
|
+
// needs to be rebased onto the content just committed above —
|
|
8279
|
+
// matching real jj's automatic rebase-on-edit. edit() itself is a
|
|
8280
|
+
// pure checkout and never mutates content, so this is the actual
|
|
8281
|
+
// moment content changes and the right place to propagate. `jj`
|
|
8282
|
+
// (not `this`) is used here because this function is a plain
|
|
8283
|
+
// closure, not a method of the `jj` object — but `jj` is a const
|
|
8284
|
+
// in the same enclosing scope and this function only ever runs
|
|
8285
|
+
// after `jj` is fully constructed, so the self-reference is safe
|
|
8286
|
+
// (same pattern used elsewhere in this file, e.g. `jj.new(...)`).
|
|
8287
|
+
const descendants = await jj._findDescendants(wcId, null);
|
|
8288
|
+
if (descendants.length > 0) {
|
|
8289
|
+
await jj._rebuildDescendants(wcId, null, oldFileSnapshot);
|
|
8290
|
+
}
|
|
8201
8291
|
}
|
|
8202
8292
|
} catch {
|
|
8203
8293
|
// Best-effort content refresh; tracking state is already updated.
|
|
@@ -8553,6 +8643,17 @@ async function createJJ(options) {
|
|
|
8553
8643
|
});
|
|
8554
8644
|
}
|
|
8555
8645
|
|
|
8646
|
+
await workingCopy.load();
|
|
8647
|
+
|
|
8648
|
+
// Capture disk state right before this write mutates it, and record
|
|
8649
|
+
// an operation for it (issue #43(1)): write() previously recorded NO
|
|
8650
|
+
// operation at all, so an edit typed into a change's working copy
|
|
8651
|
+
// could never be rewound by undo()/operations.restore() regardless
|
|
8652
|
+
// of which op you targeted — the change kept its post-edit content
|
|
8653
|
+
// forever. Mirrors the "snapshot before, record after" pattern every
|
|
8654
|
+
// other mutator in this file uses (see describe(), moveChange()).
|
|
8655
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
8656
|
+
|
|
8556
8657
|
const fullPath = `${dir}/${args.path}`;
|
|
8557
8658
|
|
|
8558
8659
|
// Ensure directory exists
|
|
@@ -8568,7 +8669,6 @@ async function createJJ(options) {
|
|
|
8568
8669
|
await fs.promises.writeFile(fullPath, data, 'utf8');
|
|
8569
8670
|
|
|
8570
8671
|
// Track the file in working copy
|
|
8571
|
-
await workingCopy.load();
|
|
8572
8672
|
const stats = await fs.promises.stat(fullPath);
|
|
8573
8673
|
await workingCopy.trackFile(args.path, {
|
|
8574
8674
|
mtime: stats.mtime,
|
|
@@ -8576,6 +8676,20 @@ async function createJJ(options) {
|
|
|
8576
8676
|
mode: stats.mode,
|
|
8577
8677
|
});
|
|
8578
8678
|
|
|
8679
|
+
await oplog.recordOperation({
|
|
8680
|
+
timestamp: new Date().toISOString(),
|
|
8681
|
+
user: await getUserOplogInfo(),
|
|
8682
|
+
description: `write ${args.path}`,
|
|
8683
|
+
parents: [],
|
|
8684
|
+
view: {
|
|
8685
|
+
bookmarks: {},
|
|
8686
|
+
remoteBookmarks: {},
|
|
8687
|
+
heads: await computeCurrentHeads(),
|
|
8688
|
+
workingCopy: workingCopy.getCurrentChangeId(),
|
|
8689
|
+
fileSnapshot: fileSnapshotBefore, // Store filesystem state from before this write
|
|
8690
|
+
},
|
|
8691
|
+
});
|
|
8692
|
+
|
|
8579
8693
|
// Return useful information about the written file
|
|
8580
8694
|
return {
|
|
8581
8695
|
path: args.path,
|
|
@@ -9004,6 +9118,15 @@ async function createJJ(options) {
|
|
|
9004
9118
|
async moveChange(args) {
|
|
9005
9119
|
await graph.load();
|
|
9006
9120
|
|
|
9121
|
+
// Capture disk state right before this rebase mutates anything, so
|
|
9122
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
9123
|
+
// #43(1): rebase()/moveChange() previously recorded no "files
|
|
9124
|
+
// before" snapshot at all, so restoring to a point right before a
|
|
9125
|
+
// rebase ran had no on-disk state to fall back to and would instead
|
|
9126
|
+
// pick up whatever a LATER operation happened to record (the wrong
|
|
9127
|
+
// point in time).
|
|
9128
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
9129
|
+
|
|
9007
9130
|
// Support multiple parameter names for flexibility
|
|
9008
9131
|
const changeId = args.changeId || args.from || args.source;
|
|
9009
9132
|
const newParent = args.newParent || args.to || args.destination;
|
|
@@ -9116,6 +9239,32 @@ async function createJJ(options) {
|
|
|
9116
9239
|
await conflicts.addConflict(conflict);
|
|
9117
9240
|
}
|
|
9118
9241
|
|
|
9242
|
+
// Materialize real conflict-marker text into the moved change's file
|
|
9243
|
+
// content — issue #43(3): rebase()/moveChange() used to record the
|
|
9244
|
+
// conflict correctly as DATA (via conflicts.addConflict() above) but
|
|
9245
|
+
// left the file's content exactly as it was pre-rebase ("ours"),
|
|
9246
|
+
// never writing marker text into change.fileSnapshot or, when this
|
|
9247
|
+
// change is the checked-out working copy, onto disk. Reuses
|
|
9248
|
+
// conflicts.markers()'s own marker-formatting logic (the same format
|
|
9249
|
+
// jj.conflicts.markers() already returns) instead of re-deriving
|
|
9250
|
+
// marker text a second way.
|
|
9251
|
+
if (detectedConflicts.length > 0) {
|
|
9252
|
+
change.fileSnapshot = { ...(change.fileSnapshot || {}) };
|
|
9253
|
+
const isCheckedOut = changeId === workingCopy.getCurrentChangeId();
|
|
9254
|
+
for (const conflict of detectedConflicts) {
|
|
9255
|
+
const markerText = await jj.conflicts.markers({ conflictId: conflict.conflictId });
|
|
9256
|
+
change.fileSnapshot[conflict.path] = markerText;
|
|
9257
|
+
if (isCheckedOut) {
|
|
9258
|
+
const fullPath = join(dir, conflict.path);
|
|
9259
|
+
const pathParts = conflict.path.split('/');
|
|
9260
|
+
if (pathParts.length > 1) {
|
|
9261
|
+
await mkdirp(fs, join(dir, pathParts.slice(0, -1).join('/')));
|
|
9262
|
+
}
|
|
9263
|
+
await fs.promises.writeFile(fullPath, markerText, 'utf8');
|
|
9264
|
+
}
|
|
9265
|
+
}
|
|
9266
|
+
}
|
|
9267
|
+
|
|
9119
9268
|
// Update parent
|
|
9120
9269
|
change.parents = [newParent];
|
|
9121
9270
|
await graph.updateChange(change);
|
|
@@ -9131,8 +9280,9 @@ async function createJJ(options) {
|
|
|
9131
9280
|
view: {
|
|
9132
9281
|
bookmarks: {},
|
|
9133
9282
|
remoteBookmarks: {},
|
|
9134
|
-
heads:
|
|
9283
|
+
heads: await computeCurrentHeads(),
|
|
9135
9284
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
9285
|
+
fileSnapshot: fileSnapshotBefore,
|
|
9136
9286
|
},
|
|
9137
9287
|
});
|
|
9138
9288
|
|
|
@@ -9587,6 +9737,16 @@ async function createJJ(options) {
|
|
|
9587
9737
|
description: args.message || '(no description)',
|
|
9588
9738
|
timestamp: new Date().toISOString(),
|
|
9589
9739
|
fileSnapshot: initialSnapshot, // Initialize with parent's snapshot
|
|
9740
|
+
// Issue #43(2): marks this as an internal, synthetic artifact
|
|
9741
|
+
// rather than something the user actually created, so the revset
|
|
9742
|
+
// engine's orphan-visibility filter (_computeHiddenOrphans) can
|
|
9743
|
+
// still hide it after it's un-referenced by a later undo()/
|
|
9744
|
+
// operations.restore(), even if some out-of-band mutation (e.g.
|
|
9745
|
+
// write()+snapshot() before the undo unwinds this far) has since
|
|
9746
|
+
// made it look "non-empty" relative to its parent. A real
|
|
9747
|
+
// description (args.message, or a later describe()) still makes
|
|
9748
|
+
// it visible regardless, via the UNDESCRIBED check.
|
|
9749
|
+
autoCreated: true,
|
|
9590
9750
|
};
|
|
9591
9751
|
|
|
9592
9752
|
// Dispatch change:creating event (preventable)
|
|
@@ -9707,17 +9867,63 @@ async function createJJ(options) {
|
|
|
9707
9867
|
await graph.load();
|
|
9708
9868
|
await workingCopy.load();
|
|
9709
9869
|
const result = await workingCopy.snapshot();
|
|
9870
|
+
|
|
9871
|
+
// Disk content itself is not mutated by this call (it's a read,
|
|
9872
|
+
// not a write) — the same value is therefore valid both as the
|
|
9873
|
+
// refreshed change.fileSnapshot AND as the recorded operation's
|
|
9874
|
+
// `view.fileSnapshot` "before" value (nothing on disk changes
|
|
9875
|
+
// between the two).
|
|
9876
|
+
const wcId = workingCopy.getCurrentChangeId();
|
|
9877
|
+
const currentDiskSnapshot = await snapshotFilesystem();
|
|
9878
|
+
|
|
9710
9879
|
// Refresh the working-copy change's content snapshot from disk.
|
|
9880
|
+
/** @type {Record<string, any>} */
|
|
9881
|
+
const changeSnapshot = {};
|
|
9711
9882
|
try {
|
|
9712
|
-
const wcId = workingCopy.getCurrentChangeId();
|
|
9713
9883
|
const change = await graph.getChange(wcId);
|
|
9714
9884
|
if (change) {
|
|
9715
|
-
|
|
9885
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore()
|
|
9886
|
+
// can reverse this content refresh — see issue #43(1):
|
|
9887
|
+
// snapshot() previously mutated `change.fileSnapshot` in place
|
|
9888
|
+
// with no "before" capture and recorded no operation at all, so
|
|
9889
|
+
// computeGraphReversal() had nothing to find for this changeId
|
|
9890
|
+
// if a restore()/undo() landed between an out-of-band edit and
|
|
9891
|
+
// the next real command.
|
|
9892
|
+
changeSnapshot[wcId] = structuredClone(change);
|
|
9893
|
+
change.fileSnapshot = currentDiskSnapshot;
|
|
9716
9894
|
await graph.updateChange(change);
|
|
9895
|
+
|
|
9896
|
+
// Issue #45: if the working-copy change isn't a leaf (e.g. we
|
|
9897
|
+
// edit()'d into an older change earlier in the session), its
|
|
9898
|
+
// descendants' fileSnapshot still reflects the OLD content and
|
|
9899
|
+
// needs to be rebased onto the content just committed above —
|
|
9900
|
+
// matching real jj's automatic rebase-on-edit. edit() itself is a
|
|
9901
|
+
// pure checkout and never mutates content, so this is the actual
|
|
9902
|
+
// moment content changes and the right place to propagate.
|
|
9903
|
+
const descendants = await this._findDescendants(wcId, null);
|
|
9904
|
+
if (descendants.length > 0) {
|
|
9905
|
+
await this._rebuildDescendants(wcId, null, changeSnapshot[wcId].fileSnapshot);
|
|
9906
|
+
}
|
|
9717
9907
|
}
|
|
9718
9908
|
} catch {
|
|
9719
|
-
// Best-effort content refresh.
|
|
9909
|
+
// Best-effort content refresh (including descendant propagation).
|
|
9720
9910
|
}
|
|
9911
|
+
|
|
9912
|
+
await oplog.recordOperation({
|
|
9913
|
+
timestamp: new Date().toISOString(),
|
|
9914
|
+
user: await getUserOplogInfo(),
|
|
9915
|
+
description: 'snapshot working copy',
|
|
9916
|
+
parents: [],
|
|
9917
|
+
changeSnapshot,
|
|
9918
|
+
view: {
|
|
9919
|
+
bookmarks: {},
|
|
9920
|
+
remoteBookmarks: {},
|
|
9921
|
+
heads: await computeCurrentHeads(),
|
|
9922
|
+
workingCopy: wcId,
|
|
9923
|
+
fileSnapshot: currentDiskSnapshot,
|
|
9924
|
+
},
|
|
9925
|
+
});
|
|
9926
|
+
|
|
9721
9927
|
return result;
|
|
9722
9928
|
},
|
|
9723
9929
|
|
|
@@ -10051,6 +10257,22 @@ async function createJJ(options) {
|
|
|
10051
10257
|
changeSnapshot[previousChangeId] = structuredClone(previousChange);
|
|
10052
10258
|
previousChange.fileSnapshot = currentSnapshot;
|
|
10053
10259
|
await graph.updateChange(previousChange);
|
|
10260
|
+
|
|
10261
|
+
// Issue #45: this is the OTHER place a checked-out change's
|
|
10262
|
+
// content gets committed to the graph — not through snapshot()/
|
|
10263
|
+
// autoSnapshotWorkingCopy() at all, but here, inline, when
|
|
10264
|
+
// edit() leaves a change it was checked out on. If that change
|
|
10265
|
+
// isn't a leaf (we'd edit()'d into an ancestor), its descendants
|
|
10266
|
+
// still have the OLD content and need the same rebase-on-edit
|
|
10267
|
+
// treatment applied at the other two commit sites.
|
|
10268
|
+
const descendants = await this._findDescendants(previousChangeId, null);
|
|
10269
|
+
if (descendants.length > 0) {
|
|
10270
|
+
await this._rebuildDescendants(
|
|
10271
|
+
previousChangeId,
|
|
10272
|
+
null,
|
|
10273
|
+
changeSnapshot[previousChangeId].fileSnapshot
|
|
10274
|
+
);
|
|
10275
|
+
}
|
|
10054
10276
|
}
|
|
10055
10277
|
}
|
|
10056
10278
|
}
|
|
@@ -10181,8 +10403,20 @@ async function createJJ(options) {
|
|
|
10181
10403
|
const current = await graph.getChange(changeId);
|
|
10182
10404
|
if (current) redoChangeSnapshot[changeId] = current;
|
|
10183
10405
|
}
|
|
10406
|
+
// Capture disk state right before this undo() runs. Used both as
|
|
10407
|
+
// `redoFileSnapshot` (for a later redo() — only meaningful when this
|
|
10408
|
+
// reversal actually touches files) AND, unconditionally, as THIS
|
|
10409
|
+
// undo operation's own `view.fileSnapshot` "before" value below —
|
|
10410
|
+
// see issue #43(1): undo()/redo()/operations.restore() used to
|
|
10411
|
+
// record `view: landingView` verbatim, i.e. reusing the LANDING
|
|
10412
|
+
// operation's own recorded fileSnapshot (disk state before THAT op
|
|
10413
|
+
// ran) instead of disk state before THIS undo ran. A later
|
|
10414
|
+
// restore()/undo() scanning forward past this one would then pick
|
|
10415
|
+
// up that stale, wrong snapshot instead of what was actually on disk
|
|
10416
|
+
// right before this undo executed.
|
|
10417
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
10184
10418
|
const redoFileSnapshot =
|
|
10185
|
-
reversal.fileSnapshot !== undefined ?
|
|
10419
|
+
reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
|
|
10186
10420
|
let redoConflictsSnapshot;
|
|
10187
10421
|
if (reversal.conflictsSnapshot !== undefined) {
|
|
10188
10422
|
await conflicts.load();
|
|
@@ -10223,7 +10457,7 @@ async function createJJ(options) {
|
|
|
10223
10457
|
redoFileSnapshot,
|
|
10224
10458
|
redoConflictsSnapshot,
|
|
10225
10459
|
parents: [],
|
|
10226
|
-
view: landingView,
|
|
10460
|
+
view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10227
10461
|
});
|
|
10228
10462
|
|
|
10229
10463
|
const restoredFileCount =
|
|
@@ -10317,6 +10551,15 @@ async function createJJ(options) {
|
|
|
10317
10551
|
await baseGraph.updateChange(snapshot);
|
|
10318
10552
|
}
|
|
10319
10553
|
}
|
|
10554
|
+
|
|
10555
|
+
// Capture disk state right before THIS redo() mutates it — see issue
|
|
10556
|
+
// #43(1)/the matching fix in undo(): `view: redoneView` below reuses
|
|
10557
|
+
// the ORIGINAL (re-applied) operation's own recorded pointers, but
|
|
10558
|
+
// its `fileSnapshot` sub-field must reflect disk state before THIS
|
|
10559
|
+
// redo ran, not before that original operation ran, or a later
|
|
10560
|
+
// restore()/undo() scanning forward past this redo would pick up a
|
|
10561
|
+
// stale, wrong snapshot.
|
|
10562
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
10320
10563
|
if (undoOp.redoFileSnapshot !== undefined) {
|
|
10321
10564
|
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
10322
10565
|
}
|
|
@@ -10337,7 +10580,7 @@ async function createJJ(options) {
|
|
|
10337
10580
|
redoOf: undoOp.id,
|
|
10338
10581
|
changeSnapshot: undoChangeSnapshot,
|
|
10339
10582
|
parents: [],
|
|
10340
|
-
view: redoneView,
|
|
10583
|
+
view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10341
10584
|
});
|
|
10342
10585
|
|
|
10343
10586
|
const restoredFileCount =
|
|
@@ -10643,6 +10886,17 @@ async function createJJ(options) {
|
|
|
10643
10886
|
if (current) changeSnapshot[changeId] = current;
|
|
10644
10887
|
}
|
|
10645
10888
|
|
|
10889
|
+
// Capture disk state right before THIS restore() mutates it — see
|
|
10890
|
+
// issue #43(1)/the matching fix in undo()/redo(): recording
|
|
10891
|
+
// `view: targetOp.view` verbatim reused targetOp's OWN recorded
|
|
10892
|
+
// `fileSnapshot` (disk state before targetOp ran), not disk state
|
|
10893
|
+
// before THIS restore ran. A later restore()/undo() scanning
|
|
10894
|
+
// forward past this one would then pick up that stale, wrong
|
|
10895
|
+
// snapshot instead of what was actually on disk right before this
|
|
10896
|
+
// restore executed — this is exactly the "forward restore to
|
|
10897
|
+
// 'edit tip' lost style.css" repro.
|
|
10898
|
+
const diskSnapshotBeforeThisOp = await snapshotFilesystem();
|
|
10899
|
+
|
|
10646
10900
|
await applyGraphReversal(reversal);
|
|
10647
10901
|
|
|
10648
10902
|
// Record this restoration as a new operation
|
|
@@ -10652,7 +10906,7 @@ async function createJJ(options) {
|
|
|
10652
10906
|
description: `restore to operation ${args.operation}`,
|
|
10653
10907
|
parents: [],
|
|
10654
10908
|
changeSnapshot,
|
|
10655
|
-
view: targetOp.view,
|
|
10909
|
+
view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10656
10910
|
});
|
|
10657
10911
|
|
|
10658
10912
|
return {
|
|
@@ -10888,6 +11142,15 @@ async function createJJ(options) {
|
|
|
10888
11142
|
await workingCopy.load();
|
|
10889
11143
|
await userConfig.load();
|
|
10890
11144
|
|
|
11145
|
+
// Capture disk state right before this squash mutates anything, so
|
|
11146
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
11147
|
+
// #43(1): squash() previously recorded no "files before" snapshot at
|
|
11148
|
+
// all, so restoring to a point right before a squash ran had no
|
|
11149
|
+
// recorded on-disk state and would fall through to whatever a LATER
|
|
11150
|
+
// operation happened to record instead (the repro: a forward
|
|
11151
|
+
// restore to "edit tip" lost `style.css`).
|
|
11152
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
11153
|
+
|
|
10891
11154
|
// Support 'into' as alias for 'dest' (matches JJ CLI)
|
|
10892
11155
|
const dest = args.into || args.dest;
|
|
10893
11156
|
|
|
@@ -11019,6 +11282,18 @@ async function createJJ(options) {
|
|
|
11019
11282
|
// every on-disk file as untracked/new until something re-wrote
|
|
11020
11283
|
// it and triggered a fresh auto-snapshot.
|
|
11021
11284
|
fileSnapshot: { ...destChange.fileSnapshot },
|
|
11285
|
+
// Marks this as an internal, synthetic artifact rather than
|
|
11286
|
+
// something the user actually created — issue #43(2): if this
|
|
11287
|
+
// squash is later undone, this change becomes unreferenced, and
|
|
11288
|
+
// by then `destChange` may have been reverted to pre-squash
|
|
11289
|
+
// content, making this change look "non-empty" relative to its
|
|
11290
|
+
// (now-reverted) parent even though it never held any real user
|
|
11291
|
+
// content of its own. The revset engine's orphan-visibility
|
|
11292
|
+
// filter (_computeHiddenOrphans) treats `autoCreated` as
|
|
11293
|
+
// sufficient on its own (given it's also unreferenced and
|
|
11294
|
+
// undescribed) rather than re-diffing against a parent that may
|
|
11295
|
+
// no longer resemble what this change was actually created from.
|
|
11296
|
+
autoCreated: true,
|
|
11022
11297
|
};
|
|
11023
11298
|
|
|
11024
11299
|
await graph.addChange(newChange); // Middleware will sync to Git
|
|
@@ -11041,8 +11316,18 @@ async function createJJ(options) {
|
|
|
11041
11316
|
view: {
|
|
11042
11317
|
bookmarks: {},
|
|
11043
11318
|
remoteBookmarks: {},
|
|
11044
|
-
|
|
11319
|
+
// issue #43(1): this used to hard-code `[destChangeId]` — wrong
|
|
11320
|
+
// whenever squash() created a fresh working-copy change on top
|
|
11321
|
+
// of dest (the real new tip was `newWorkingCopyId`, not
|
|
11322
|
+
// `destChangeId` itself), and incomplete whenever a sibling head
|
|
11323
|
+
// elsewhere in the graph existed and wasn't touched by this
|
|
11324
|
+
// squash at all ("two sibling changes report only one head
|
|
11325
|
+
// between them"). computeCurrentHeads() reflects the WHOLE
|
|
11326
|
+
// repo's actual head set after this squash, not just the
|
|
11327
|
+
// changeId(s) this call happened to touch.
|
|
11328
|
+
heads: await computeCurrentHeads(),
|
|
11045
11329
|
workingCopy: newWorkingCopyId,
|
|
11330
|
+
fileSnapshot: fileSnapshotBefore,
|
|
11046
11331
|
},
|
|
11047
11332
|
});
|
|
11048
11333
|
|
|
@@ -11114,6 +11399,12 @@ async function createJJ(options) {
|
|
|
11114
11399
|
// abandoning any OTHER change must leave @ exactly where it was.
|
|
11115
11400
|
const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
|
|
11116
11401
|
|
|
11402
|
+
// Capture disk state right before this abandon mutates anything, so
|
|
11403
|
+
// undo()/redo()/operations.restore() can rewind past it — see issue
|
|
11404
|
+
// #43(1): abandon() previously recorded no "files before" snapshot
|
|
11405
|
+
// at all.
|
|
11406
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
11407
|
+
|
|
11117
11408
|
// Dispatch change:abandoning event (preventable)
|
|
11118
11409
|
await dispatchEventAsync(jj, 'change:abandoning', {
|
|
11119
11410
|
operation: 'abandon',
|
|
@@ -11148,8 +11439,16 @@ async function createJJ(options) {
|
|
|
11148
11439
|
view: {
|
|
11149
11440
|
bookmarks: {},
|
|
11150
11441
|
remoteBookmarks: {},
|
|
11151
|
-
|
|
11442
|
+
// issue #43(1): this used to hard-code `[]`, dropping every live
|
|
11443
|
+
// head from the recorded operation (including any untouched
|
|
11444
|
+
// sibling branch — "two sibling changes report only one head
|
|
11445
|
+
// between them"). computeCurrentHeads() reflects the actual
|
|
11446
|
+
// repo-wide head set right after this abandon (and its
|
|
11447
|
+
// reparenting) ran, before the compensating jj.new() below (if
|
|
11448
|
+
// any) records its own separate operation.
|
|
11449
|
+
heads: await computeCurrentHeads(),
|
|
11152
11450
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11451
|
+
fileSnapshot: fileSnapshotBefore,
|
|
11153
11452
|
},
|
|
11154
11453
|
});
|
|
11155
11454
|
|
|
@@ -12212,9 +12511,27 @@ async function createJJ(options) {
|
|
|
12212
12511
|
* @private
|
|
12213
12512
|
* @param {string} ancestorId - The ancestor change that was updated
|
|
12214
12513
|
* @param {string} stopAtChangeId - Don't rebuild this change (usually working copy)
|
|
12215
|
-
* @
|
|
12514
|
+
* @param {Record<string, string>} [originalAncestorSnapshot] - The
|
|
12515
|
+
* ancestor's fileSnapshot as it was BEFORE the caller's own
|
|
12516
|
+
* `graph.updateChange()` for `ancestorId` ran. Callers must invoke this
|
|
12517
|
+
* function AFTER committing the ancestor's new content (that's the
|
|
12518
|
+
* natural order — the ancestor's content must exist before its
|
|
12519
|
+
* descendants can be rebased onto it), but that means a live
|
|
12520
|
+
* `graph.getChange(ancestorId).fileSnapshot` read inside this function
|
|
12521
|
+
* would already see the NEW content. For the ancestor's DIRECT
|
|
12522
|
+
* children, this function compares each descendant's stale content
|
|
12523
|
+
* against "the original parent state" to figure out which lines are
|
|
12524
|
+
* the descendant's own edits; if that "original" state were actually
|
|
12525
|
+
* the already-new ancestor content, every line of the stale
|
|
12526
|
+
* descendant would look like a deliberate edit (stale != new) and get
|
|
12527
|
+
* preserved verbatim — silently reproducing the staleness instead of
|
|
12528
|
+
* fixing it. Passing the pre-mutation snapshot here lets the ancestor's
|
|
12529
|
+
* direct children be diffed against the correct baseline. Deeper
|
|
12530
|
+
* descendants are unaffected (their own immediate parent hasn't been
|
|
12531
|
+
* mutated yet when this function reads it, so the live read is
|
|
12532
|
+
* correct for them). Optional for backward compatibility.
|
|
12216
12533
|
*/
|
|
12217
|
-
async _rebuildDescendants(ancestorId, stopAtChangeId) {
|
|
12534
|
+
async _rebuildDescendants(ancestorId, stopAtChangeId, originalAncestorSnapshot) {
|
|
12218
12535
|
// Find all descendants of ancestorId
|
|
12219
12536
|
const allChanges = graph.getAll();
|
|
12220
12537
|
|
|
@@ -12222,11 +12539,16 @@ async function createJJ(options) {
|
|
|
12222
12539
|
const originalParentStates = new Map();
|
|
12223
12540
|
for (const change of allChanges) {
|
|
12224
12541
|
if (change.parents && change.parents.length > 0) {
|
|
12225
|
-
const
|
|
12542
|
+
const parentId = change.parents[0];
|
|
12543
|
+
const parent = await graph.getChange(parentId);
|
|
12226
12544
|
if (parent) {
|
|
12545
|
+
const parentSnapshot =
|
|
12546
|
+
parentId === ancestorId && originalAncestorSnapshot !== undefined
|
|
12547
|
+
? JSON.parse(JSON.stringify(originalAncestorSnapshot))
|
|
12548
|
+
: JSON.parse(JSON.stringify(parent.fileSnapshot || {}));
|
|
12227
12549
|
originalParentStates.set(change.changeId, {
|
|
12228
|
-
parentId
|
|
12229
|
-
parentSnapshot
|
|
12550
|
+
parentId,
|
|
12551
|
+
parentSnapshot,
|
|
12230
12552
|
});
|
|
12231
12553
|
}
|
|
12232
12554
|
}
|
|
@@ -12296,30 +12618,78 @@ async function createJJ(options) {
|
|
|
12296
12618
|
const allFiles = new Set([
|
|
12297
12619
|
...Object.keys(descendant.fileSnapshot || {}),
|
|
12298
12620
|
...Object.keys(parent.fileSnapshot || {}),
|
|
12621
|
+
...Object.keys(originalParentSnapshot || {}),
|
|
12299
12622
|
]);
|
|
12300
12623
|
|
|
12301
12624
|
for (const filePath of allFiles) {
|
|
12625
|
+
const descendantExists = Object.prototype.hasOwnProperty.call(
|
|
12626
|
+
descendant.fileSnapshot || {},
|
|
12627
|
+
filePath
|
|
12628
|
+
);
|
|
12629
|
+
const originalParentExists = Object.prototype.hasOwnProperty.call(
|
|
12630
|
+
originalParentSnapshot || {},
|
|
12631
|
+
filePath
|
|
12632
|
+
);
|
|
12633
|
+
const updatedParentExists = Object.prototype.hasOwnProperty.call(
|
|
12634
|
+
parent.fileSnapshot || {},
|
|
12635
|
+
filePath
|
|
12636
|
+
);
|
|
12637
|
+
|
|
12638
|
+
// A file's EXISTENCE is a separate question from its content —
|
|
12639
|
+
// coercing "doesn't exist" to '' before diffing (the previous
|
|
12640
|
+
// version of this loop did) means a descendant that deliberately
|
|
12641
|
+
// deleted a file the old parent had gets that file resurrected as
|
|
12642
|
+
// an empty string once rebuilt, instead of staying deleted. This
|
|
12643
|
+
// was a real, latent bug: nothing called this function at all
|
|
12644
|
+
// until issue #45 gave it a real caller, so it was never
|
|
12645
|
+
// exercised against a deletion before.
|
|
12646
|
+
if (
|
|
12647
|
+
descendantExists === originalParentExists &&
|
|
12648
|
+
(!descendantExists ||
|
|
12649
|
+
descendant.fileSnapshot[filePath] === originalParentSnapshot[filePath])
|
|
12650
|
+
) {
|
|
12651
|
+
// Descendant never touched this file relative to its old
|
|
12652
|
+
// parent (same existence + content as before) -- just follow
|
|
12653
|
+
// whatever the updated parent did with it (added, removed, or
|
|
12654
|
+
// changed since the descendant branched off).
|
|
12655
|
+
if (updatedParentExists) newSnapshot[filePath] = parent.fileSnapshot[filePath];
|
|
12656
|
+
continue; // else: stays deleted -- don't add it to newSnapshot
|
|
12657
|
+
}
|
|
12658
|
+
|
|
12659
|
+
if (originalParentExists && !descendantExists) {
|
|
12660
|
+
// Descendant deliberately deleted a file the old parent had —
|
|
12661
|
+
// preserve that deletion rather than resurrecting it.
|
|
12662
|
+
continue;
|
|
12663
|
+
}
|
|
12664
|
+
|
|
12665
|
+
if (!originalParentExists && descendantExists && !updatedParentExists) {
|
|
12666
|
+
// Descendant added a file that didn't exist on either side of
|
|
12667
|
+
// the parent at all -- keep the descendant's own addition.
|
|
12668
|
+
newSnapshot[filePath] = descendant.fileSnapshot[filePath];
|
|
12669
|
+
continue;
|
|
12670
|
+
}
|
|
12671
|
+
|
|
12672
|
+
// Both the descendant and the parent have real content for this
|
|
12673
|
+
// file (whether or not it existed originally) -- do the actual
|
|
12674
|
+
// line-level replay: find which lines the descendant changed
|
|
12675
|
+
// from the OLD parent, then reapply just those lines onto the
|
|
12676
|
+
// NEW parent's content.
|
|
12302
12677
|
const descendantContent = descendant.fileSnapshot[filePath] || '';
|
|
12303
12678
|
const originalParentContent = originalParentSnapshot[filePath] || '';
|
|
12304
12679
|
const updatedParentContent = parent.fileSnapshot[filePath] || '';
|
|
12305
12680
|
|
|
12306
|
-
// Find which lines descendant modified from original parent
|
|
12307
12681
|
const descendantLines = descendantContent.split('\n');
|
|
12308
12682
|
const originalParentLines = originalParentContent.split('\n');
|
|
12309
12683
|
const updatedParentLines = updatedParentContent.split('\n');
|
|
12310
12684
|
|
|
12311
|
-
// Rebuild file: start with updated parent, apply descendant's modifications
|
|
12312
12685
|
const resultLines = [...updatedParentLines];
|
|
12313
12686
|
|
|
12314
|
-
// Find lines that descendant modified
|
|
12315
12687
|
for (let i = 0; i < Math.max(descendantLines.length, originalParentLines.length); i++) {
|
|
12316
12688
|
const descendantLine = i < descendantLines.length ? descendantLines[i] : undefined;
|
|
12317
12689
|
const originalParentLine =
|
|
12318
12690
|
i < originalParentLines.length ? originalParentLines[i] : undefined;
|
|
12319
12691
|
|
|
12320
|
-
// If descendant modified this line from original parent, apply that modification
|
|
12321
12692
|
if (descendantLine !== originalParentLine) {
|
|
12322
|
-
// Extend resultLines if needed
|
|
12323
12693
|
while (resultLines.length <= i) {
|
|
12324
12694
|
resultLines.push('');
|
|
12325
12695
|
}
|
|
@@ -13411,6 +13781,32 @@ async function createJJ(options) {
|
|
|
13411
13781
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
13412
13782
|
}
|
|
13413
13783
|
|
|
13784
|
+
// Snapshot BEFORE mutating so undo()/redo()/operations.restore()
|
|
13785
|
+
// can reverse this resolution — issue #43(4): conflicts.resolve()
|
|
13786
|
+
// previously recorded no operation at all, so resolving a conflict
|
|
13787
|
+
// could never be undone: no changeSnapshot of the working-copy
|
|
13788
|
+
// change's content, no conflictsSnapshot of the pre-resolve
|
|
13789
|
+
// conflict state, and no fileSnapshot of the on-disk content
|
|
13790
|
+
// before the resolved content was written.
|
|
13791
|
+
await graph.load();
|
|
13792
|
+
await workingCopy.load();
|
|
13793
|
+
// structuredClone matters here (unlike the read-only conflictsSnapshot
|
|
13794
|
+
// capture in moveChange()/merge()): resolveConflict() below mutates
|
|
13795
|
+
// the SAME conflict object in place (`conflict.resolved = true`),
|
|
13796
|
+
// and Object.fromEntries() on the live Map would otherwise leave
|
|
13797
|
+
// this "before" snapshot holding a reference to that very object —
|
|
13798
|
+
// corrupting it into already showing `resolved: true` by the time
|
|
13799
|
+
// it's recorded.
|
|
13800
|
+
const conflictsSnapshotBefore = structuredClone({
|
|
13801
|
+
conflicts: Object.fromEntries(conflicts.conflicts),
|
|
13802
|
+
fileConflicts: Object.fromEntries(conflicts.fileConflicts),
|
|
13803
|
+
});
|
|
13804
|
+
const fileSnapshotBefore = await snapshotFilesystem();
|
|
13805
|
+
const wcId = workingCopy.getCurrentChangeId();
|
|
13806
|
+
const wcChange = await graph.getChange(wcId);
|
|
13807
|
+
/** @type {Record<string, any>} */
|
|
13808
|
+
const changeSnapshot = wcChange ? { [wcId]: structuredClone(wcChange) } : {};
|
|
13809
|
+
|
|
13414
13810
|
let resolvedContent;
|
|
13415
13811
|
|
|
13416
13812
|
// v0.5: Support different resolution methods
|
|
@@ -13454,6 +13850,36 @@ async function createJJ(options) {
|
|
|
13454
13850
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
13455
13851
|
await conflicts.save();
|
|
13456
13852
|
|
|
13853
|
+
// Refresh the working-copy change's own fileSnapshot to match the
|
|
13854
|
+
// resolved on-disk content (conflicted files always live under
|
|
13855
|
+
// the checked-out working copy — see the writeFile above), so
|
|
13856
|
+
// undo()/restore() has an accurate "after" state to compare
|
|
13857
|
+
// against, and log()/read() see the resolution immediately rather
|
|
13858
|
+
// than waiting for the next auto-snapshot.
|
|
13859
|
+
if (wcChange) {
|
|
13860
|
+
wcChange.fileSnapshot = {
|
|
13861
|
+
...(wcChange.fileSnapshot || {}),
|
|
13862
|
+
[conflict.path]: resolvedContent,
|
|
13863
|
+
};
|
|
13864
|
+
await graph.updateChange(wcChange);
|
|
13865
|
+
}
|
|
13866
|
+
|
|
13867
|
+
await oplog.recordOperation({
|
|
13868
|
+
timestamp: new Date().toISOString(),
|
|
13869
|
+
user: await getUserOplogInfo(),
|
|
13870
|
+
description: `resolve conflict ${args.conflictId} (${conflict.path})`,
|
|
13871
|
+
parents: [],
|
|
13872
|
+
changeSnapshot,
|
|
13873
|
+
conflictsSnapshot: conflictsSnapshotBefore,
|
|
13874
|
+
view: {
|
|
13875
|
+
bookmarks: {},
|
|
13876
|
+
remoteBookmarks: {},
|
|
13877
|
+
heads: await computeCurrentHeads(),
|
|
13878
|
+
workingCopy: wcId,
|
|
13879
|
+
fileSnapshot: fileSnapshotBefore,
|
|
13880
|
+
},
|
|
13881
|
+
});
|
|
13882
|
+
|
|
13457
13883
|
return { resolved: true };
|
|
13458
13884
|
},
|
|
13459
13885
|
|