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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/git/adapter.d.ts +16 -0
- package/dist/git/adapter.d.ts.map +1 -0
- package/dist/git/adapter.js +2 -0
- package/dist/git/githubAdapter.d.ts +50 -0
- package/dist/git/githubAdapter.d.ts.map +1 -0
- package/dist/git/githubAdapter.js +179 -0
- package/dist/git/gitlabAdapter.d.ts +91 -0
- package/dist/git/gitlabAdapter.d.ts.map +1 -0
- package/dist/git/gitlabAdapter.js +182 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/test/e2e/github.spec.d.ts +2 -0
- package/dist/test/e2e/github.spec.d.ts.map +1 -0
- package/dist/test/e2e/github.spec.js +47 -0
- package/dist/test/e2e/gitlab.spec.d.ts +2 -0
- package/dist/test/e2e/gitlab.spec.d.ts.map +1 -0
- package/dist/test/e2e/gitlab.spec.js +34 -0
- package/dist/test/e2e/virtualfs.spec.d.ts +2 -0
- package/dist/test/e2e/virtualfs.spec.d.ts.map +1 -0
- package/dist/test/e2e/virtualfs.spec.js +409 -0
- package/dist/virtualfs/persistence.d.ts +149 -0
- package/dist/virtualfs/persistence.d.ts.map +1 -0
- package/dist/virtualfs/persistence.js +294 -0
- package/dist/virtualfs/types.d.ts +46 -0
- package/dist/virtualfs/types.d.ts.map +1 -0
- package/dist/virtualfs/types.js +2 -0
- package/dist/virtualfs/virtualfs.d.ts +189 -0
- package/dist/virtualfs/virtualfs.d.ts.map +1 -0
- package/dist/virtualfs/virtualfs.js +496 -0
- package/package.json +47 -0
|
@@ -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
|
+
}
|