isomorphic-jj 1.0.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/LICENSE +21 -0
- package/README.md +971 -0
- package/dist/index.cjs +12609 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +12600 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +90 -0
- package/src/api/repository.js +6885 -0
- package/src/backend/isomorphic-git-backend.js +687 -0
- package/src/backend/lazy-git-backend.js +180 -0
- package/src/browser/helpers.js +189 -0
- package/src/browser/index.d.ts +174 -0
- package/src/browser/index.js +12 -0
- package/src/core/background-ops.js +272 -0
- package/src/core/bookmark-store.js +232 -0
- package/src/core/change-graph.js +285 -0
- package/src/core/conflict-model.js +505 -0
- package/src/core/jj-checkout.js +78 -0
- package/src/core/jj-operation-store.js +120 -0
- package/src/core/jj-tree-state.js +94 -0
- package/src/core/jj-view-store.js +105 -0
- package/src/core/merge-driver-registry.js +432 -0
- package/src/core/operation-log.js +272 -0
- package/src/core/revset-engine.js +1081 -0
- package/src/core/storage-manager.js +207 -0
- package/src/core/tag-store.js +167 -0
- package/src/core/user-config.js +142 -0
- package/src/core/working-copy.js +314 -0
- package/src/core/workspace-manager.js +283 -0
- package/src/drivers/built-in-drivers.js +296 -0
- package/src/index.js +15 -0
- package/src/protos/local_working_copy.proto +76 -0
- package/src/protos/simple_op_store.proto +147 -0
- package/src/types.d.ts +1129 -0
- package/src/utils/errors.js +21 -0
- package/src/utils/id-generation.js +54 -0
- package/src/utils/validation.js +124 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* isomorphic-git Backend Adapter
|
|
3
|
+
*
|
|
4
|
+
* Provides real Git object storage using isomorphic-git library.
|
|
5
|
+
* Implements the JJBackend interface for Git interoperability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import git from 'isomorphic-git';
|
|
9
|
+
import { JJError } from '../utils/errors.js';
|
|
10
|
+
import { JJCheckout } from '../core/jj-checkout.js';
|
|
11
|
+
import { JJTreeState } from '../core/jj-tree-state.js';
|
|
12
|
+
import { JJOperationStore } from '../core/jj-operation-store.js';
|
|
13
|
+
import { JJViewStore } from '../core/jj-view-store.js';
|
|
14
|
+
import crypto from 'crypto';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Backend adapter for isomorphic-git
|
|
18
|
+
*
|
|
19
|
+
* @class IsomorphicGitBackend
|
|
20
|
+
*/
|
|
21
|
+
export class IsomorphicGitBackend {
|
|
22
|
+
/**
|
|
23
|
+
* Create isomorphic-git backend
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} options - Backend options
|
|
26
|
+
* @param {Object} options.fs - Filesystem implementation (Node fs or LightningFS)
|
|
27
|
+
* @param {Object} [options.http] - HTTP client for network operations
|
|
28
|
+
* @param {string} options.dir - Repository directory path
|
|
29
|
+
*/
|
|
30
|
+
constructor({ fs, http, dir }) {
|
|
31
|
+
if (!fs) {
|
|
32
|
+
throw new JJError(
|
|
33
|
+
'INVALID_BACKEND_CONFIG',
|
|
34
|
+
'Filesystem (fs) is required for isomorphic-git backend',
|
|
35
|
+
{ suggestion: 'Provide fs option (Node fs or LightningFS)' }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (!dir) {
|
|
39
|
+
throw new JJError(
|
|
40
|
+
'INVALID_BACKEND_CONFIG',
|
|
41
|
+
'Repository directory (dir) is required',
|
|
42
|
+
{ suggestion: 'Provide dir option with repository path' }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.fs = fs;
|
|
47
|
+
this.http = http;
|
|
48
|
+
this.dir = dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Initialize Git repository
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} opts - Init options
|
|
55
|
+
* @param {string} [opts.defaultBranch='main'] - Default branch name
|
|
56
|
+
* @returns {Promise<void>}
|
|
57
|
+
*/
|
|
58
|
+
async init(opts = {}) {
|
|
59
|
+
try {
|
|
60
|
+
await git.init({
|
|
61
|
+
fs: this.fs,
|
|
62
|
+
dir: this.dir,
|
|
63
|
+
defaultBranch: opts.defaultBranch || 'main',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Create .jj/repo structure for jj CLI compatibility
|
|
67
|
+
await this._createJJRepoStructure();
|
|
68
|
+
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw new JJError(
|
|
71
|
+
'INIT_FAILED',
|
|
72
|
+
`Failed to initialize Git repository: ${error.message}`,
|
|
73
|
+
{ dir: this.dir, originalError: error }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read Git object from storage
|
|
80
|
+
*
|
|
81
|
+
* @param {string} oid - Git object SHA-1 hash (40-char hex)
|
|
82
|
+
* @returns {Promise<{type: string, data: Uint8Array}>} Git object
|
|
83
|
+
*/
|
|
84
|
+
async getObject(oid) {
|
|
85
|
+
try {
|
|
86
|
+
const { type, object } = await git.readObject({
|
|
87
|
+
fs: this.fs,
|
|
88
|
+
dir: this.dir,
|
|
89
|
+
oid,
|
|
90
|
+
});
|
|
91
|
+
return { type, data: object };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.code === 'NotFoundError' || error.code === 'ReadObjectFail') {
|
|
94
|
+
throw new JJError(
|
|
95
|
+
'NOT_FOUND',
|
|
96
|
+
`Git object ${oid} not found`,
|
|
97
|
+
{ oid, suggestion: 'Ensure object exists in repository' }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
throw new JJError(
|
|
101
|
+
'STORAGE_READ_FAILED',
|
|
102
|
+
`Failed to read Git object: ${error.message}`,
|
|
103
|
+
{ oid, originalError: error }
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Write Git object to storage
|
|
110
|
+
*
|
|
111
|
+
* @param {string} type - Object type ('blob', 'tree', 'commit', 'tag')
|
|
112
|
+
* @param {Uint8Array} data - Object data
|
|
113
|
+
* @returns {Promise<string>} Object SHA-1 hash
|
|
114
|
+
*/
|
|
115
|
+
async putObject(type, data) {
|
|
116
|
+
try {
|
|
117
|
+
const oid = await git.writeObject({
|
|
118
|
+
fs: this.fs,
|
|
119
|
+
dir: this.dir,
|
|
120
|
+
type,
|
|
121
|
+
object: data,
|
|
122
|
+
});
|
|
123
|
+
return oid;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new JJError(
|
|
126
|
+
'STORAGE_WRITE_FAILED',
|
|
127
|
+
`Failed to write Git object: ${error.message}`,
|
|
128
|
+
{ type, dataSize: data.length, originalError: error }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read Git reference
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name - Full ref name (e.g., 'refs/heads/main')
|
|
137
|
+
* @returns {Promise<string|null>} Commit SHA-1 or null if not found
|
|
138
|
+
*/
|
|
139
|
+
async readRef(name) {
|
|
140
|
+
try {
|
|
141
|
+
const oid = await git.resolveRef({
|
|
142
|
+
fs: this.fs,
|
|
143
|
+
dir: this.dir,
|
|
144
|
+
ref: name,
|
|
145
|
+
});
|
|
146
|
+
return oid;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error.code === 'NotFoundError' || error.code === 'ResolveRefError') {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
throw new JJError(
|
|
152
|
+
'STORAGE_READ_FAILED',
|
|
153
|
+
`Failed to read Git ref: ${error.message}`,
|
|
154
|
+
{ ref: name, originalError: error }
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create, update, or delete Git reference
|
|
161
|
+
*
|
|
162
|
+
* @param {string} name - Full ref name
|
|
163
|
+
* @param {string|null} oid - Commit SHA-1 or null to delete
|
|
164
|
+
* @returns {Promise<void>}
|
|
165
|
+
*/
|
|
166
|
+
async updateRef(name, oid) {
|
|
167
|
+
try {
|
|
168
|
+
if (oid === null) {
|
|
169
|
+
// Delete ref
|
|
170
|
+
const refPath = `${this.dir}/.git/${name}`;
|
|
171
|
+
try {
|
|
172
|
+
await this.fs.promises.unlink(refPath);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
// Ignore if ref doesn't exist
|
|
175
|
+
if (error.code !== 'ENOENT') {
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// Create or update ref
|
|
181
|
+
await git.writeRef({
|
|
182
|
+
fs: this.fs,
|
|
183
|
+
dir: this.dir,
|
|
184
|
+
ref: name,
|
|
185
|
+
value: oid,
|
|
186
|
+
force: true,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new JJError(
|
|
191
|
+
'STORAGE_WRITE_FAILED',
|
|
192
|
+
`Failed to update Git ref: ${error.message}`,
|
|
193
|
+
{ ref: name, oid, originalError: error }
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* List Git references
|
|
200
|
+
*
|
|
201
|
+
* @param {string} [prefix=''] - Ref prefix filter
|
|
202
|
+
* @returns {Promise<Array<{name: string, oid: string}>>} List of refs
|
|
203
|
+
*/
|
|
204
|
+
async listRefs(prefix = '') {
|
|
205
|
+
try {
|
|
206
|
+
// Use expandRef to get all possible refs
|
|
207
|
+
const result = [];
|
|
208
|
+
const refDirs = ['refs/heads', 'refs/tags', 'refs/remotes'];
|
|
209
|
+
|
|
210
|
+
for (const refDir of refDirs) {
|
|
211
|
+
if (prefix && !refDir.startsWith(prefix) && !prefix.startsWith(refDir)) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const dirPath = `${this.dir}/.git/${refDir}`;
|
|
217
|
+
const entries = await this.fs.promises.readdir(dirPath, { recursive: true });
|
|
218
|
+
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
const refName = `${refDir}/${entry}`;
|
|
221
|
+
if (prefix && !refName.startsWith(prefix)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const oid = await this.readRef(refName);
|
|
227
|
+
if (oid) {
|
|
228
|
+
result.push({ name: refName, oid });
|
|
229
|
+
}
|
|
230
|
+
} catch (e) {
|
|
231
|
+
// Skip refs that can't be read
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// Directory doesn't exist, skip
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result.sort((a, b) => a.name.localeCompare(b.name));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
throw new JJError(
|
|
242
|
+
'STORAGE_READ_FAILED',
|
|
243
|
+
`Failed to list Git refs: ${error.message}`,
|
|
244
|
+
{ prefix, originalError: error }
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Create a Git commit from working directory state
|
|
251
|
+
*
|
|
252
|
+
* @param {Object} opts - Commit options
|
|
253
|
+
* @param {string} opts.message - Commit message
|
|
254
|
+
* @param {Object} opts.author - Author info
|
|
255
|
+
* @param {string} opts.author.name - Author name
|
|
256
|
+
* @param {string} opts.author.email - Author email
|
|
257
|
+
* @param {number} [opts.author.timestamp] - Author timestamp (milliseconds since epoch)
|
|
258
|
+
* @param {Object} [opts.committer] - Committer info (defaults to author)
|
|
259
|
+
* @param {string[]} [opts.parents=[]] - Parent commit SHAs
|
|
260
|
+
* @returns {Promise<string>} Commit SHA
|
|
261
|
+
*/
|
|
262
|
+
async createCommit(opts) {
|
|
263
|
+
try {
|
|
264
|
+
const author = {
|
|
265
|
+
name: opts.author.name,
|
|
266
|
+
email: opts.author.email,
|
|
267
|
+
timestamp: opts.author.timestamp ? Math.floor(opts.author.timestamp / 1000) : Math.floor(Date.now() / 1000),
|
|
268
|
+
timezoneOffset: 0,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const committer = opts.committer ? {
|
|
272
|
+
name: opts.committer.name,
|
|
273
|
+
email: opts.committer.email,
|
|
274
|
+
timestamp: opts.committer.timestamp ? Math.floor(opts.committer.timestamp / 1000) : author.timestamp,
|
|
275
|
+
timezoneOffset: 0,
|
|
276
|
+
} : author;
|
|
277
|
+
|
|
278
|
+
const commitSha = await git.commit({
|
|
279
|
+
fs: this.fs,
|
|
280
|
+
dir: this.dir,
|
|
281
|
+
message: opts.message,
|
|
282
|
+
author,
|
|
283
|
+
committer,
|
|
284
|
+
parent: opts.parents || [],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return commitSha;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
throw new JJError(
|
|
290
|
+
'COMMIT_FAILED',
|
|
291
|
+
`Failed to create Git commit: ${error.message}`,
|
|
292
|
+
{ message: opts.message, originalError: error }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get the current tree SHA for the working directory
|
|
299
|
+
*
|
|
300
|
+
* @returns {Promise<string>} Tree SHA
|
|
301
|
+
*/
|
|
302
|
+
async getCurrentTree() {
|
|
303
|
+
try {
|
|
304
|
+
// Get the current staging area tree
|
|
305
|
+
// This will create tree objects from the current working directory
|
|
306
|
+
const tree = await git.writeTree({
|
|
307
|
+
fs: this.fs,
|
|
308
|
+
dir: this.dir,
|
|
309
|
+
});
|
|
310
|
+
return tree;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
throw new JJError(
|
|
313
|
+
'TREE_READ_FAILED',
|
|
314
|
+
`Failed to get current tree: ${error.message}`,
|
|
315
|
+
{ originalError: error }
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Stage all files in the working directory
|
|
322
|
+
*
|
|
323
|
+
* @returns {Promise<void>}
|
|
324
|
+
*/
|
|
325
|
+
async stageAll() {
|
|
326
|
+
try {
|
|
327
|
+
// Get all files in the directory
|
|
328
|
+
const files = await this._getAllFiles(this.dir);
|
|
329
|
+
|
|
330
|
+
// Add each file to the index
|
|
331
|
+
for (const filepath of files) {
|
|
332
|
+
try {
|
|
333
|
+
await git.add({
|
|
334
|
+
fs: this.fs,
|
|
335
|
+
dir: this.dir,
|
|
336
|
+
filepath,
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
// Skip files that can't be staged (like .git directory)
|
|
340
|
+
if (error.code !== 'ENOENT') {
|
|
341
|
+
throw new JJError(
|
|
342
|
+
'STAGE_FILE_FAILED',
|
|
343
|
+
`Could not stage ${filepath}: ${error.message}`,
|
|
344
|
+
{ filepath, originalError: error.message }
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
throw new JJError(
|
|
351
|
+
'STAGE_FAILED',
|
|
352
|
+
`Failed to stage files: ${error.message}`,
|
|
353
|
+
{ originalError: error }
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get all files in a directory recursively (helper method)
|
|
360
|
+
*
|
|
361
|
+
* @private
|
|
362
|
+
* @param {string} dir - Directory path
|
|
363
|
+
* @param {string} [baseDir=''] - Base directory for relative paths
|
|
364
|
+
* @returns {Promise<string[]>} Array of relative file paths
|
|
365
|
+
*/
|
|
366
|
+
async _getAllFiles(dir, baseDir = '') {
|
|
367
|
+
const files = [];
|
|
368
|
+
const entries = await this.fs.promises.readdir(dir, { withFileTypes: true });
|
|
369
|
+
|
|
370
|
+
for (const entry of entries) {
|
|
371
|
+
const name = entry.name || entry;
|
|
372
|
+
const fullPath = `${dir}/${name}`;
|
|
373
|
+
const relativePath = baseDir ? `${baseDir}/${name}` : name;
|
|
374
|
+
|
|
375
|
+
// Skip .git and .jj directories
|
|
376
|
+
if (name === '.git' || name === '.jj') {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const stats = await this.fs.promises.stat(fullPath);
|
|
382
|
+
if (stats.isDirectory && stats.isDirectory()) {
|
|
383
|
+
// Recurse into subdirectories
|
|
384
|
+
const subFiles = await this._getAllFiles(fullPath, relativePath);
|
|
385
|
+
files.push(...subFiles);
|
|
386
|
+
} else if (stats.isFile && stats.isFile()) {
|
|
387
|
+
files.push(relativePath);
|
|
388
|
+
} else {
|
|
389
|
+
// For simple objects without methods
|
|
390
|
+
const isDir = (await this.fs.promises.readdir(fullPath).catch(() => null)) !== null;
|
|
391
|
+
if (isDir) {
|
|
392
|
+
const subFiles = await this._getAllFiles(fullPath, relativePath);
|
|
393
|
+
files.push(...subFiles);
|
|
394
|
+
} else {
|
|
395
|
+
files.push(relativePath);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} catch (error) {
|
|
399
|
+
// Skip files we can't read
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return files;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Fetch objects and refs from remote repository
|
|
409
|
+
*
|
|
410
|
+
* @param {Object} opts - Fetch options
|
|
411
|
+
* @param {string} opts.remote - Remote name or URL
|
|
412
|
+
* @param {string[]} [opts.refs] - Ref specs to fetch
|
|
413
|
+
* @param {number} [opts.depth] - Create a shallow clone with history truncated to depth (v0.4)
|
|
414
|
+
* @param {boolean} [opts.relative] - Depth measured from current shallow depth (v0.4)
|
|
415
|
+
* @param {boolean} [opts.singleBranch] - Only fetch single branch (v0.4)
|
|
416
|
+
* @param {boolean} [opts.noTags] - Don't fetch tags (v0.4)
|
|
417
|
+
* @param {Function} [opts.onProgress] - Progress callback
|
|
418
|
+
* @param {Function} [opts.onAuth] - Authentication callback
|
|
419
|
+
* @returns {Promise<{fetchedRefs: Array, updatedRefs: Array}>} Fetch result
|
|
420
|
+
*/
|
|
421
|
+
async fetch(opts) {
|
|
422
|
+
if (!this.http) {
|
|
423
|
+
throw new JJError(
|
|
424
|
+
'NETWORK_NOT_AVAILABLE',
|
|
425
|
+
'HTTP client not provided - network operations unavailable',
|
|
426
|
+
{ suggestion: 'Provide http option when creating backend' }
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const fetchedRefs = [];
|
|
432
|
+
const updatedRefs = [];
|
|
433
|
+
|
|
434
|
+
// Fetch each ref spec (isomorphic-git fetches one at a time)
|
|
435
|
+
const refsToFetch = opts.refs || ['HEAD'];
|
|
436
|
+
for (const ref of refsToFetch) {
|
|
437
|
+
await git.fetch({
|
|
438
|
+
fs: this.fs,
|
|
439
|
+
http: this.http,
|
|
440
|
+
dir: this.dir,
|
|
441
|
+
remote: opts.remote,
|
|
442
|
+
ref,
|
|
443
|
+
depth: opts.depth, // v0.4: Shallow clone support
|
|
444
|
+
relative: opts.relative, // v0.4: Relative depth
|
|
445
|
+
singleBranch: opts.singleBranch, // v0.4: Single branch only
|
|
446
|
+
tags: opts.noTags === true ? false : undefined, // v0.4: Skip tags
|
|
447
|
+
onProgress: opts.onProgress,
|
|
448
|
+
onAuth: opts.onAuth,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Track fetched ref
|
|
452
|
+
const remoteRef = `refs/remotes/${opts.remote}/${ref.replace('refs/heads/', '')}`;
|
|
453
|
+
const oid = await this.readRef(remoteRef);
|
|
454
|
+
if (oid) {
|
|
455
|
+
fetchedRefs.push({ name: remoteRef, oid });
|
|
456
|
+
updatedRefs.push(remoteRef);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return { fetchedRefs, updatedRefs };
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (error.code === 'HttpError' || error.code === 'NetworkError') {
|
|
463
|
+
throw new JJError(
|
|
464
|
+
'NETWORK_ERROR',
|
|
465
|
+
`Network error during fetch: ${error.message}`,
|
|
466
|
+
{ remote: opts.remote, originalError: error }
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (error.code === 'AuthError') {
|
|
470
|
+
throw new JJError(
|
|
471
|
+
'AUTH_FAILED',
|
|
472
|
+
`Authentication failed: ${error.message}`,
|
|
473
|
+
{ remote: opts.remote, originalError: error }
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
throw new JJError(
|
|
477
|
+
'FETCH_FAILED',
|
|
478
|
+
`Fetch failed: ${error.message}`,
|
|
479
|
+
{ remote: opts.remote, originalError: error }
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Push objects and refs to remote repository
|
|
486
|
+
*
|
|
487
|
+
* @param {Object} opts - Push options
|
|
488
|
+
* @param {string} opts.remote - Remote name or URL
|
|
489
|
+
* @param {string[]} [opts.refs] - Ref specs to push
|
|
490
|
+
* @param {boolean} [opts.force] - Allow non-fast-forward
|
|
491
|
+
* @param {Function} [opts.onProgress] - Progress callback
|
|
492
|
+
* @param {Function} [opts.onAuth] - Authentication callback
|
|
493
|
+
* @returns {Promise<{pushedRefs: Array, rejectedRefs: Array}>} Push result
|
|
494
|
+
*/
|
|
495
|
+
async push(opts) {
|
|
496
|
+
if (!this.http) {
|
|
497
|
+
throw new JJError(
|
|
498
|
+
'NETWORK_NOT_AVAILABLE',
|
|
499
|
+
'HTTP client not provided - network operations unavailable',
|
|
500
|
+
{ suggestion: 'Provide http option when creating backend' }
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
const pushedRefs = [];
|
|
506
|
+
const rejectedRefs = [];
|
|
507
|
+
|
|
508
|
+
// Push each ref spec (isomorphic-git pushes one at a time)
|
|
509
|
+
const refsToPush = opts.refs || [];
|
|
510
|
+
for (const ref of refsToPush) {
|
|
511
|
+
try {
|
|
512
|
+
await git.push({
|
|
513
|
+
fs: this.fs,
|
|
514
|
+
http: this.http,
|
|
515
|
+
dir: this.dir,
|
|
516
|
+
remote: opts.remote,
|
|
517
|
+
ref,
|
|
518
|
+
force: opts.force || false,
|
|
519
|
+
onProgress: opts.onProgress,
|
|
520
|
+
onAuth: opts.onAuth,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Track pushed ref
|
|
524
|
+
const oid = await this.readRef(ref);
|
|
525
|
+
if (oid) {
|
|
526
|
+
pushedRefs.push({ name: ref, oid });
|
|
527
|
+
}
|
|
528
|
+
} catch (pushError) {
|
|
529
|
+
// Track rejected refs
|
|
530
|
+
rejectedRefs.push(ref);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return { pushedRefs, rejectedRefs };
|
|
535
|
+
} catch (error) {
|
|
536
|
+
if (error.code === 'HttpError' || error.code === 'NetworkError') {
|
|
537
|
+
throw new JJError(
|
|
538
|
+
'NETWORK_ERROR',
|
|
539
|
+
`Network error during push: ${error.message}`,
|
|
540
|
+
{ remote: opts.remote, originalError: error }
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (error.code === 'AuthError') {
|
|
544
|
+
throw new JJError(
|
|
545
|
+
'AUTH_FAILED',
|
|
546
|
+
`Authentication failed: ${error.message}`,
|
|
547
|
+
{ remote: opts.remote, originalError: error }
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (error.code === 'PushRejectedError') {
|
|
551
|
+
throw new JJError(
|
|
552
|
+
'PUSH_REJECTED',
|
|
553
|
+
`Push rejected (non-fast-forward): ${error.message}`,
|
|
554
|
+
{ remote: opts.remote, suggestion: 'Use force option or pull first', originalError: error }
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
throw new JJError(
|
|
558
|
+
'PUSH_FAILED',
|
|
559
|
+
`Push failed: ${error.message}`,
|
|
560
|
+
{ remote: opts.remote, originalError: error }
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Create minimal .jj/repo structure for jj CLI compatibility
|
|
567
|
+
* @private
|
|
568
|
+
*/
|
|
569
|
+
async _createJJRepoStructure() {
|
|
570
|
+
const jjDir = `${this.dir}/.jj`;
|
|
571
|
+
const jjRepo = `${jjDir}/repo`;
|
|
572
|
+
|
|
573
|
+
// Create directory structure
|
|
574
|
+
await this.fs.promises.mkdir(`${jjRepo}/store`, { recursive: true });
|
|
575
|
+
await this.fs.promises.mkdir(`${jjRepo}/store/extra/heads`, { recursive: true });
|
|
576
|
+
await this.fs.promises.mkdir(`${jjRepo}/op_store/operations`, { recursive: true });
|
|
577
|
+
await this.fs.promises.mkdir(`${jjRepo}/op_store/views`, { recursive: true });
|
|
578
|
+
await this.fs.promises.mkdir(`${jjRepo}/op_heads/heads`, { recursive: true });
|
|
579
|
+
await this.fs.promises.mkdir(`${jjRepo}/index`, { recursive: true });
|
|
580
|
+
await this.fs.promises.mkdir(`${jjRepo}/submodule_store`, { recursive: true });
|
|
581
|
+
await this.fs.promises.mkdir(`${jjDir}/working_copy`, { recursive: true });
|
|
582
|
+
|
|
583
|
+
// Write type files
|
|
584
|
+
await this.fs.promises.writeFile(`${jjRepo}/store/type`, 'git');
|
|
585
|
+
await this.fs.promises.writeFile(`${jjRepo}/store/git_target`, '../../../.git');
|
|
586
|
+
await this.fs.promises.writeFile(`${jjRepo}/op_store/type`, 'simple_op_store');
|
|
587
|
+
await this.fs.promises.writeFile(`${jjRepo}/op_heads/type`, 'simple_op_heads_store');
|
|
588
|
+
await this.fs.promises.writeFile(`${jjRepo}/index/type`, 'default');
|
|
589
|
+
await this.fs.promises.writeFile(`${jjRepo}/submodule_store/type`, 'default');
|
|
590
|
+
await this.fs.promises.writeFile(`${jjDir}/working_copy/type`, 'local');
|
|
591
|
+
|
|
592
|
+
// Create .jj/.gitignore to prevent git from tracking jj metadata
|
|
593
|
+
await this.fs.promises.writeFile(`${jjDir}/.gitignore`, '/*\n');
|
|
594
|
+
|
|
595
|
+
// Add .jj to root .gitignore if it doesn't exist
|
|
596
|
+
await this._ensureGitignore();
|
|
597
|
+
|
|
598
|
+
// Create initial protobuf files for jj CLI compatibility
|
|
599
|
+
await this._createInitialJJState();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Create initial JJ state (protobuf files)
|
|
604
|
+
* @private
|
|
605
|
+
*/
|
|
606
|
+
async _createInitialJJState() {
|
|
607
|
+
// Generate unique IDs for initial state
|
|
608
|
+
const operationId = crypto.randomBytes(64).toString('hex'); // 512-bit = 128 hex chars
|
|
609
|
+
const viewId = crypto.randomBytes(64).toString('hex');
|
|
610
|
+
const emptyTreeId = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; // Git's empty tree hash
|
|
611
|
+
|
|
612
|
+
// Get the initial commit from Git
|
|
613
|
+
let initialCommitId = '0000000000000000000000000000000000000000';
|
|
614
|
+
try {
|
|
615
|
+
const headOid = await git.resolveRef({
|
|
616
|
+
fs: this.fs,
|
|
617
|
+
dir: this.dir,
|
|
618
|
+
ref: 'HEAD',
|
|
619
|
+
});
|
|
620
|
+
if (headOid) {
|
|
621
|
+
initialCommitId = crypto.randomBytes(64).toString('hex');
|
|
622
|
+
}
|
|
623
|
+
} catch (error) {
|
|
624
|
+
// No initial commit yet
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Create checkout file
|
|
628
|
+
const checkout = new JJCheckout(this.fs, this.dir);
|
|
629
|
+
await checkout.writeCheckout(operationId, 'default');
|
|
630
|
+
|
|
631
|
+
// Create tree_state file
|
|
632
|
+
const treeState = new JJTreeState(this.fs, this.dir);
|
|
633
|
+
await treeState.writeTreeState(emptyTreeId, []);
|
|
634
|
+
|
|
635
|
+
// Create initial operation
|
|
636
|
+
const opStore = new JJOperationStore(this.fs, this.dir);
|
|
637
|
+
const metadata = {
|
|
638
|
+
start_time: {
|
|
639
|
+
millis_since_epoch: Date.now(),
|
|
640
|
+
tz_offset: new Date().getTimezoneOffset()
|
|
641
|
+
},
|
|
642
|
+
end_time: {
|
|
643
|
+
millis_since_epoch: Date.now(),
|
|
644
|
+
tz_offset: new Date().getTimezoneOffset()
|
|
645
|
+
},
|
|
646
|
+
description: 'initialize repo',
|
|
647
|
+
hostname: 'localhost',
|
|
648
|
+
username: 'user',
|
|
649
|
+
is_snapshot: false,
|
|
650
|
+
tags: {}
|
|
651
|
+
};
|
|
652
|
+
await opStore.writeOperation(operationId, viewId, [], metadata);
|
|
653
|
+
|
|
654
|
+
// Create initial view
|
|
655
|
+
const viewStore = new JJViewStore(this.fs, this.dir);
|
|
656
|
+
await viewStore.writeView(viewId, [initialCommitId], { default: initialCommitId });
|
|
657
|
+
|
|
658
|
+
// Write operation head - the filename IS the operation ID
|
|
659
|
+
const opHeadPath = `${this.dir}/.jj/repo/op_heads/heads/${operationId}`;
|
|
660
|
+
await this.fs.promises.writeFile(opHeadPath, '');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Ensure .gitignore has .jj/ entry
|
|
665
|
+
* @private
|
|
666
|
+
*/
|
|
667
|
+
async _ensureGitignore() {
|
|
668
|
+
const gitignorePath = `${this.dir}/.gitignore`;
|
|
669
|
+
let content = '';
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
content = await this.fs.promises.readFile(gitignorePath, 'utf8');
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (error.code !== 'ENOENT') throw error;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Check if .jj is already in .gitignore
|
|
678
|
+
const lines = content.split('\n');
|
|
679
|
+
const hasJJ = lines.some(line => line.trim() === '.jj' || line.trim() === '.jj/');
|
|
680
|
+
|
|
681
|
+
if (!hasJJ) {
|
|
682
|
+
// Add .jj to .gitignore
|
|
683
|
+
const newContent = content + (content && !content.endsWith('\n') ? '\n' : '') + '.jj/\n';
|
|
684
|
+
await this.fs.promises.writeFile(gitignorePath, newContent);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|