isomorphic-jj 1.7.0 → 1.9.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 +425 -1
- package/LICENSE +1 -1
- package/README.md +233 -35
- package/bin/isojj.js +4 -1
- package/dist/index.cjs +2419 -378
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +2419 -377
- package/dist/index.mjs.map +1 -1
- package/package.json +24 -10
- package/src/api/repository.js +966 -153
- package/src/backend/isomorphic-git-backend.js +72 -15
- package/src/browser/helpers.js +40 -18
- package/src/browser/index.d.ts +9 -2
- package/src/core/change-graph.js +125 -4
- package/src/core/conflict-model.js +72 -7
- package/src/core/jj-checkout.js +5 -16
- package/src/core/jj-operation-store.js +11 -19
- package/src/core/jj-tree-state.js +8 -18
- package/src/core/jj-view-store.js +9 -19
- package/src/core/merge-driver-registry.js +11 -10
- package/src/core/revset-engine.js +63 -6
- package/src/core/storage-manager.js +56 -18
- package/src/core/tag-store.js +42 -15
- package/src/core/workspace-manager.js +4 -3
- package/src/protos/local_working_copy.json.js +146 -0
- package/src/protos/schema.js +49 -0
- package/src/protos/simple_op_store.json.js +374 -0
- package/src/types.d.ts +33 -0
- package/src/utils/atomic-write.js +46 -0
- package/src/utils/bytes.js +67 -0
- package/src/utils/id-generation.js +18 -4
- package/src/utils/mkdirp.js +41 -0
- package/src/utils/mutex.js +63 -0
- package/src/utils/posix-path.js +117 -0
|
@@ -11,7 +11,8 @@ import { JJCheckout } from '../core/jj-checkout.js';
|
|
|
11
11
|
import { JJTreeState } from '../core/jj-tree-state.js';
|
|
12
12
|
import { JJOperationStore } from '../core/jj-operation-store.js';
|
|
13
13
|
import { JJViewStore } from '../core/jj-view-store.js';
|
|
14
|
-
import
|
|
14
|
+
import { randomHex } from '../utils/id-generation.js';
|
|
15
|
+
import { mkdirp } from '../utils/mkdirp.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Backend adapter for isomorphic-git
|
|
@@ -540,7 +541,11 @@ export class IsomorphicGitBackend {
|
|
|
540
541
|
* @param {boolean} [opts.force] - Allow non-fast-forward
|
|
541
542
|
* @param {any} [opts.onProgress] - Progress callback
|
|
542
543
|
* @param {any} [opts.onAuth] - Authentication callback
|
|
543
|
-
* @returns {Promise<{pushedRefs: Array<
|
|
544
|
+
* @returns {Promise<{pushedRefs: Array<{name: string, oid: string}>, rejectedRefs: Array<{name: string, code: string, reason: string, message: string}>}>} Push result.
|
|
545
|
+
* Each rejected ref carries a `code` (one of `AUTH_FAILED`,
|
|
546
|
+
* `NETWORK_ERROR`, `NON_FAST_FORWARD`, `OTHER`) and a human-readable
|
|
547
|
+
* `reason`, so callers can tell an auth failure apart from a
|
|
548
|
+
* non-fast-forward rejection instead of guessing (see issue #14).
|
|
544
549
|
*/
|
|
545
550
|
async push(opts) {
|
|
546
551
|
if (!this.http) {
|
|
@@ -576,8 +581,13 @@ export class IsomorphicGitBackend {
|
|
|
576
581
|
pushedRefs.push({ name: ref, oid });
|
|
577
582
|
}
|
|
578
583
|
} catch (pushError) {
|
|
579
|
-
// Track rejected refs
|
|
580
|
-
|
|
584
|
+
// Track rejected refs with the categorized reason so callers can
|
|
585
|
+
// distinguish an auth failure from a non-fast-forward rejection
|
|
586
|
+
// (or a network blip) instead of guessing — see issue #14. A
|
|
587
|
+
// caller that blindly retries with force:true on every rejection
|
|
588
|
+
// risks doing so on what was actually an auth/network problem.
|
|
589
|
+
const { code, reason } = this._categorizePushError(pushError);
|
|
590
|
+
rejectedRefs.push({ name: ref, code, reason, message: pushError.message });
|
|
581
591
|
}
|
|
582
592
|
}
|
|
583
593
|
|
|
@@ -609,6 +619,53 @@ export class IsomorphicGitBackend {
|
|
|
609
619
|
}
|
|
610
620
|
}
|
|
611
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Categorize a per-ref push failure into a stable code/reason pair so
|
|
624
|
+
* callers can distinguish auth failures, network errors, and
|
|
625
|
+
* non-fast-forward rejections from each other and from other/unknown
|
|
626
|
+
* failures — see issue #14. isomorphic-git doesn't throw a single
|
|
627
|
+
* "AuthError" class; auth failures generally surface as an HttpError with
|
|
628
|
+
* a 401/403 status code (or a UserCanceledError if onAuth cancels).
|
|
629
|
+
*
|
|
630
|
+
* @private
|
|
631
|
+
* @param {any} error - Error thrown by git.push() for a single ref
|
|
632
|
+
* @returns {{code: string, reason: string}}
|
|
633
|
+
*/
|
|
634
|
+
_categorizePushError(error) {
|
|
635
|
+
const errCode = error && error.code;
|
|
636
|
+
|
|
637
|
+
if (errCode === 'PushRejectedError') {
|
|
638
|
+
const reason = error.data && error.data.reason;
|
|
639
|
+
return {
|
|
640
|
+
code: 'NON_FAST_FORWARD',
|
|
641
|
+
reason: reason === 'tag-exists' ? 'tag already exists on remote' : 'non-fast-forward',
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (errCode === 'HttpError') {
|
|
646
|
+
const status = error.data && error.data.statusCode;
|
|
647
|
+
if (status === 401 || status === 403) {
|
|
648
|
+
return { code: 'AUTH_FAILED', reason: `HTTP ${status}` };
|
|
649
|
+
}
|
|
650
|
+
return { code: 'NETWORK_ERROR', reason: status ? `HTTP ${status}` : 'HTTP error' };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (
|
|
654
|
+
errCode === 'AuthError' ||
|
|
655
|
+
errCode === 'UserCanceledError' ||
|
|
656
|
+
errCode === 'MissingUsernameError' ||
|
|
657
|
+
errCode === 'MissingPasswordTokenError'
|
|
658
|
+
) {
|
|
659
|
+
return { code: 'AUTH_FAILED', reason: errCode };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (errCode === 'NetworkError' || errCode === 'UnknownTransportError') {
|
|
663
|
+
return { code: 'NETWORK_ERROR', reason: errCode };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return { code: 'OTHER', reason: (error && error.message) || String(error) };
|
|
667
|
+
}
|
|
668
|
+
|
|
612
669
|
/**
|
|
613
670
|
* Create minimal .jj/repo structure for jj CLI compatibility
|
|
614
671
|
* @private
|
|
@@ -618,14 +675,14 @@ export class IsomorphicGitBackend {
|
|
|
618
675
|
const jjRepo = `${jjDir}/repo`;
|
|
619
676
|
|
|
620
677
|
// Create directory structure
|
|
621
|
-
await this.fs
|
|
622
|
-
await this.fs
|
|
623
|
-
await this.fs
|
|
624
|
-
await this.fs
|
|
625
|
-
await this.fs
|
|
626
|
-
await this.fs
|
|
627
|
-
await this.fs
|
|
628
|
-
await this.fs
|
|
678
|
+
await mkdirp(this.fs, `${jjRepo}/store`);
|
|
679
|
+
await mkdirp(this.fs, `${jjRepo}/store/extra/heads`);
|
|
680
|
+
await mkdirp(this.fs, `${jjRepo}/op_store/operations`);
|
|
681
|
+
await mkdirp(this.fs, `${jjRepo}/op_store/views`);
|
|
682
|
+
await mkdirp(this.fs, `${jjRepo}/op_heads/heads`);
|
|
683
|
+
await mkdirp(this.fs, `${jjRepo}/index`);
|
|
684
|
+
await mkdirp(this.fs, `${jjRepo}/submodule_store`);
|
|
685
|
+
await mkdirp(this.fs, `${jjDir}/working_copy`);
|
|
629
686
|
|
|
630
687
|
// Write type files
|
|
631
688
|
await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
|
|
@@ -652,8 +709,8 @@ export class IsomorphicGitBackend {
|
|
|
652
709
|
*/
|
|
653
710
|
async _createInitialJJState() {
|
|
654
711
|
// Generate unique IDs for initial state
|
|
655
|
-
const operationId =
|
|
656
|
-
const viewId =
|
|
712
|
+
const operationId = randomHex(64); // 512-bit = 128 hex chars
|
|
713
|
+
const viewId = randomHex(64);
|
|
657
714
|
const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
|
|
658
715
|
|
|
659
716
|
// Get the initial commit from Git
|
|
@@ -665,7 +722,7 @@ export class IsomorphicGitBackend {
|
|
|
665
722
|
ref: 'HEAD',
|
|
666
723
|
});
|
|
667
724
|
if (headOid) {
|
|
668
|
-
initialCommitId =
|
|
725
|
+
initialCommitId = randomHex(64);
|
|
669
726
|
}
|
|
670
727
|
} catch (error) {
|
|
671
728
|
// No initial commit yet
|
package/src/browser/helpers.js
CHANGED
|
@@ -5,24 +5,30 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Create a browser-compatible filesystem
|
|
8
|
+
* Create a browser-compatible filesystem, defaulting to LightningFS.
|
|
9
9
|
*
|
|
10
|
-
* Requires: @isomorphic-git/lightning-fs
|
|
10
|
+
* Requires (unless `opts.fs` is supplied): @isomorphic-git/lightning-fs
|
|
11
11
|
*
|
|
12
|
-
* @param {Object} opts - Options
|
|
12
|
+
* @param {Object} [opts] - Options
|
|
13
13
|
* @param {string} [opts.backend='idb'] - Storage backend ('idb' or 'memory')
|
|
14
14
|
* @param {string} [opts.name='jj'] - Database name for IndexedDB
|
|
15
15
|
* @param {boolean} [opts.wipe=false] - Wipe existing data
|
|
16
|
-
* @
|
|
16
|
+
* @param {any} [opts.fs] - Bring your own filesystem instead of the default
|
|
17
|
+
* LightningFS auto-import. Pass either an already-constructed
|
|
18
|
+
* isomorphic-git-compatible fs (e.g. a `memfs` volume, or your own
|
|
19
|
+
* LightningFS instance) — returned as-is — or a constructor with the
|
|
20
|
+
* LightningFS signature (`new Ctor(name, { wipe })`), which is
|
|
21
|
+
* instantiated the same way the default LightningFS would be.
|
|
22
|
+
* @returns {Promise<Object>} Filesystem instance compatible with isomorphic-git
|
|
17
23
|
*
|
|
18
24
|
* @example
|
|
19
25
|
* ```javascript
|
|
20
|
-
* import { createBrowserFS } from 'isomorphic-jj/browser';
|
|
21
|
-
* import { createJJ } from 'isomorphic-jj';
|
|
26
|
+
* import { createBrowserFS } from '@johnhenry/isomorphic-jj/browser';
|
|
27
|
+
* import { createJJ } from '@johnhenry/isomorphic-jj';
|
|
22
28
|
* import git from 'isomorphic-git';
|
|
23
29
|
* import http from 'isomorphic-git/http/web';
|
|
24
30
|
*
|
|
25
|
-
* const fs = createBrowserFS({ backend: 'idb', name: 'my-repo' });
|
|
31
|
+
* const fs = await createBrowserFS({ backend: 'idb', name: 'my-repo' });
|
|
26
32
|
* const jj = await createJJ({
|
|
27
33
|
* fs,
|
|
28
34
|
* dir: '/repo',
|
|
@@ -30,24 +36,36 @@
|
|
|
30
36
|
* http
|
|
31
37
|
* });
|
|
32
38
|
* ```
|
|
39
|
+
*
|
|
40
|
+
* @example Bring your own fs (e.g. memfs)
|
|
41
|
+
* ```javascript
|
|
42
|
+
* import { fs as memfs } from 'memfs';
|
|
43
|
+
* const fs = await createBrowserFS({ fs: memfs });
|
|
44
|
+
* ```
|
|
33
45
|
*/
|
|
34
|
-
export function createBrowserFS(opts = {}) {
|
|
46
|
+
export async function createBrowserFS(opts = {}) {
|
|
35
47
|
// Check if we're in a browser environment
|
|
36
48
|
if (typeof window === 'undefined') {
|
|
37
49
|
throw new Error('createBrowserFS() should only be used in browser environments');
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
52
|
+
// Caller supplied their own filesystem — use it directly instead of
|
|
53
|
+
// pulling in LightningFS at all.
|
|
54
|
+
if (opts.fs) {
|
|
55
|
+
if (typeof opts.fs === 'function') {
|
|
56
|
+
return new opts.fs(opts.name || 'jj', { wipe: opts.wipe || false });
|
|
57
|
+
}
|
|
58
|
+
return opts.fs;
|
|
59
|
+
}
|
|
49
60
|
|
|
50
|
-
|
|
61
|
+
// Dynamic `import()` (not `require`) so this resolves in an ESM browser
|
|
62
|
+
// bundle — `require` does not exist there (see issue #28). Users must
|
|
63
|
+
// install @isomorphic-git/lightning-fs separately; it's an optional
|
|
64
|
+
// peer/dev dependency of this package.
|
|
65
|
+
let LightningFS;
|
|
66
|
+
try {
|
|
67
|
+
const mod = await import('@isomorphic-git/lightning-fs');
|
|
68
|
+
LightningFS = mod.default ?? mod;
|
|
51
69
|
} catch (error) {
|
|
52
70
|
throw new Error(
|
|
53
71
|
'LightningFS not found. Install it with: npm install @isomorphic-git/lightning-fs\n' +
|
|
@@ -55,6 +73,10 @@ export function createBrowserFS(opts = {}) {
|
|
|
55
73
|
error.message
|
|
56
74
|
);
|
|
57
75
|
}
|
|
76
|
+
|
|
77
|
+
return new LightningFS(opts.name || 'jj', {
|
|
78
|
+
wipe: opts.wipe || false,
|
|
79
|
+
});
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
/**
|
package/src/browser/index.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface BrowserFSOptions {
|
|
|
14
14
|
name?: string;
|
|
15
15
|
/** Wipe existing data on initialization */
|
|
16
16
|
wipe?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Bring your own filesystem instead of the default LightningFS
|
|
19
|
+
* auto-import. Pass either an already-constructed isomorphic-git
|
|
20
|
+
* compatible fs (returned as-is) or a LightningFS-shaped constructor
|
|
21
|
+
* (`new Ctor(name, { wipe })`).
|
|
22
|
+
*/
|
|
23
|
+
fs?: any;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
@@ -99,11 +106,11 @@ export interface ServiceWorkerUtils {
|
|
|
99
106
|
* import git from 'isomorphic-git';
|
|
100
107
|
* import http from 'isomorphic-git/http/web';
|
|
101
108
|
*
|
|
102
|
-
* const fs = createBrowserFS({ backend: 'idb', name: 'my-repo' });
|
|
109
|
+
* const fs = await createBrowserFS({ backend: 'idb', name: 'my-repo' });
|
|
103
110
|
* const jj = await createJJ({ fs, dir: '/repo', git, http });
|
|
104
111
|
* ```
|
|
105
112
|
*/
|
|
106
|
-
export function createBrowserFS(opts?: BrowserFSOptions): any
|
|
113
|
+
export function createBrowserFS(opts?: BrowserFSOptions): Promise<any>;
|
|
107
114
|
|
|
108
115
|
/**
|
|
109
116
|
* Get storage quota information (browser only)
|
package/src/core/change-graph.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { JJError } from '../utils/errors.js';
|
|
8
8
|
import { validateChangeId } from '../utils/validation.js';
|
|
9
|
+
import { randomHex } from '../utils/id-generation.js';
|
|
9
10
|
|
|
10
11
|
export class ChangeGraph {
|
|
11
12
|
/**
|
|
@@ -95,6 +96,68 @@ export class ChangeGraph {
|
|
|
95
96
|
await this.save();
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Add a DIVERGENT copy of an existing change: another commit that also
|
|
101
|
+
* claims `change.changeId` (see issue #32 / the `divergent()` revset —
|
|
102
|
+
* "multiple visible commits sharing one change id"). `addChange()`
|
|
103
|
+
* can't represent this: `nodes` is a `Map<changeId, change>`, one entry
|
|
104
|
+
* per key, by design — every other API (getChange, getParents,
|
|
105
|
+
* getChildren, ...) keys on plain changeId and would be ambiguous if
|
|
106
|
+
* that stopped being unique.
|
|
107
|
+
*
|
|
108
|
+
* Divergent copies are therefore stored under a synthetic internal key
|
|
109
|
+
* (`${changeId}\0${commitId}`) so the `nodes` Map stays a true map, but
|
|
110
|
+
* each copy's *own* `changeId` field is unchanged — so `getAll()` (and
|
|
111
|
+
* therefore the existing `divergent()` revset filter, which counts
|
|
112
|
+
* occurrences of `c.changeId` across `getAll()`) sees both/all of them
|
|
113
|
+
* and correctly reports the change as divergent. `getChange(changeId)`
|
|
114
|
+
* deliberately keeps resolving to just the primary copy (the one
|
|
115
|
+
* actually stored under the plain `changeId` key) — for anything that
|
|
116
|
+
* needs every copy, see getDivergentSiblings().
|
|
117
|
+
*
|
|
118
|
+
* @param {Record<string, any>} change - The divergent copy (must have
|
|
119
|
+
* the same `changeId` as an existing change, and its own `commitId`)
|
|
120
|
+
*/
|
|
121
|
+
async addDivergentCopy(change) {
|
|
122
|
+
validateChangeId(change.changeId);
|
|
123
|
+
|
|
124
|
+
if (!this.nodes.has(change.changeId)) {
|
|
125
|
+
throw new JJError(
|
|
126
|
+
'CHANGE_NOT_FOUND',
|
|
127
|
+
`Cannot add a divergent copy of ${change.changeId}: no existing change with that id`,
|
|
128
|
+
{ changeId: change.changeId, suggestion: 'Use addChange() to create the first copy' }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const key = `${change.changeId}\0${change.commitId}`;
|
|
133
|
+
if (this.nodes.has(key)) {
|
|
134
|
+
throw new JJError(
|
|
135
|
+
'CHANGE_EXISTS',
|
|
136
|
+
`Change ${change.changeId} already has a divergent copy with commit ${change.commitId}`,
|
|
137
|
+
{ changeId: change.changeId, commitId: change.commitId }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
change.divergent = true;
|
|
142
|
+
this.nodes.set(key, change);
|
|
143
|
+
this.commitIndex.set(change.commitId, change.changeId);
|
|
144
|
+
|
|
145
|
+
await this.save();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get every visible copy of a (possibly divergent) change id — just the
|
|
150
|
+
* one copy in the common case, or the primary plus every divergent copy
|
|
151
|
+
* added via addDivergentCopy().
|
|
152
|
+
*
|
|
153
|
+
* @param {string} changeId - Change ID
|
|
154
|
+
* @returns {Array<any>} All copies sharing this changeId, primary first
|
|
155
|
+
*/
|
|
156
|
+
getDivergentSiblings(changeId) {
|
|
157
|
+
validateChangeId(changeId);
|
|
158
|
+
return this.getAll().filter((change) => change.changeId === changeId);
|
|
159
|
+
}
|
|
160
|
+
|
|
98
161
|
/**
|
|
99
162
|
* Get a change by ID
|
|
100
163
|
*
|
|
@@ -206,6 +269,65 @@ export class ChangeGraph {
|
|
|
206
269
|
await this.save();
|
|
207
270
|
}
|
|
208
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Remove one divergent copy added via addDivergentCopy() — e.g. after
|
|
274
|
+
* converge() (issue #32) resolves a divergence and only needs to drop
|
|
275
|
+
* the now-superseded copy. The *primary* copy (stored under the plain
|
|
276
|
+
* `changeId` key) is never touched by this — use deleteChange() (or
|
|
277
|
+
* updateChange() to overwrite it with a resolved result) for that.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} changeId
|
|
280
|
+
* @param {string} commitId - The divergent copy's own commit id
|
|
281
|
+
* @returns {Promise<boolean>} Whether a copy was actually removed
|
|
282
|
+
*/
|
|
283
|
+
async deleteDivergentCopy(changeId, commitId) {
|
|
284
|
+
validateChangeId(changeId);
|
|
285
|
+
|
|
286
|
+
const key = `${changeId}\0${commitId}`;
|
|
287
|
+
const change = this.nodes.get(key);
|
|
288
|
+
if (!change) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.nodes.delete(key);
|
|
293
|
+
if (this.commitIndex.get(commitId) === changeId) {
|
|
294
|
+
this.commitIndex.delete(commitId);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
await this.save();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Remove a change from the graph outright.
|
|
303
|
+
*
|
|
304
|
+
* Used to reverse an addChange() — e.g. undoing an operation that created
|
|
305
|
+
* a change (new(), or squash()'s synthetic empty working-copy change) —
|
|
306
|
+
* where restoring a "before" snapshot doesn't apply because there was no
|
|
307
|
+
* "before": the change simply didn't exist yet. A soft-delete (setting
|
|
308
|
+
* `abandoned: true` via updateChange()) is NOT the same thing and is used
|
|
309
|
+
* separately for abandon() itself.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} changeId - Change ID to remove
|
|
312
|
+
* @returns {Promise<boolean>} Whether a change was actually removed
|
|
313
|
+
*/
|
|
314
|
+
async deleteChange(changeId) {
|
|
315
|
+
validateChangeId(changeId);
|
|
316
|
+
|
|
317
|
+
const change = this.nodes.get(changeId);
|
|
318
|
+
if (!change) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.nodes.delete(changeId);
|
|
323
|
+
if (this.commitIndex.get(change.commitId) === changeId) {
|
|
324
|
+
this.commitIndex.delete(change.commitId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await this.save();
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
209
331
|
/**
|
|
210
332
|
* Get all ancestors of a change (recursive parent traversal)
|
|
211
333
|
*
|
|
@@ -243,10 +365,9 @@ export class ChangeGraph {
|
|
|
243
365
|
* @returns {Promise<Record<string, any>>} Created change object
|
|
244
366
|
*/
|
|
245
367
|
async createChange(params = {}) {
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
const tree = params.tree || crypto.randomBytes(20).toString('hex');
|
|
368
|
+
const changeId = params.changeId || randomHex(16);
|
|
369
|
+
const commitId = params.commitId || randomHex(20);
|
|
370
|
+
const tree = params.tree || randomHex(20);
|
|
250
371
|
const timestamp = params.timestamp || new Date().toISOString();
|
|
251
372
|
|
|
252
373
|
/** @type {Record<string, any>} */
|
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* This matches JJ's philosophy: conflicts never block you.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import path from 'path';
|
|
13
|
+
import * as path from '../utils/posix-path.js';
|
|
14
14
|
import { JJError } from '../utils/errors.js';
|
|
15
|
+
import { isBytes } from '../utils/bytes.js';
|
|
15
16
|
import { generateId } from '../utils/id-generation.js';
|
|
17
|
+
import { mkdirp } from '../utils/mkdirp.js';
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Conflict types supported by JJ
|
|
@@ -174,6 +176,15 @@ export class ConflictModel {
|
|
|
174
176
|
const conflict = this._detectPathConflict(path, baseContent, leftContent, rightContent);
|
|
175
177
|
if (conflict) {
|
|
176
178
|
conflicts.push(conflict);
|
|
179
|
+
} else if (workingCopyDir) {
|
|
180
|
+
// No conflict, but "no conflict" doesn't mean "nothing to do": of
|
|
181
|
+
// _detectPathConflict's null-returning cases, every one *except*
|
|
182
|
+
// "right side changed, left didn't" already matches what's on disk,
|
|
183
|
+
// because `leftContent` **is** what's on disk (read straight from
|
|
184
|
+
// the working copy above) -- so only that one case needs a write.
|
|
185
|
+
if (baseContent === leftContent && baseContent !== rightContent) {
|
|
186
|
+
await this._applyCleanResolution(workingCopyDir, path, rightContent);
|
|
187
|
+
}
|
|
177
188
|
}
|
|
178
189
|
}
|
|
179
190
|
|
|
@@ -261,6 +272,30 @@ export class ConflictModel {
|
|
|
261
272
|
return null;
|
|
262
273
|
}
|
|
263
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Create a content conflict with more than two disagreeing versions —
|
|
277
|
+
* every other conflict in this module has exactly two sides (`left`/
|
|
278
|
+
* `right`, or `left`/`right` on an add-add), because every OTHER caller
|
|
279
|
+
* (merge, rebase) only ever three-way-merges two changes at a time.
|
|
280
|
+
* converge() (issue #32) can face more than two divergent copies of one
|
|
281
|
+
* change in a single call, so `sides` here is `{ base, versions }`
|
|
282
|
+
* instead: `versions` is every distinct value found across all copies
|
|
283
|
+
* that differs from `base`, each paired with (one of) the commitId(s)
|
|
284
|
+
* that has it.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} path
|
|
287
|
+
* @param {any} base
|
|
288
|
+
* @param {Array<{commitId: string, content: any}>} versions
|
|
289
|
+
*/
|
|
290
|
+
createNWayConflict(path, base, versions) {
|
|
291
|
+
return this._createConflict({
|
|
292
|
+
type: ConflictType.CONTENT,
|
|
293
|
+
path,
|
|
294
|
+
sides: { base, versions },
|
|
295
|
+
message: `${versions.length} divergent copies disagree on this file's content`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
264
299
|
/**
|
|
265
300
|
* Create a conflict object
|
|
266
301
|
*
|
|
@@ -504,6 +539,36 @@ export class ConflictModel {
|
|
|
504
539
|
return result;
|
|
505
540
|
}
|
|
506
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Apply a clean (non-conflicting) three-way resolution to the working
|
|
544
|
+
* copy, for the default (no merge driver) path in detectConflicts(): the
|
|
545
|
+
* "right side wins" case, where the resolved content differs from what's
|
|
546
|
+
* currently on disk (see the call site for why this is the only clean
|
|
547
|
+
* case that needs one).
|
|
548
|
+
*
|
|
549
|
+
* @param {string} workingCopyDir - Working copy directory
|
|
550
|
+
* @param {string} filePath - File path
|
|
551
|
+
* @param {any} content - The winning (right) side's content, or
|
|
552
|
+
* `undefined` to mean the file was deleted on that side
|
|
553
|
+
*/
|
|
554
|
+
async _applyCleanResolution(workingCopyDir, filePath, content) {
|
|
555
|
+
const fullPath = path.join(workingCopyDir, filePath);
|
|
556
|
+
|
|
557
|
+
if (content === undefined) {
|
|
558
|
+
await this.fs.promises.unlink(fullPath).catch(() => {});
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const dir = path.dirname(fullPath);
|
|
563
|
+
await mkdirp(this.fs, dir);
|
|
564
|
+
|
|
565
|
+
if (isBytes(content)) {
|
|
566
|
+
await this.fs.promises.writeFile(fullPath, content);
|
|
567
|
+
} else {
|
|
568
|
+
await this.fs.promises.writeFile(fullPath, content, 'utf8');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
507
572
|
/**
|
|
508
573
|
* Write merge driver result to working copy (v0.5)
|
|
509
574
|
*
|
|
@@ -516,14 +581,14 @@ export class ConflictModel {
|
|
|
516
581
|
|
|
517
582
|
// Ensure directory exists
|
|
518
583
|
const dir = path.dirname(fullPath);
|
|
519
|
-
await this.fs
|
|
584
|
+
await mkdirp(this.fs, dir);
|
|
520
585
|
|
|
521
586
|
// Write main file
|
|
522
587
|
if (result.content !== null && result.content !== undefined) {
|
|
523
|
-
if (
|
|
588
|
+
if (isBytes(result.content)) {
|
|
524
589
|
await this.fs.promises.writeFile(fullPath, result.content);
|
|
525
590
|
} else {
|
|
526
|
-
await this.fs.promises.writeFile(fullPath, result.content, '
|
|
591
|
+
await this.fs.promises.writeFile(fullPath, result.content, 'utf8');
|
|
527
592
|
}
|
|
528
593
|
}
|
|
529
594
|
|
|
@@ -532,12 +597,12 @@ export class ConflictModel {
|
|
|
532
597
|
for (const [additionalPath, additionalContent] of Object.entries(result.additionalFiles)) {
|
|
533
598
|
const additionalFullPath = path.join(workingCopyDir, additionalPath);
|
|
534
599
|
const additionalDir = path.dirname(additionalFullPath);
|
|
535
|
-
await this.fs
|
|
600
|
+
await mkdirp(this.fs, additionalDir);
|
|
536
601
|
|
|
537
|
-
if (
|
|
602
|
+
if (isBytes(additionalContent)) {
|
|
538
603
|
await this.fs.promises.writeFile(additionalFullPath, additionalContent);
|
|
539
604
|
} else {
|
|
540
|
-
await this.fs.promises.writeFile(additionalFullPath, additionalContent, '
|
|
605
|
+
await this.fs.promises.writeFile(additionalFullPath, additionalContent, 'utf8');
|
|
541
606
|
}
|
|
542
607
|
}
|
|
543
608
|
}
|
package/src/core/jj-checkout.js
CHANGED
|
@@ -5,18 +5,8 @@
|
|
|
5
5
|
* This file tracks which operation the working copy is at.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import { fileURLToPath } from 'url';
|
|
11
|
-
|
|
12
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
-
const __dirname = path.dirname(__filename);
|
|
14
|
-
|
|
15
|
-
// Determine proto path based on whether we're running from dist or src
|
|
16
|
-
const inDist = __dirname.includes('/dist') || __dirname.includes('\\dist');
|
|
17
|
-
const protoDir = inDist
|
|
18
|
-
? path.join(__dirname, '..', 'src', 'protos')
|
|
19
|
-
: path.join(__dirname, '..', 'protos');
|
|
8
|
+
import { getLocalWorkingCopyRoot } from '../protos/schema.js';
|
|
9
|
+
import { hexToBytes } from '../utils/bytes.js';
|
|
20
10
|
|
|
21
11
|
export class JJCheckout {
|
|
22
12
|
/**
|
|
@@ -26,7 +16,6 @@ export class JJCheckout {
|
|
|
26
16
|
constructor(fs, dir) {
|
|
27
17
|
this.fs = fs;
|
|
28
18
|
this.dir = dir;
|
|
29
|
-
this.protoPath = path.join(protoDir, 'local_working_copy.proto');
|
|
30
19
|
}
|
|
31
20
|
|
|
32
21
|
/**
|
|
@@ -37,11 +26,11 @@ export class JJCheckout {
|
|
|
37
26
|
*/
|
|
38
27
|
async writeCheckout(operationId, workspaceName = 'default') {
|
|
39
28
|
// Load protobuf schema
|
|
40
|
-
const root =
|
|
29
|
+
const root = getLocalWorkingCopyRoot();
|
|
41
30
|
const Checkout = root.lookupType('local_working_copy.Checkout');
|
|
42
31
|
|
|
43
32
|
// Convert hex operation ID to bytes
|
|
44
|
-
const opIdBuffer =
|
|
33
|
+
const opIdBuffer = hexToBytes(operationId);
|
|
45
34
|
|
|
46
35
|
// Create message (use camelCase for protobufjs)
|
|
47
36
|
const message = Checkout.create({
|
|
@@ -73,7 +62,7 @@ export class JJCheckout {
|
|
|
73
62
|
const buffer = await this.fs.promises.readFile(checkoutPath);
|
|
74
63
|
|
|
75
64
|
// Load protobuf schema
|
|
76
|
-
const root =
|
|
65
|
+
const root = getLocalWorkingCopyRoot();
|
|
77
66
|
const Checkout = root.lookupType('local_working_copy.Checkout');
|
|
78
67
|
|
|
79
68
|
// Decode
|
|
@@ -5,18 +5,9 @@
|
|
|
5
5
|
* Operations track the history of changes to the repository.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
-
const __dirname = path.dirname(__filename);
|
|
14
|
-
|
|
15
|
-
// Determine proto path based on whether we're running from dist or src
|
|
16
|
-
const inDist = __dirname.includes('/dist') || __dirname.includes('\\dist');
|
|
17
|
-
const protoDir = inDist
|
|
18
|
-
? path.join(__dirname, '..', 'src', 'protos')
|
|
19
|
-
: path.join(__dirname, '..', 'protos');
|
|
8
|
+
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
9
|
+
import { getSimpleOpStoreRoot } from '../protos/schema.js';
|
|
10
|
+
import { hexToBytes } from '../utils/bytes.js';
|
|
20
11
|
|
|
21
12
|
export class JJOperationStore {
|
|
22
13
|
/**
|
|
@@ -26,7 +17,6 @@ export class JJOperationStore {
|
|
|
26
17
|
constructor(fs, dir) {
|
|
27
18
|
this.fs = fs;
|
|
28
19
|
this.dir = dir;
|
|
29
|
-
this.protoPath = path.join(protoDir, 'simple_op_store.proto');
|
|
30
20
|
}
|
|
31
21
|
|
|
32
22
|
/**
|
|
@@ -39,12 +29,12 @@ export class JJOperationStore {
|
|
|
39
29
|
*/
|
|
40
30
|
async writeOperation(operationId, viewId, parentIds, metadata) {
|
|
41
31
|
// Load protobuf schema
|
|
42
|
-
const root =
|
|
32
|
+
const root = getSimpleOpStoreRoot();
|
|
43
33
|
const Operation = root.lookupType('simple_op_store.Operation');
|
|
44
34
|
|
|
45
35
|
// Convert hex IDs to bytes
|
|
46
|
-
const viewIdBuffer =
|
|
47
|
-
const parentBuffers = parentIds.map((id) =>
|
|
36
|
+
const viewIdBuffer = hexToBytes(viewId);
|
|
37
|
+
const parentBuffers = parentIds.map((id) => hexToBytes(id));
|
|
48
38
|
|
|
49
39
|
// Create metadata message (use camelCase for protobufjs)
|
|
50
40
|
const metadataMsg = {
|
|
@@ -81,9 +71,11 @@ export class JJOperationStore {
|
|
|
81
71
|
// Encode to binary
|
|
82
72
|
const buffer = Operation.encode(message).finish();
|
|
83
73
|
|
|
84
|
-
// Write to .jj/repo/op_store/operations/OPERATIONID
|
|
74
|
+
// Write to .jj/repo/op_store/operations/OPERATIONID (atomically — a
|
|
75
|
+
// crash mid-write must never leave a truncated/undecodable file, see
|
|
76
|
+
// issue #16).
|
|
85
77
|
const opPath = `${this.dir}/.jj/repo/op_store/operations/${operationId}`;
|
|
86
|
-
await this.fs
|
|
78
|
+
await atomicWriteFile(this.fs, opPath, buffer);
|
|
87
79
|
}
|
|
88
80
|
|
|
89
81
|
/**
|
|
@@ -98,7 +90,7 @@ export class JJOperationStore {
|
|
|
98
90
|
const buffer = await this.fs.promises.readFile(opPath);
|
|
99
91
|
|
|
100
92
|
// Load protobuf schema
|
|
101
|
-
const root =
|
|
93
|
+
const root = getSimpleOpStoreRoot();
|
|
102
94
|
const Operation = root.lookupType('simple_op_store.Operation');
|
|
103
95
|
|
|
104
96
|
// Decode
|