isomorphic-jj 1.9.0 → 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 +275 -0
- package/dist/index.cjs +571 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +571 -23
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/api/repository.js +380 -15
- package/src/core/revset-engine.js +140 -4
- package/src/core/working-copy.js +51 -4
package/dist/index.cjs
CHANGED
|
@@ -991,6 +991,44 @@ class ChangeGraph {
|
|
|
991
991
|
*/
|
|
992
992
|
|
|
993
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Normalize an mtime value to a millisecond epoch number so two stat()
|
|
996
|
+
* results (or a stat() result and a stored value) can be compared by VALUE
|
|
997
|
+
* instead of by object identity (see issue #41).
|
|
998
|
+
*
|
|
999
|
+
* `fs.promises.stat()` returns a fresh `Date` instance on every call, so two
|
|
1000
|
+
* separate stats of the same unchanged file are never `===`/`!==`-equal even
|
|
1001
|
+
* when the underlying timestamp is identical. Additionally, once a tracked
|
|
1002
|
+
* mtime has been through a `JSON.stringify`/`JSON.parse` round-trip (Storage
|
|
1003
|
+
* persists working-copy state as JSON), a `Date` becomes an ISO string, not a
|
|
1004
|
+
* `Date` — so the stored side of the comparison may be a `Date`, a plain
|
|
1005
|
+
* epoch-ms number (this module now stores numbers going forward), or an ISO
|
|
1006
|
+
* string (older/reloaded-from-disk state). This normalizes all three to a
|
|
1007
|
+
* comparable number.
|
|
1008
|
+
*
|
|
1009
|
+
* Deliberately duck-types the Date case (`typeof value.getTime ===
|
|
1010
|
+
* 'function'`) instead of `value instanceof Date`: Node's built-in `fs`
|
|
1011
|
+
* module constructs `Stats.mtime` using its own realm's `Date`, which is not
|
|
1012
|
+
* always the same `Date` constructor as this module's under test runners
|
|
1013
|
+
* that sandbox each test file in its own VM context (observed under Jest
|
|
1014
|
+
* with `--experimental-vm-modules`) — there, a real `Date` instance from
|
|
1015
|
+
* `fs.promises.stat()` fails `instanceof Date` here even though it's a
|
|
1016
|
+
* genuine Date. Duck-typing survives that realm mismatch.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {Date|number|string|undefined} value
|
|
1019
|
+
* @returns {number}
|
|
1020
|
+
*/
|
|
1021
|
+
function mtimeMs(value) {
|
|
1022
|
+
if (value === null || value === undefined) return NaN;
|
|
1023
|
+
if (typeof value === 'number') return value;
|
|
1024
|
+
if (typeof value === 'string') return new Date(value).getTime();
|
|
1025
|
+
// Per the type contract above, anything left is Date-like -- duck-typed
|
|
1026
|
+
// (see the comment above) rather than `instanceof Date`, so no runtime
|
|
1027
|
+
// check is needed here either; a value outside this contract is a caller
|
|
1028
|
+
// bug, not something to silently coerce.
|
|
1029
|
+
return /** @type {any} */ (value).getTime();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
994
1032
|
class WorkingCopy {
|
|
995
1033
|
/**
|
|
996
1034
|
* @param {Storage} storage - Storage manager instance
|
|
@@ -1162,8 +1200,10 @@ class WorkingCopy {
|
|
|
1162
1200
|
try {
|
|
1163
1201
|
const stats = await this.fs.promises.stat(fullPath);
|
|
1164
1202
|
|
|
1165
|
-
// Fast path: check mtime and size
|
|
1166
|
-
|
|
1203
|
+
// Fast path: check mtime and size. Compare by VALUE (see mtimeMs
|
|
1204
|
+
// above) — comparing the raw stat()/stored values with !== is
|
|
1205
|
+
// always "different" even when nothing changed (issue #41).
|
|
1206
|
+
if (mtimeMs(stats.mtime) !== mtimeMs(tracked.mtime) || stats.size !== tracked.size) {
|
|
1167
1207
|
modified.push(path);
|
|
1168
1208
|
}
|
|
1169
1209
|
} catch (error) {
|
|
@@ -1274,12 +1314,19 @@ class WorkingCopy {
|
|
|
1274
1314
|
}
|
|
1275
1315
|
|
|
1276
1316
|
const prev = this.state.fileStates[rel];
|
|
1277
|
-
|
|
1317
|
+
// Store mtime as a plain epoch-ms number (not the raw stat() value):
|
|
1318
|
+
// numbers round-trip through JSON exactly, so state reloaded from disk
|
|
1319
|
+
// later compares identically to state still cached in-memory. Goes
|
|
1320
|
+
// through mtimeMs() rather than assuming `.getTime()` exists directly —
|
|
1321
|
+
// some fs implementations (e.g. the in-memory mock used in tests)
|
|
1322
|
+
// return mtime as a plain epoch-ms number already, not a Date (see
|
|
1323
|
+
// mtimeMs above and issue #41).
|
|
1324
|
+
const next = { mtime: mtimeMs(stats.mtime), size: stats.size, mode: stats.mode };
|
|
1278
1325
|
|
|
1279
1326
|
if (!prev) {
|
|
1280
1327
|
this.state.fileStates[rel] = next;
|
|
1281
1328
|
added.push(rel);
|
|
1282
|
-
} else if (
|
|
1329
|
+
} else if (mtimeMs(stats.mtime) !== mtimeMs(prev.mtime) || prev.size !== stats.size) {
|
|
1283
1330
|
this.state.fileStates[rel] = next;
|
|
1284
1331
|
modified.push(rel);
|
|
1285
1332
|
}
|
|
@@ -2668,9 +2715,12 @@ class RevsetEngine {
|
|
|
2668
2715
|
const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
|
|
2669
2716
|
|
|
2670
2717
|
switch (name) {
|
|
2671
|
-
case 'all':
|
|
2718
|
+
case 'all': {
|
|
2672
2719
|
await this.graph.load();
|
|
2673
|
-
|
|
2720
|
+
const allChanges = this.graph.getAll();
|
|
2721
|
+
const hidden = await this._computeHiddenOrphans(allChanges);
|
|
2722
|
+
return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
|
|
2723
|
+
}
|
|
2674
2724
|
|
|
2675
2725
|
// `builtin_log()` (jj v0.44) is the revset the built-in `jj log` uses
|
|
2676
2726
|
// when no user override is configured. isomorphic-jj's `log()` already
|
|
@@ -2678,9 +2728,12 @@ class RevsetEngine {
|
|
|
2678
2728
|
// `builtin_log()` is a pure alias for that same default — this lets
|
|
2679
2729
|
// callers compose it (e.g. `builtin_log() & mine()`) instead of
|
|
2680
2730
|
// duplicating `all()`.
|
|
2681
|
-
case 'builtin_log':
|
|
2731
|
+
case 'builtin_log': {
|
|
2682
2732
|
await this.graph.load();
|
|
2683
|
-
|
|
2733
|
+
const allChanges = this.graph.getAll();
|
|
2734
|
+
const hidden = await this._computeHiddenOrphans(allChanges);
|
|
2735
|
+
return allChanges.filter((c) => !hidden.has(c.changeId)).map((c) => c.changeId);
|
|
2736
|
+
}
|
|
2684
2737
|
|
|
2685
2738
|
case 'none':
|
|
2686
2739
|
return [];
|
|
@@ -3141,6 +3194,136 @@ class RevsetEngine {
|
|
|
3141
3194
|
return ancestors;
|
|
3142
3195
|
}
|
|
3143
3196
|
|
|
3197
|
+
/**
|
|
3198
|
+
* Change IDs that `all()`/`builtin_log()` (and therefore the default
|
|
3199
|
+
* `jj.log()`) should exclude — issue #37(b).
|
|
3200
|
+
*
|
|
3201
|
+
* undo()/redo()/operations.restore() deliberately don't delete a change
|
|
3202
|
+
* record for a `new()`/`squash()` that gets reverted — they only un-ref
|
|
3203
|
+
* it (restore the working-copy pointer/heads that made it reachable),
|
|
3204
|
+
* matching real jj where an unreachable change stays resolvable by its
|
|
3205
|
+
* change id (hidden, not gone) until explicitly gc'd — see
|
|
3206
|
+
* computeGraphReversal's doc comment in repository.js. But that means
|
|
3207
|
+
* the orphaned change record lingers in the graph, and before this fix
|
|
3208
|
+
* `all()` (a flat `graph.getAll()`) surfaced it in `log()` right
|
|
3209
|
+
* alongside everything still actually reachable.
|
|
3210
|
+
*
|
|
3211
|
+
* Real jj hides a commit from the default log when it is BOTH:
|
|
3212
|
+
* - unreachable from any visible head (not itself a head, and not an
|
|
3213
|
+
* ancestor of one), AND
|
|
3214
|
+
* - "discardable": empty (no file diff versus its own parent) and
|
|
3215
|
+
* undescribed.
|
|
3216
|
+
* Merge commits (2+ parents) are conservatively never treated as
|
|
3217
|
+
* "empty" here — real jj's diff-emptiness rule for merges is more
|
|
3218
|
+
* involved and isn't needed by anything this package creates today.
|
|
3219
|
+
*
|
|
3220
|
+
* "Visible head" here deliberately does NOT mean "any change with no
|
|
3221
|
+
* children" (that's what the `visible_heads()` revset function computes,
|
|
3222
|
+
* structurally, from the raw graph) — an orphan `new()`/`squash()`
|
|
3223
|
+
* change that undo() un-referenced has no children EITHER, so it would
|
|
3224
|
+
* trivially count as its own head under that definition and never be
|
|
3225
|
+
* excludable. Real jj instead tracks an explicit, maintained set of
|
|
3226
|
+
* heads in its View (updated as operations run — abandoning/squashing a
|
|
3227
|
+
* change removes it, rebasing children onto a new parent removes the
|
|
3228
|
+
* old parent, etc.). This package doesn't thread the operation log's
|
|
3229
|
+
* `view.heads` through to the revset engine, but the working-copy
|
|
3230
|
+
* pointer plus every bookmark/tag target is the closest available
|
|
3231
|
+
* proxy for "what's actually still in play" — anything only reachable
|
|
3232
|
+
* from those, not from every dangling graph leaf.
|
|
3233
|
+
*
|
|
3234
|
+
* @param {any[]} allChanges - `graph.getAll()`
|
|
3235
|
+
* @returns {Promise<Set<string>>} change IDs to exclude
|
|
3236
|
+
*/
|
|
3237
|
+
async _computeHiddenOrphans(allChanges) {
|
|
3238
|
+
const trackedHeads = new Set();
|
|
3239
|
+
try {
|
|
3240
|
+
const wcId = this.workingCopy && this.workingCopy.getCurrentChangeId();
|
|
3241
|
+
if (wcId) trackedHeads.add(wcId);
|
|
3242
|
+
} catch {
|
|
3243
|
+
// working copy not loaded/available — fine, just no wc-based root.
|
|
3244
|
+
}
|
|
3245
|
+
if (this.bookmarkStore) {
|
|
3246
|
+
await this.bookmarkStore.load();
|
|
3247
|
+
for (const bookmark of await this.bookmarkStore.list()) {
|
|
3248
|
+
if (bookmark && bookmark.changeId) trackedHeads.add(bookmark.changeId);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
if (this.tagStore) {
|
|
3252
|
+
for (const tag of await this.tagStore.list()) {
|
|
3253
|
+
if (tag && tag.changeId) trackedHeads.add(tag.changeId);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
const reachable = new Set();
|
|
3258
|
+
for (const headId of trackedHeads) {
|
|
3259
|
+
for (const id of await this.getAncestors(headId)) {
|
|
3260
|
+
reachable.add(id);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// Sentinel "no message given" descriptions this package itself writes
|
|
3265
|
+
// (new()'s '(no description)', squash()'s post-squash working-copy
|
|
3266
|
+
// change '(no description set)') are, for visibility purposes, just
|
|
3267
|
+
// as undescribed as a genuinely empty/missing description field.
|
|
3268
|
+
const UNDESCRIBED = new Set([undefined, null, '', '(no description)', '(no description set)']);
|
|
3269
|
+
|
|
3270
|
+
const byId = new Map(allChanges.map((c) => [c.changeId, c]));
|
|
3271
|
+
const isEmpty = (/** @type {any} */ change) => {
|
|
3272
|
+
const parents = change.parents || [];
|
|
3273
|
+
if (parents.length === 0) {
|
|
3274
|
+
return !change.fileSnapshot || Object.keys(change.fileSnapshot).length === 0;
|
|
3275
|
+
}
|
|
3276
|
+
if (parents.length > 1) return false; // merges: not handled by this rule
|
|
3277
|
+
const parent = byId.get(parents[0]);
|
|
3278
|
+
const parentSnapshot = (parent && parent.fileSnapshot) || {};
|
|
3279
|
+
const ownSnapshot = change.fileSnapshot || {};
|
|
3280
|
+
const parentKeys = Object.keys(parentSnapshot);
|
|
3281
|
+
const ownKeys = Object.keys(ownSnapshot);
|
|
3282
|
+
if (parentKeys.length !== ownKeys.length) return false;
|
|
3283
|
+
return parentKeys.every((k) => parentSnapshot[k] === ownSnapshot[k]);
|
|
3284
|
+
};
|
|
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.
|
|
3314
|
+
const hidden = new Set();
|
|
3315
|
+
for (const change of allChanges) {
|
|
3316
|
+
if (
|
|
3317
|
+
!reachable.has(change.changeId) &&
|
|
3318
|
+
(isEmpty(change) || change.autoCreated) &&
|
|
3319
|
+
UNDESCRIBED.has(change.description)
|
|
3320
|
+
) {
|
|
3321
|
+
hidden.add(change.changeId);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
return hidden;
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3144
3327
|
/**
|
|
3145
3328
|
* Resolve a revset argument to a set of change IDs (v1.5).
|
|
3146
3329
|
*
|
|
@@ -7899,7 +8082,16 @@ async function createJJ(options) {
|
|
|
7899
8082
|
await graph.load();
|
|
7900
8083
|
for (const [changeId, snapshot] of Object.entries(reversal.changeSnapshotToApply)) {
|
|
7901
8084
|
if (await graph.getChange(changeId)) {
|
|
7902
|
-
|
|
8085
|
+
// Bypass the git-sync middleware (baseGraph, not the wrapped
|
|
8086
|
+
// `graph`): undo/restore are pure reverts, and `snapshot` already
|
|
8087
|
+
// carries the change's ORIGINAL commitId from before the reverted
|
|
8088
|
+
// operation(s) ran. Going through graph.updateChange() would fire
|
|
8089
|
+
// onUpdateChange -> syncChangeToGit(), which mints a brand-new
|
|
8090
|
+
// commit (and thus a new commitId) from the CURRENT tree instead
|
|
8091
|
+
// of restoring the one that was actually there — see issue #37(c):
|
|
8092
|
+
// "undo/restore give reverted commits new commit IDs". Writing the
|
|
8093
|
+
// snapshot verbatim via baseGraph puts the original commitId back.
|
|
8094
|
+
await baseGraph.updateChange(snapshot);
|
|
7903
8095
|
}
|
|
7904
8096
|
}
|
|
7905
8097
|
|
|
@@ -7923,6 +8115,38 @@ async function createJJ(options) {
|
|
|
7923
8115
|
// an earlier, unrelated merge(). Fixed as part of issue #29.)
|
|
7924
8116
|
};
|
|
7925
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
|
+
|
|
7926
8150
|
/**
|
|
7927
8151
|
* Re-parent every child of `changeId` onto `newParents` instead — used
|
|
7928
8152
|
* when a change is being elided from the graph (abandon(), issue #30;
|
|
@@ -7954,6 +8178,18 @@ async function createJJ(options) {
|
|
|
7954
8178
|
}
|
|
7955
8179
|
};
|
|
7956
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
|
+
|
|
7957
8193
|
/**
|
|
7958
8194
|
* Determine which operation a fresh undo() call should revert, matching
|
|
7959
8195
|
* real jj's progressive-undo semantics (`jj-undo(1)`: "If used once after
|
|
@@ -8389,6 +8625,17 @@ async function createJJ(options) {
|
|
|
8389
8625
|
});
|
|
8390
8626
|
}
|
|
8391
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
|
+
|
|
8392
8639
|
const fullPath = `${dir}/${args.path}`;
|
|
8393
8640
|
|
|
8394
8641
|
// Ensure directory exists
|
|
@@ -8404,7 +8651,6 @@ async function createJJ(options) {
|
|
|
8404
8651
|
await fs.promises.writeFile(fullPath, data, 'utf8');
|
|
8405
8652
|
|
|
8406
8653
|
// Track the file in working copy
|
|
8407
|
-
await workingCopy.load();
|
|
8408
8654
|
const stats = await fs.promises.stat(fullPath);
|
|
8409
8655
|
await workingCopy.trackFile(args.path, {
|
|
8410
8656
|
mtime: stats.mtime,
|
|
@@ -8412,6 +8658,20 @@ async function createJJ(options) {
|
|
|
8412
8658
|
mode: stats.mode,
|
|
8413
8659
|
});
|
|
8414
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
|
+
|
|
8415
8675
|
// Return useful information about the written file
|
|
8416
8676
|
return {
|
|
8417
8677
|
path: args.path,
|
|
@@ -8840,6 +9100,15 @@ async function createJJ(options) {
|
|
|
8840
9100
|
async moveChange(args) {
|
|
8841
9101
|
await graph.load();
|
|
8842
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
|
+
|
|
8843
9112
|
// Support multiple parameter names for flexibility
|
|
8844
9113
|
const changeId = args.changeId || args.from || args.source;
|
|
8845
9114
|
const newParent = args.newParent || args.to || args.destination;
|
|
@@ -8952,6 +9221,32 @@ async function createJJ(options) {
|
|
|
8952
9221
|
await conflicts.addConflict(conflict);
|
|
8953
9222
|
}
|
|
8954
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
|
+
|
|
8955
9250
|
// Update parent
|
|
8956
9251
|
change.parents = [newParent];
|
|
8957
9252
|
await graph.updateChange(change);
|
|
@@ -8967,8 +9262,9 @@ async function createJJ(options) {
|
|
|
8967
9262
|
view: {
|
|
8968
9263
|
bookmarks: {},
|
|
8969
9264
|
remoteBookmarks: {},
|
|
8970
|
-
heads:
|
|
9265
|
+
heads: await computeCurrentHeads(),
|
|
8971
9266
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
9267
|
+
fileSnapshot: fileSnapshotBefore,
|
|
8972
9268
|
},
|
|
8973
9269
|
});
|
|
8974
9270
|
|
|
@@ -9423,6 +9719,16 @@ async function createJJ(options) {
|
|
|
9423
9719
|
description: args.message || '(no description)',
|
|
9424
9720
|
timestamp: new Date().toISOString(),
|
|
9425
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,
|
|
9426
9732
|
};
|
|
9427
9733
|
|
|
9428
9734
|
// Dispatch change:creating event (preventable)
|
|
@@ -9543,17 +9849,51 @@ async function createJJ(options) {
|
|
|
9543
9849
|
await graph.load();
|
|
9544
9850
|
await workingCopy.load();
|
|
9545
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
|
+
|
|
9546
9861
|
// Refresh the working-copy change's content snapshot from disk.
|
|
9862
|
+
/** @type {Record<string, any>} */
|
|
9863
|
+
const changeSnapshot = {};
|
|
9547
9864
|
try {
|
|
9548
|
-
const wcId = workingCopy.getCurrentChangeId();
|
|
9549
9865
|
const change = await graph.getChange(wcId);
|
|
9550
9866
|
if (change) {
|
|
9551
|
-
|
|
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;
|
|
9552
9876
|
await graph.updateChange(change);
|
|
9553
9877
|
}
|
|
9554
9878
|
} catch {
|
|
9555
9879
|
// Best-effort content refresh.
|
|
9556
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
|
+
|
|
9557
9897
|
return result;
|
|
9558
9898
|
},
|
|
9559
9899
|
|
|
@@ -10017,8 +10357,20 @@ async function createJJ(options) {
|
|
|
10017
10357
|
const current = await graph.getChange(changeId);
|
|
10018
10358
|
if (current) redoChangeSnapshot[changeId] = current;
|
|
10019
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();
|
|
10020
10372
|
const redoFileSnapshot =
|
|
10021
|
-
reversal.fileSnapshot !== undefined ?
|
|
10373
|
+
reversal.fileSnapshot !== undefined ? diskSnapshotBeforeThisOp : undefined;
|
|
10022
10374
|
let redoConflictsSnapshot;
|
|
10023
10375
|
if (reversal.conflictsSnapshot !== undefined) {
|
|
10024
10376
|
await conflicts.load();
|
|
@@ -10037,6 +10389,16 @@ async function createJJ(options) {
|
|
|
10037
10389
|
// which operation it targeted so a following undo() can walk one
|
|
10038
10390
|
// step further back (resolveUndoTarget) instead of toggling, and
|
|
10039
10391
|
// carrying exactly what redo() needs to re-apply (captured above).
|
|
10392
|
+
//
|
|
10393
|
+
// Also record `changeSnapshot: redoChangeSnapshot` — the SAME
|
|
10394
|
+
// ground-truth-before-this-op values, but under the field name
|
|
10395
|
+
// computeGraphReversal() actually scans for. Without this, undo()
|
|
10396
|
+
// itself was the one mutating operation in the whole API that never
|
|
10397
|
+
// recorded its own changeSnapshot, so a LATER undo() targeting THIS
|
|
10398
|
+
// undo (to step back past it) had nothing to restore: it would
|
|
10399
|
+
// repoint the working copy and files, but leave whatever this
|
|
10400
|
+
// undo() overwrote (e.g. a change's description) exactly as this
|
|
10401
|
+
// undo() left it. See issue #37(a).
|
|
10040
10402
|
await oplog.recordOperation({
|
|
10041
10403
|
timestamp: new Date().toISOString(),
|
|
10042
10404
|
user: await getUserOplogInfo(),
|
|
@@ -10044,11 +10406,12 @@ async function createJJ(options) {
|
|
|
10044
10406
|
eventType: 'undo',
|
|
10045
10407
|
targetOpId: targetOp.id,
|
|
10046
10408
|
undoneOpId: targetOp.id, // kept for API/type compatibility
|
|
10409
|
+
changeSnapshot: redoChangeSnapshot,
|
|
10047
10410
|
redoChangeSnapshot,
|
|
10048
10411
|
redoFileSnapshot,
|
|
10049
10412
|
redoConflictsSnapshot,
|
|
10050
10413
|
parents: [],
|
|
10051
|
-
view: landingView,
|
|
10414
|
+
view: { ...landingView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10052
10415
|
});
|
|
10053
10416
|
|
|
10054
10417
|
const restoredFileCount =
|
|
@@ -10121,11 +10484,36 @@ async function createJJ(options) {
|
|
|
10121
10484
|
// by scanning forward. undo() captured it directly for exactly this
|
|
10122
10485
|
// reason (see its own comment).
|
|
10123
10486
|
await graph.load();
|
|
10487
|
+
|
|
10488
|
+
// Capture ground truth right before overwriting, same as undo()
|
|
10489
|
+
// does for its own changeSnapshot (issue #37a) — lets a later
|
|
10490
|
+
// undo() targeting THIS redo op restore what redo() is about to
|
|
10491
|
+
// clobber, instead of finding no changeSnapshot at all.
|
|
10492
|
+
/** @type {Record<string, any>} */
|
|
10493
|
+
const undoChangeSnapshot = {};
|
|
10494
|
+
for (const changeId of Object.keys(undoOp.redoChangeSnapshot || {})) {
|
|
10495
|
+
const current = await graph.getChange(changeId);
|
|
10496
|
+
if (current) undoChangeSnapshot[changeId] = current;
|
|
10497
|
+
}
|
|
10498
|
+
|
|
10124
10499
|
for (const [changeId, snapshot] of Object.entries(undoOp.redoChangeSnapshot || {})) {
|
|
10125
10500
|
if (await graph.getChange(changeId)) {
|
|
10126
|
-
|
|
10127
|
-
|
|
10128
|
-
|
|
10501
|
+
// Bypass the git-sync middleware — see the matching comment in
|
|
10502
|
+
// applyGraphReversal(). `snapshot` already carries the change's
|
|
10503
|
+
// correct commitId from before the undo it's reversing ran
|
|
10504
|
+
// (issue #37c applies to redo() too).
|
|
10505
|
+
await baseGraph.updateChange(snapshot);
|
|
10506
|
+
}
|
|
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();
|
|
10129
10517
|
if (undoOp.redoFileSnapshot !== undefined) {
|
|
10130
10518
|
await syncWorkingCopyFiles(undoOp.redoFileSnapshot);
|
|
10131
10519
|
}
|
|
@@ -10144,8 +10532,9 @@ async function createJJ(options) {
|
|
|
10144
10532
|
description: 'redo operation',
|
|
10145
10533
|
eventType: 'redo',
|
|
10146
10534
|
redoOf: undoOp.id,
|
|
10535
|
+
changeSnapshot: undoChangeSnapshot,
|
|
10147
10536
|
parents: [],
|
|
10148
|
-
view: redoneView,
|
|
10537
|
+
view: { ...redoneView, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10149
10538
|
});
|
|
10150
10539
|
|
|
10151
10540
|
const restoredFileCount =
|
|
@@ -10434,6 +10823,34 @@ async function createJJ(options) {
|
|
|
10434
10823
|
// Revert the change graph, working-copy files, and conflict state
|
|
10435
10824
|
// to exactly what they were right after targetOp ran.
|
|
10436
10825
|
const reversal = computeGraphReversal(ops, targetOp);
|
|
10826
|
+
|
|
10827
|
+
// Capture ground truth for every changeId this restore is about to
|
|
10828
|
+
// overwrite, BEFORE applying the reversal — exactly like undo()
|
|
10829
|
+
// does for its own changeSnapshot. Without this, restore()'s own
|
|
10830
|
+
// oplog entry carried no changeSnapshot at all, so undoing the
|
|
10831
|
+
// restore itself had nothing to put back: a later undo() would
|
|
10832
|
+
// repoint the working copy and files, but leave whatever the
|
|
10833
|
+
// restore had just overwritten (e.g. a change's description)
|
|
10834
|
+
// exactly as the restore left it. See issue #37(a).
|
|
10835
|
+
await graph.load();
|
|
10836
|
+
/** @type {Record<string, any>} */
|
|
10837
|
+
const changeSnapshot = {};
|
|
10838
|
+
for (const changeId of Object.keys(reversal.changeSnapshotToApply)) {
|
|
10839
|
+
const current = await graph.getChange(changeId);
|
|
10840
|
+
if (current) changeSnapshot[changeId] = current;
|
|
10841
|
+
}
|
|
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
|
+
|
|
10437
10854
|
await applyGraphReversal(reversal);
|
|
10438
10855
|
|
|
10439
10856
|
// Record this restoration as a new operation
|
|
@@ -10442,7 +10859,8 @@ async function createJJ(options) {
|
|
|
10442
10859
|
user: await getUserOplogInfo(),
|
|
10443
10860
|
description: `restore to operation ${args.operation}`,
|
|
10444
10861
|
parents: [],
|
|
10445
|
-
|
|
10862
|
+
changeSnapshot,
|
|
10863
|
+
view: { ...targetOp.view, fileSnapshot: diskSnapshotBeforeThisOp },
|
|
10446
10864
|
});
|
|
10447
10865
|
|
|
10448
10866
|
return {
|
|
@@ -10678,6 +11096,15 @@ async function createJJ(options) {
|
|
|
10678
11096
|
await workingCopy.load();
|
|
10679
11097
|
await userConfig.load();
|
|
10680
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
|
+
|
|
10681
11108
|
// Support 'into' as alias for 'dest' (matches JJ CLI)
|
|
10682
11109
|
const dest = args.into || args.dest;
|
|
10683
11110
|
|
|
@@ -10801,10 +11228,34 @@ async function createJJ(options) {
|
|
|
10801
11228
|
},
|
|
10802
11229
|
description: '(no description set)',
|
|
10803
11230
|
timestamp: new Date().toISOString(),
|
|
10804
|
-
|
|
11231
|
+
// Start from the DESTINATION's tree (which now includes source's
|
|
11232
|
+
// folded-in changes, set above), not an empty object — matches
|
|
11233
|
+
// real `jj squash`, where the new working-copy change looks
|
|
11234
|
+
// identical to its new parent until you touch something. Issue
|
|
11235
|
+
// #39: this used to hard-code `{}`, so status()/snapshot() saw
|
|
11236
|
+
// every on-disk file as untracked/new until something re-wrote
|
|
11237
|
+
// it and triggered a fresh auto-snapshot.
|
|
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,
|
|
10805
11251
|
};
|
|
10806
11252
|
|
|
10807
11253
|
await graph.addChange(newChange); // Middleware will sync to Git
|
|
11254
|
+
// Sync the working directory to match (issue #30's fix for
|
|
11255
|
+
// new()/edit(); squash()'s own synthetic post-squash change never
|
|
11256
|
+
// did this) — a no-op when source's parent was already dest (the
|
|
11257
|
+
// common case), but necessary whenever source/dest aren't adjacent.
|
|
11258
|
+
await syncWorkingCopyFiles(newChange.fileSnapshot);
|
|
10808
11259
|
await workingCopy.setCurrentChange(newChangeId);
|
|
10809
11260
|
newWorkingCopyId = newChangeId;
|
|
10810
11261
|
}
|
|
@@ -10819,8 +11270,18 @@ async function createJJ(options) {
|
|
|
10819
11270
|
view: {
|
|
10820
11271
|
bookmarks: {},
|
|
10821
11272
|
remoteBookmarks: {},
|
|
10822
|
-
|
|
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(),
|
|
10823
11283
|
workingCopy: newWorkingCopyId,
|
|
11284
|
+
fileSnapshot: fileSnapshotBefore,
|
|
10824
11285
|
},
|
|
10825
11286
|
});
|
|
10826
11287
|
|
|
@@ -10886,6 +11347,18 @@ async function createJJ(options) {
|
|
|
10886
11347
|
throw new JJError('CHANGE_NOT_FOUND', `Change ${changeId} not found`);
|
|
10887
11348
|
}
|
|
10888
11349
|
|
|
11350
|
+
// Captured BEFORE anything mutates — see issue #38: abandoning the
|
|
11351
|
+
// change @ currently points at must move @ off it (real jj checks
|
|
11352
|
+
// out a fresh empty change on the abandoned change's own parent(s));
|
|
11353
|
+
// abandoning any OTHER change must leave @ exactly where it was.
|
|
11354
|
+
const wasWorkingCopy = changeId === workingCopy.getCurrentChangeId();
|
|
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
|
+
|
|
10889
11362
|
// Dispatch change:abandoning event (preventable)
|
|
10890
11363
|
await dispatchEventAsync(jj, 'change:abandoning', {
|
|
10891
11364
|
operation: 'abandon',
|
|
@@ -10920,8 +11393,16 @@ async function createJJ(options) {
|
|
|
10920
11393
|
view: {
|
|
10921
11394
|
bookmarks: {},
|
|
10922
11395
|
remoteBookmarks: {},
|
|
10923
|
-
|
|
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(),
|
|
10924
11404
|
workingCopy: workingCopy.getCurrentChangeId(),
|
|
11405
|
+
fileSnapshot: fileSnapshotBefore,
|
|
10925
11406
|
},
|
|
10926
11407
|
});
|
|
10927
11408
|
|
|
@@ -10938,6 +11419,17 @@ async function createJJ(options) {
|
|
|
10938
11419
|
{ cancelable: false }
|
|
10939
11420
|
);
|
|
10940
11421
|
|
|
11422
|
+
// If @ was pointing at the change we just abandoned, it can't stay
|
|
11423
|
+
// there — real jj moves @ to a new empty change on the abandoned
|
|
11424
|
+
// change's own parent(s) (issue #38). Reuse new()'s own "create an
|
|
11425
|
+
// empty change with given parents" logic rather than duplicating it
|
|
11426
|
+
// — this is exactly the workaround callers had to reach for
|
|
11427
|
+
// themselves (`new({ parents: [parentOfAt] })` before abandoning).
|
|
11428
|
+
if (wasWorkingCopy) {
|
|
11429
|
+
const newWorkingCopyChange = await jj.new({ parents: change.parents || [] });
|
|
11430
|
+
return { ...change, newWorkingCopyChangeId: newWorkingCopyChange.changeId };
|
|
11431
|
+
}
|
|
11432
|
+
|
|
10941
11433
|
return change;
|
|
10942
11434
|
},
|
|
10943
11435
|
|
|
@@ -13172,6 +13664,32 @@ async function createJJ(options) {
|
|
|
13172
13664
|
throw new JJError('CONFLICT_NOT_FOUND', `Conflict ${args.conflictId} not found`);
|
|
13173
13665
|
}
|
|
13174
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
|
+
|
|
13175
13693
|
let resolvedContent;
|
|
13176
13694
|
|
|
13177
13695
|
// v0.5: Support different resolution methods
|
|
@@ -13215,6 +13733,36 @@ async function createJJ(options) {
|
|
|
13215
13733
|
await conflicts.resolveConflict(args.conflictId, 'manual');
|
|
13216
13734
|
await conflicts.save();
|
|
13217
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
|
+
|
|
13218
13766
|
return { resolved: true };
|
|
13219
13767
|
},
|
|
13220
13768
|
|