browser-git-ops 0.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.
@@ -0,0 +1,496 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.VirtualFS = void 0;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const persistence_1 = require("./persistence");
9
+ /**
10
+ *
11
+ */
12
+ class VirtualFS {
13
+ storageDir;
14
+ base = new Map();
15
+ workspace = new Map();
16
+ tombstones = new Map();
17
+ index = { head: '', entries: {} };
18
+ backend;
19
+ /**
20
+ *
21
+ */
22
+ constructor(options) {
23
+ this.storageDir = options?.storageDir;
24
+ if (options?.backend)
25
+ this.backend = options.backend;
26
+ else
27
+ this.backend = new persistence_1.NodeFsStorage(this.storageDir || '.apigit_workspace');
28
+ }
29
+ /**
30
+ * コンテンツから SHA1 を計算します。
31
+ * @param {string} content コンテンツ
32
+ * @returns {string} 計算された SHA
33
+ */
34
+ shaOf(content) {
35
+ return crypto_1.default.createHash('sha1').update(content, 'utf8').digest('hex');
36
+ }
37
+ /**
38
+ * VirtualFS の初期化を行います(バックエンド初期化と index 読み込み)。
39
+ * @returns {Promise<void>}
40
+ */
41
+ async init() {
42
+ await this.backend.init();
43
+ await this.loadIndex();
44
+ }
45
+ /**
46
+ * 永続化レイヤーから index を読み込み、内部マップを初期化します。
47
+ * @returns {Promise<void>}
48
+ */
49
+ async loadIndex() {
50
+ try {
51
+ const raw = await this.backend.readIndex();
52
+ if (raw)
53
+ this.index = raw;
54
+ // Populate internal maps lightly (only shas known)
55
+ for (const [p, e] of Object.entries(this.index.entries)) {
56
+ if (e.baseSha) {
57
+ this.base.set(p, { sha: e.baseSha, content: '' });
58
+ }
59
+ if (e.workspaceSha) {
60
+ this.workspace.set(p, { sha: e.workspaceSha, content: '' });
61
+ }
62
+ }
63
+ }
64
+ catch (err) {
65
+ this.index = { head: '', entries: {} };
66
+ await this.saveIndex();
67
+ }
68
+ }
69
+ /**
70
+ * 内部インデックスを永続化します。
71
+ * @returns {Promise<void>}
72
+ */
73
+ async saveIndex() {
74
+ await this.backend.writeIndex(this.index);
75
+ }
76
+ /**
77
+ *
78
+ */
79
+ /**
80
+ * ワークスペースにファイルを書き込みます(ローカル編集)。
81
+ * @param {string} filepath ファイルパス
82
+ * @param {string} content コンテンツ
83
+ * @returns {Promise<void>}
84
+ */
85
+ async writeWorkspace(filepath, content) {
86
+ const sha = this.shaOf(content);
87
+ this.workspace.set(filepath, { sha, content });
88
+ const now = Date.now();
89
+ const existing = this.index.entries[filepath];
90
+ const state = existing && existing.baseSha ? 'modified' : 'added';
91
+ this.index.entries[filepath] = {
92
+ path: filepath,
93
+ state: state,
94
+ baseSha: existing?.baseSha,
95
+ workspaceSha: sha,
96
+ updatedAt: now,
97
+ };
98
+ // persist workspace blob (optional)
99
+ await this.backend.writeBlob(filepath, content);
100
+ await this.saveIndex();
101
+ }
102
+ /**
103
+ * ワークスペース上のファイルを削除します(トゥームストーン作成を含む)。
104
+ * @param {string} filepath ファイルパス
105
+ * @returns {Promise<void>}
106
+ */
107
+ async deleteWorkspace(filepath) {
108
+ // if file existed in base, create tombstone
109
+ const entry = this.index.entries[filepath];
110
+ const now = Date.now();
111
+ if (entry && entry.baseSha) {
112
+ this.tombstones.set(filepath, { path: filepath, baseSha: entry.baseSha, deletedAt: now });
113
+ this.index.entries[filepath] = {
114
+ path: filepath,
115
+ state: 'deleted',
116
+ baseSha: entry.baseSha,
117
+ updatedAt: now,
118
+ };
119
+ }
120
+ else {
121
+ // created in workspace and deleted before push
122
+ delete this.index.entries[filepath];
123
+ this.workspace.delete(filepath);
124
+ await this.backend.deleteBlob(filepath);
125
+ }
126
+ await this.saveIndex();
127
+ }
128
+ /**
129
+ * rename を delete + create の合成で行うヘルパ
130
+ * @param from 元パス
131
+ * @param to 新パス
132
+ */
133
+ async renameWorkspace(from, to) {
134
+ // read content from workspace if present, otherwise from base
135
+ const w = this.workspace.get(from);
136
+ const content = w ? w.content : (this.base.get(from)?.content ?? null);
137
+ if (content === null)
138
+ throw new Error('source not found');
139
+ // create new workspace entry
140
+ await this.writeWorkspace(to, content);
141
+ // delete original path (creates tombstone if base existed)
142
+ await this.deleteWorkspace(from);
143
+ }
144
+ /**
145
+ * ワークスペース/ベースからファイル内容を読み出します。
146
+ * @param {string} filepath ファイルパス
147
+ * @returns {Promise<string|null>} ファイル内容または null
148
+ */
149
+ async readWorkspace(filepath) {
150
+ const w = this.workspace.get(filepath);
151
+ if (w)
152
+ return w.content;
153
+ // try backend blob
154
+ const blob = await this.backend.readBlob(filepath);
155
+ if (blob !== null)
156
+ return blob;
157
+ const b = this.base.get(filepath);
158
+ if (b && b.content)
159
+ return b.content;
160
+ return null;
161
+ }
162
+ /**
163
+ * リモートのベーススナップショットを適用します。
164
+ * @param {{[path:string]:string}} snapshot path->content のマップ
165
+ * @param {string} headSha リモート HEAD
166
+ * @returns {Promise<void>}
167
+ */
168
+ async applyBaseSnapshot(snapshot, headSha) {
169
+ // snapshot: path -> content
170
+ this.base.clear();
171
+ for (const [p, c] of Object.entries(snapshot)) {
172
+ this.base.set(p, { sha: this.shaOf(c), content: c });
173
+ // persist base blob
174
+ await this.backend.writeBlob(p, c);
175
+ }
176
+ // update index entries for files not touched in workspace
177
+ for (const [p, be] of this.base.entries()) {
178
+ const existing = this.index.entries[p];
179
+ if (!existing) {
180
+ this.index.entries[p] = {
181
+ path: p,
182
+ state: 'base',
183
+ baseSha: be.sha,
184
+ updatedAt: Date.now(),
185
+ };
186
+ }
187
+ else if (existing.state === 'base') {
188
+ existing.baseSha = be.sha;
189
+ existing.updatedAt = Date.now();
190
+ }
191
+ }
192
+ this.index.head = headSha;
193
+ await this.saveIndex();
194
+ }
195
+ /**
196
+ * インデックス情報を返します。
197
+ * @returns {IndexFile}
198
+ */
199
+ getIndex() {
200
+ return this.index;
201
+ }
202
+ /**
203
+ * 登録されているパス一覧を返します。
204
+ * @returns {string[]}
205
+ */
206
+ listPaths() {
207
+ return Object.keys(this.index.entries);
208
+ }
209
+ /**
210
+ * tombstone を返します。
211
+ * @returns {TombstoneEntry[]}
212
+ */
213
+ getTombstones() {
214
+ return Array.from(this.tombstones.values());
215
+ }
216
+ /**
217
+ * tombstone を返します。
218
+ * @returns {TombstoneEntry[]}
219
+ */
220
+ /**
221
+ *
222
+ */
223
+ async getChangeSet() {
224
+ const changes = [];
225
+ changes.push(...this._changesFromTombstones());
226
+ changes.push(...this._changesFromIndexEntries());
227
+ return changes;
228
+ }
229
+ /**
230
+ * tombstone からの削除変更リストを生成します。
231
+ * @returns {Array<{type:'delete',path:string,baseSha:string}>}
232
+ */
233
+ _changesFromTombstones() {
234
+ const out = [];
235
+ for (const t of this.tombstones.values())
236
+ out.push({ type: 'delete', path: t.path, baseSha: t.baseSha });
237
+ return out;
238
+ }
239
+ /**
240
+ * index entries から create/update の変更リストを生成します。
241
+ * @returns {Array<{type:'create'|'update',path:string,content:string,baseSha?:string}>}
242
+ */
243
+ _changesFromIndexEntries() {
244
+ const out = [];
245
+ out.push(...this._changesFromAddedEntries());
246
+ out.push(...this._changesFromModifiedEntries());
247
+ return out;
248
+ }
249
+ /**
250
+ * 追加状態のエントリから create 変更を生成します。
251
+ * @returns {Array<{type:'create',path:string,content:string}>}
252
+ */
253
+ _changesFromAddedEntries() {
254
+ const out = [];
255
+ for (const [p, e] of Object.entries(this.index.entries)) {
256
+ if (e.state === 'added') {
257
+ const w = this.workspace.get(p);
258
+ if (w)
259
+ out.push({ type: 'create', path: p, content: w.content });
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+ /**
265
+ * 変更状態のエントリから update 変更を生成します。
266
+ * @returns {Array<{type:'update',path:string,content:string,baseSha:string}>}
267
+ */
268
+ _changesFromModifiedEntries() {
269
+ const out = [];
270
+ for (const [p, e] of Object.entries(this.index.entries)) {
271
+ if (e.state === 'modified') {
272
+ const w = this.workspace.get(p);
273
+ if (w && e.baseSha)
274
+ out.push({ type: 'update', path: p, content: w.content, baseSha: e.baseSha });
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+ /**
280
+ * リモートスナップショットからの差分取り込み時に、単一パスを評価して
281
+ * 必要なら conflicts に追加、もしくは base を更新します。
282
+ * @returns {Promise<void>}
283
+ */
284
+ async _handleRemotePath(p, remoteSha, baseSnapshot, conflicts) {
285
+ const idxEntry = this.index.entries[p];
286
+ const localWorkspace = this.workspace.get(p);
287
+ const localBase = this.base.get(p);
288
+ if (!idxEntry)
289
+ return await this._handleRemoteNew(p, remoteSha, baseSnapshot, conflicts, localWorkspace, localBase);
290
+ return await this._handleRemoteExisting(p, idxEntry, remoteSha, baseSnapshot, conflicts, localWorkspace);
291
+ }
292
+ /**
293
+ * リモートに存在するがローカルにないパスを処理します。
294
+ * @returns {Promise<void>}
295
+ */
296
+ async _handleRemoteNew(p, remoteSha, baseSnapshot, conflicts, localWorkspace, localBase) {
297
+ if (localWorkspace) {
298
+ // workspace has uncommitted changes -> conflict
299
+ conflicts.push({ path: p, remoteSha, workspaceSha: localWorkspace.sha, baseSha: localBase?.sha });
300
+ }
301
+ else {
302
+ // safe to add to base
303
+ const content = baseSnapshot[p];
304
+ this.base.set(p, { sha: remoteSha, content });
305
+ this.index.entries[p] = { path: p, state: 'base', baseSha: remoteSha, updatedAt: Date.now() };
306
+ await this.backend.writeBlob(p, content);
307
+ }
308
+ }
309
+ /**
310
+ * リモートに存在し、かつローカルにエントリがあるパスを処理します。
311
+ * @returns {Promise<void>}
312
+ */
313
+ async _handleRemoteExisting(p, idxEntry, remoteSha, baseSnapshot, conflicts, localWorkspace) {
314
+ const baseSha = idxEntry.baseSha;
315
+ if (baseSha === remoteSha)
316
+ return;
317
+ // remote changed
318
+ if (!localWorkspace || localWorkspace.sha === baseSha) {
319
+ // workspace unchanged -> update base
320
+ const content = baseSnapshot[p];
321
+ this.base.set(p, { sha: remoteSha, content });
322
+ idxEntry.baseSha = remoteSha;
323
+ idxEntry.state = 'base';
324
+ idxEntry.updatedAt = Date.now();
325
+ await this.backend.writeBlob(p, content);
326
+ }
327
+ else {
328
+ // workspace modified -> conflict
329
+ conflicts.push({ path: p, baseSha, remoteSha, workspaceSha: localWorkspace?.sha });
330
+ }
331
+ }
332
+ /**
333
+ * ローカルに対する変更(create/update/delete)を適用するヘルパー
334
+ * @param {any} ch 変更オブジェクト
335
+ * @returns {Promise<void>}
336
+ */
337
+ async _applyChangeLocally(ch) {
338
+ if (ch.type === 'create' || ch.type === 'update') {
339
+ const sha = this.shaOf(ch.content);
340
+ this.base.set(ch.path, { sha, content: ch.content });
341
+ const entry = this.index.entries[ch.path] || { path: ch.path };
342
+ entry.baseSha = sha;
343
+ entry.state = 'base';
344
+ entry.updatedAt = Date.now();
345
+ entry.workspaceSha = undefined;
346
+ this.index.entries[ch.path] = entry;
347
+ await this.backend.writeBlob(ch.path, ch.content);
348
+ this.workspace.delete(ch.path);
349
+ }
350
+ else if (ch.type === 'delete') {
351
+ delete this.index.entries[ch.path];
352
+ this.base.delete(ch.path);
353
+ this.tombstones.delete(ch.path);
354
+ await this.backend.deleteBlob(ch.path);
355
+ this.workspace.delete(ch.path);
356
+ }
357
+ }
358
+ /**
359
+ * リモート側で削除されたエントリをローカルに反映します。
360
+ * @returns {Promise<void>}
361
+ */
362
+ async _handleRemoteDeletion(p, e, _remoteShas, conflicts) {
363
+ const localWorkspace = this.workspace.get(p);
364
+ if (!localWorkspace || localWorkspace.sha === e.baseSha) {
365
+ // safe to delete locally
366
+ delete this.index.entries[p];
367
+ this.base.delete(p);
368
+ await this.backend.deleteBlob(p);
369
+ }
370
+ else {
371
+ conflicts.push({ path: p, baseSha: e.baseSha, workspaceSha: localWorkspace?.sha });
372
+ }
373
+ }
374
+ /**
375
+ * GitLab 風の actions ベースコミットフローで push を実行します。
376
+ * @returns {Promise<{commitSha:string}>}
377
+ */
378
+ async _pushWithActions(adapter, input, branch) {
379
+ const commitSha = await adapter.createCommitWithActions(branch, input.message, input.changes);
380
+ try {
381
+ await adapter.updateRef(`heads/${branch}`, commitSha);
382
+ }
383
+ catch (e) {
384
+ // ignore; adapter may not support updateRef
385
+ }
386
+ for (const ch of input.changes) {
387
+ await this._applyChangeLocally(ch);
388
+ }
389
+ this.index.head = commitSha;
390
+ await this.saveIndex();
391
+ return { commitSha };
392
+ }
393
+ /**
394
+ * GitHub 風の blob/tree/commit フローで push を実行します。
395
+ * @returns {Promise<{commitSha:string}>}
396
+ */
397
+ async _pushWithGitHubFlow(adapter, input, branch) {
398
+ const blobMap = await adapter.createBlobs(input.changes);
399
+ const changesWithBlob = input.changes.map((c) => ({ ...c, blobSha: blobMap[c.path] }));
400
+ const treeSha = await adapter.createTree(changesWithBlob);
401
+ const commitSha = await adapter.createCommit(input.message, input.parentSha, treeSha);
402
+ try {
403
+ await adapter.updateRef(`heads/${branch}`, commitSha);
404
+ }
405
+ catch (e) {
406
+ // ignore; adapter may not support updateRef
407
+ }
408
+ for (const ch of input.changes) {
409
+ await this._applyChangeLocally(ch);
410
+ }
411
+ this.index.head = commitSha;
412
+ await this.saveIndex();
413
+ return { commitSha };
414
+ }
415
+ /**
416
+ * リモートのスナップショットを取り込み、コンフリクト情報を返します。
417
+ * @param {string} remoteHead リモート HEAD
418
+ * @param {{[path:string]:string}} baseSnapshot path->content マップ
419
+ * @returns {Promise<{conflicts:Array<import('./types').ConflictEntry>}>}
420
+ */
421
+ async pull(remoteHead, baseSnapshot) {
422
+ const conflicts = [];
423
+ // compute remote shas
424
+ const remoteShas = {};
425
+ for (const [p, c] of Object.entries(baseSnapshot)) {
426
+ remoteShas[p] = this.shaOf(c);
427
+ }
428
+ // handle remote additions/updates via helper
429
+ for (const [p, remoteSha] of Object.entries(remoteShas)) {
430
+ await this._handleRemotePath(p, remoteSha, baseSnapshot, conflicts);
431
+ }
432
+ // handle remote deletions via helper
433
+ for (const [p, e] of Object.entries(this.index.entries)) {
434
+ if (!(p in remoteShas)) {
435
+ await this._handleRemoteDeletion(p, e, remoteShas, conflicts);
436
+ }
437
+ }
438
+ if (conflicts.length === 0) {
439
+ this.index.head = remoteHead;
440
+ await this.saveIndex();
441
+ }
442
+ return { conflicts };
443
+ }
444
+ /**
445
+ * 変更をコミットしてリモートへ反映します。adapter が無ければローカルシミュレーションします。
446
+ * @param {import('./types').CommitInput} input コミット入力
447
+ * @param {import('../git/adapter').GitAdapter} [adapter] 任意のアダプタ
448
+ * @returns {Promise<{commitSha:string}>}
449
+ */
450
+ async push(input, adapter) {
451
+ // pre-check
452
+ if (input.parentSha !== this.index.head) {
453
+ throw new Error('HEAD changed. pull required');
454
+ }
455
+ // generate commitKey for idempotency if not provided
456
+ if (!input.commitKey) {
457
+ // commitKey = hash(parentSha + JSON.stringify(changes))
458
+ input.commitKey = this.shaOf(input.parentSha + JSON.stringify(input.changes));
459
+ }
460
+ // ensure changes are present
461
+ if (!input.changes || input.changes.length === 0)
462
+ throw new Error('No changes to commit');
463
+ // If adapter provided, perform remote API reflect
464
+ if (adapter) {
465
+ const branch = input.ref || 'main';
466
+ const messageWithKey = `${input.message}\n\napigit-commit-key:${input.commitKey}`;
467
+ // If adapter supports createCommitWithActions (GitLab style), use it directly
468
+ if (adapter.createCommitWithActions) {
469
+ // ensure message contains commitKey
470
+ ;
471
+ input.message = messageWithKey;
472
+ const res = await this._pushWithActions(adapter, input, branch);
473
+ // record commitKey in index metadata
474
+ this.index.lastCommitKey = input.commitKey;
475
+ return res;
476
+ }
477
+ // Fallback to GitHub-style flow: delegate to helper
478
+ ;
479
+ input.message = messageWithKey;
480
+ const res = await this._pushWithGitHubFlow(adapter, input, branch);
481
+ this.index.lastCommitKey = input.commitKey;
482
+ return res;
483
+ }
484
+ // fallback: simulate commit locally
485
+ const commitSha = this.shaOf(input.parentSha + '|' + input.commitKey);
486
+ for (const ch of input.changes) {
487
+ await this._applyChangeLocally(ch);
488
+ }
489
+ this.index.head = commitSha;
490
+ this.index.lastCommitKey = input.commitKey;
491
+ await this.saveIndex();
492
+ return { commitSha };
493
+ }
494
+ }
495
+ exports.VirtualFS = VirtualFS;
496
+ exports.default = VirtualFS;
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "browser-git-ops",
3
+ "version": "0.0.0",
4
+ "type": "commonjs",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "description": "A browser-native Git operations library built with OPFS and Web APIs.",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/nojaja/APIGitWorkspace.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/nojaja/APIGitWorkspace/issues"
14
+ },
15
+ "homepage": "https://github.com/nojaja/APIGitWorkspace#readme",
16
+ "keywords": ["virtualfs", "git", "github", "vfs", "apigit"],
17
+ "author": "nojaja <free.riccia@gmail.com> (https://github.com/nojaja)",
18
+ "license": "MIT",
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --runInBand",
26
+ "pretest:e2e": "npm run build",
27
+ "test:e2e": "playwright test --config=playwright.config.cjs",
28
+ "test:ci": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --coverage --runInBand",
29
+ "lint": "eslint \"src/**/*.{ts,js}\" --config .eslintrc.cjs",
30
+ "depcruise": "depcruise --config .dependency-cruiser.js src || exit 0",
31
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.e2e.json"
32
+ },
33
+ "devDependencies": {
34
+ "@playwright/test": "^1.40.0",
35
+ "@types/jest": "^29.5.2",
36
+ "@typescript-eslint/eslint-plugin": "^6.10.0",
37
+ "@typescript-eslint/parser": "^6.10.0",
38
+ "dependency-cruiser": "^17.3.6",
39
+ "eslint": "^8.45.0",
40
+ "eslint-plugin-jsdoc": "^62.1.0",
41
+ "eslint-plugin-sonarjs": "^0.16.0",
42
+ "jest": "^29.6.1",
43
+ "ts-jest": "^29.1.0",
44
+ "typedoc": "^0.25.0",
45
+ "typescript": "5.3.3"
46
+ }
47
+ }