@gitgov/core 2.1.2 → 2.2.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/README.md +53 -6
- package/dist/src/{agent_runner-ByOUWOt6.d.ts → agent_runner-Cs5HXt4h.d.ts} +2 -1
- package/dist/src/fs.d.ts +11 -20
- package/dist/src/fs.js +9 -0
- package/dist/src/fs.js.map +1 -1
- package/dist/src/github.d.ts +472 -0
- package/dist/src/github.js +1281 -0
- package/dist/src/github.js.map +1 -0
- package/dist/src/{index--ahcnsG3.d.ts → index-D1RVufxB.d.ts} +38 -234
- package/dist/src/index.d.ts +10 -7
- package/dist/src/index.js +9 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/key_provider-CRpHFGjN.d.ts +227 -0
- package/dist/src/memory.d.ts +10 -4
- package/dist/src/memory.js +9 -1
- package/dist/src/memory.js.map +1 -1
- package/dist/src/{memory_file_lister-BkQ_C3ZU.d.ts → memory_file_lister-CfHtByeZ.d.ts} +2 -1
- package/package.json +6 -1
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
import picomatch from 'picomatch';
|
|
2
|
+
|
|
3
|
+
// src/file_lister/github/github_file_lister.ts
|
|
4
|
+
|
|
5
|
+
// src/file_lister/file_lister.errors.ts
|
|
6
|
+
var FileListerError = class extends Error {
|
|
7
|
+
constructor(message, code, filePath) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.filePath = filePath;
|
|
11
|
+
this.name = "FileListerError";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/file_lister/github/github_file_lister.ts
|
|
16
|
+
var GitHubFileLister = class {
|
|
17
|
+
owner;
|
|
18
|
+
repo;
|
|
19
|
+
ref;
|
|
20
|
+
basePath;
|
|
21
|
+
octokit;
|
|
22
|
+
/** Cached tree entries from the Trees API */
|
|
23
|
+
treeCache = null;
|
|
24
|
+
constructor(options, octokit) {
|
|
25
|
+
this.owner = options.owner;
|
|
26
|
+
this.repo = options.repo;
|
|
27
|
+
this.ref = options.ref ?? "gitgov-state";
|
|
28
|
+
this.basePath = options.basePath ?? "";
|
|
29
|
+
this.octokit = octokit;
|
|
30
|
+
}
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
32
|
+
// FileLister Interface
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
34
|
+
/**
|
|
35
|
+
* [EARS-A1] Lists files matching glob patterns.
|
|
36
|
+
* [EARS-B1] Uses Trees API with recursive=1 and picomatch filter.
|
|
37
|
+
* [EARS-B3] Applies basePath prefix for tree entries, strips from results.
|
|
38
|
+
* [EARS-B6] Caches tree between list() calls.
|
|
39
|
+
*/
|
|
40
|
+
async list(patterns, options) {
|
|
41
|
+
const entries = await this.fetchTree();
|
|
42
|
+
const blobs = entries.filter((entry) => entry.type === "blob");
|
|
43
|
+
const prefix = this.basePath ? `${this.basePath}/` : "";
|
|
44
|
+
const relativePaths = [];
|
|
45
|
+
for (const blob of blobs) {
|
|
46
|
+
if (!blob.path) continue;
|
|
47
|
+
if (prefix && !blob.path.startsWith(prefix)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const relativePath = prefix ? blob.path.slice(prefix.length) : blob.path;
|
|
51
|
+
relativePaths.push(relativePath);
|
|
52
|
+
}
|
|
53
|
+
const isMatch = picomatch(patterns, {
|
|
54
|
+
ignore: options?.ignore
|
|
55
|
+
});
|
|
56
|
+
return relativePaths.filter((p) => isMatch(p)).sort();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* [EARS-A2] Checks if a file exists via Contents API.
|
|
60
|
+
* [EARS-B4] Returns false for 404 responses.
|
|
61
|
+
*/
|
|
62
|
+
async exists(filePath) {
|
|
63
|
+
const fullPath = this.buildFullPath(filePath);
|
|
64
|
+
try {
|
|
65
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
66
|
+
owner: this.owner,
|
|
67
|
+
repo: this.repo,
|
|
68
|
+
path: fullPath,
|
|
69
|
+
ref: this.ref
|
|
70
|
+
});
|
|
71
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (isOctokitRequestError(error)) {
|
|
77
|
+
if (error.status === 404) return false;
|
|
78
|
+
if (error.status === 401 || error.status === 403) {
|
|
79
|
+
throw new FileListerError(
|
|
80
|
+
`Permission denied: ${filePath}`,
|
|
81
|
+
"PERMISSION_DENIED",
|
|
82
|
+
filePath
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (error.status >= 500) {
|
|
86
|
+
throw new FileListerError(
|
|
87
|
+
`GitHub API server error (${error.status}): ${filePath}`,
|
|
88
|
+
"READ_ERROR",
|
|
89
|
+
filePath
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
throw new FileListerError(
|
|
93
|
+
`Unexpected GitHub API response (${error.status}): ${filePath}`,
|
|
94
|
+
"READ_ERROR",
|
|
95
|
+
filePath
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
throw new FileListerError(
|
|
99
|
+
`Network error checking file: ${filePath}`,
|
|
100
|
+
"NETWORK_ERROR",
|
|
101
|
+
filePath
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* [EARS-A3] Reads file content as string.
|
|
107
|
+
* [EARS-B2] Decodes base64 content from Contents API.
|
|
108
|
+
* [EARS-B7] Falls back to Blobs API for files >1MB (null content).
|
|
109
|
+
*/
|
|
110
|
+
async read(filePath) {
|
|
111
|
+
const fullPath = this.buildFullPath(filePath);
|
|
112
|
+
try {
|
|
113
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
114
|
+
owner: this.owner,
|
|
115
|
+
repo: this.repo,
|
|
116
|
+
path: fullPath,
|
|
117
|
+
ref: this.ref
|
|
118
|
+
});
|
|
119
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
120
|
+
throw new FileListerError(
|
|
121
|
+
`Not a file: ${filePath}`,
|
|
122
|
+
"READ_ERROR",
|
|
123
|
+
filePath
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (data.content !== null && data.content !== void 0) {
|
|
127
|
+
return Buffer.from(data.content, "base64").toString("utf-8");
|
|
128
|
+
}
|
|
129
|
+
return this.readViaBlobs(data.sha, filePath);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error instanceof FileListerError) throw error;
|
|
132
|
+
if (isOctokitRequestError(error)) {
|
|
133
|
+
if (error.status === 404) {
|
|
134
|
+
throw new FileListerError(
|
|
135
|
+
`File not found: ${filePath}`,
|
|
136
|
+
"FILE_NOT_FOUND",
|
|
137
|
+
filePath
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (error.status === 401 || error.status === 403) {
|
|
141
|
+
throw new FileListerError(
|
|
142
|
+
`Permission denied: ${filePath}`,
|
|
143
|
+
"PERMISSION_DENIED",
|
|
144
|
+
filePath
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (error.status >= 500) {
|
|
148
|
+
throw new FileListerError(
|
|
149
|
+
`GitHub API server error (${error.status}): ${filePath}`,
|
|
150
|
+
"READ_ERROR",
|
|
151
|
+
filePath
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
throw new FileListerError(
|
|
155
|
+
`Unexpected GitHub API response (${error.status}): ${filePath}`,
|
|
156
|
+
"READ_ERROR",
|
|
157
|
+
filePath
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
throw new FileListerError(
|
|
161
|
+
`Network error reading file: ${filePath}`,
|
|
162
|
+
"NETWORK_ERROR",
|
|
163
|
+
filePath
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* [EARS-A4] Gets file statistics via Contents API.
|
|
169
|
+
* Returns size from API, mtime as 0 (not available via Contents API), isFile as true.
|
|
170
|
+
*/
|
|
171
|
+
async stat(filePath) {
|
|
172
|
+
const fullPath = this.buildFullPath(filePath);
|
|
173
|
+
try {
|
|
174
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
175
|
+
owner: this.owner,
|
|
176
|
+
repo: this.repo,
|
|
177
|
+
path: fullPath,
|
|
178
|
+
ref: this.ref
|
|
179
|
+
});
|
|
180
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
181
|
+
throw new FileListerError(
|
|
182
|
+
`Not a file: ${filePath}`,
|
|
183
|
+
"READ_ERROR",
|
|
184
|
+
filePath
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
size: data.size,
|
|
189
|
+
mtime: 0,
|
|
190
|
+
isFile: true
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof FileListerError) throw error;
|
|
194
|
+
if (isOctokitRequestError(error)) {
|
|
195
|
+
if (error.status === 404) {
|
|
196
|
+
throw new FileListerError(
|
|
197
|
+
`File not found: ${filePath}`,
|
|
198
|
+
"FILE_NOT_FOUND",
|
|
199
|
+
filePath
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (error.status === 401 || error.status === 403) {
|
|
203
|
+
throw new FileListerError(
|
|
204
|
+
`Permission denied: ${filePath}`,
|
|
205
|
+
"PERMISSION_DENIED",
|
|
206
|
+
filePath
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (error.status >= 500) {
|
|
210
|
+
throw new FileListerError(
|
|
211
|
+
`GitHub API server error (${error.status}): ${filePath}`,
|
|
212
|
+
"READ_ERROR",
|
|
213
|
+
filePath
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
throw new FileListerError(
|
|
217
|
+
`Unexpected GitHub API response (${error.status}): ${filePath}`,
|
|
218
|
+
"READ_ERROR",
|
|
219
|
+
filePath
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
throw new FileListerError(
|
|
223
|
+
`Network error getting file stats: ${filePath}`,
|
|
224
|
+
"NETWORK_ERROR",
|
|
225
|
+
filePath
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
230
|
+
// Private Helpers
|
|
231
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
232
|
+
/**
|
|
233
|
+
* Builds the full file path including basePath prefix.
|
|
234
|
+
*/
|
|
235
|
+
buildFullPath(filePath) {
|
|
236
|
+
if (this.basePath) {
|
|
237
|
+
return `${this.basePath}/${filePath}`;
|
|
238
|
+
}
|
|
239
|
+
return filePath;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* [EARS-B6] Fetches and caches the full repository tree.
|
|
243
|
+
* [EARS-C3] Throws READ_ERROR if the tree response is truncated.
|
|
244
|
+
*/
|
|
245
|
+
async fetchTree() {
|
|
246
|
+
if (this.treeCache !== null) {
|
|
247
|
+
return this.treeCache;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const { data } = await this.octokit.rest.git.getTree({
|
|
251
|
+
owner: this.owner,
|
|
252
|
+
repo: this.repo,
|
|
253
|
+
tree_sha: this.ref,
|
|
254
|
+
recursive: "1"
|
|
255
|
+
});
|
|
256
|
+
if (data.truncated) {
|
|
257
|
+
throw new FileListerError(
|
|
258
|
+
"Repository tree is truncated; too many files to list via Trees API",
|
|
259
|
+
"READ_ERROR"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
this.treeCache = data.tree;
|
|
263
|
+
return this.treeCache;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error instanceof FileListerError) throw error;
|
|
266
|
+
if (isOctokitRequestError(error)) {
|
|
267
|
+
if (error.status === 404) {
|
|
268
|
+
throw new FileListerError(
|
|
269
|
+
"Repository or ref not found",
|
|
270
|
+
"FILE_NOT_FOUND"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (error.status === 401 || error.status === 403) {
|
|
274
|
+
throw new FileListerError(
|
|
275
|
+
"Permission denied accessing repository tree",
|
|
276
|
+
"PERMISSION_DENIED"
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (error.status >= 500) {
|
|
280
|
+
throw new FileListerError(
|
|
281
|
+
`GitHub API server error (${error.status}) fetching tree`,
|
|
282
|
+
"READ_ERROR"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
throw new FileListerError(
|
|
286
|
+
`Unexpected GitHub API response (${error.status}) fetching tree`,
|
|
287
|
+
"READ_ERROR"
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
throw new FileListerError(
|
|
291
|
+
"Network error fetching repository tree",
|
|
292
|
+
"NETWORK_ERROR"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* [EARS-B7] Reads file content via the Blobs API (fallback for >1MB files).
|
|
298
|
+
*/
|
|
299
|
+
async readViaBlobs(sha, filePath) {
|
|
300
|
+
try {
|
|
301
|
+
const { data } = await this.octokit.rest.git.getBlob({
|
|
302
|
+
owner: this.owner,
|
|
303
|
+
repo: this.repo,
|
|
304
|
+
file_sha: sha
|
|
305
|
+
});
|
|
306
|
+
return Buffer.from(data.content, "base64").toString("utf-8");
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (isOctokitRequestError(error)) {
|
|
309
|
+
if (error.status === 404) {
|
|
310
|
+
throw new FileListerError(
|
|
311
|
+
`File not found: ${filePath}`,
|
|
312
|
+
"FILE_NOT_FOUND",
|
|
313
|
+
filePath
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (error.status === 401 || error.status === 403) {
|
|
317
|
+
throw new FileListerError(
|
|
318
|
+
`Permission denied: ${filePath}`,
|
|
319
|
+
"PERMISSION_DENIED",
|
|
320
|
+
filePath
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
throw new FileListerError(
|
|
324
|
+
`GitHub API error (${error.status}): ${filePath}`,
|
|
325
|
+
"READ_ERROR",
|
|
326
|
+
filePath
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
throw new FileListerError(
|
|
330
|
+
`Network error reading blob for file: ${filePath}`,
|
|
331
|
+
"NETWORK_ERROR",
|
|
332
|
+
filePath
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// src/record_store/github/github_record_store.ts
|
|
339
|
+
var GitHubRecordStore = class {
|
|
340
|
+
owner;
|
|
341
|
+
repo;
|
|
342
|
+
ref;
|
|
343
|
+
basePath;
|
|
344
|
+
extension;
|
|
345
|
+
idEncoder;
|
|
346
|
+
octokit;
|
|
347
|
+
/** SHA cache keyed by full file path (basePath/encoded + extension) */
|
|
348
|
+
shaCache = /* @__PURE__ */ new Map();
|
|
349
|
+
/** IGitModule dependency for putMany() atomic commits. Optional — only needed for putMany(). */
|
|
350
|
+
gitModule;
|
|
351
|
+
constructor(options, octokit, gitModule) {
|
|
352
|
+
this.owner = options.owner;
|
|
353
|
+
this.repo = options.repo;
|
|
354
|
+
this.ref = options.ref ?? "gitgov-state";
|
|
355
|
+
this.basePath = options.basePath;
|
|
356
|
+
this.extension = options.extension ?? ".json";
|
|
357
|
+
this.idEncoder = options.idEncoder;
|
|
358
|
+
this.octokit = octokit;
|
|
359
|
+
this.gitModule = gitModule;
|
|
360
|
+
}
|
|
361
|
+
async get(id) {
|
|
362
|
+
this.validateId(id);
|
|
363
|
+
const filePath = this.buildFilePath(id);
|
|
364
|
+
try {
|
|
365
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
366
|
+
owner: this.owner,
|
|
367
|
+
repo: this.repo,
|
|
368
|
+
path: filePath,
|
|
369
|
+
ref: this.ref
|
|
370
|
+
});
|
|
371
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
372
|
+
throw new GitHubApiError(`Not a file: ${filePath}`, "INVALID_RESPONSE");
|
|
373
|
+
}
|
|
374
|
+
if (data.content === null || data.content === void 0) {
|
|
375
|
+
throw new GitHubApiError(
|
|
376
|
+
`File content is null (file may exceed 1MB): ${filePath}`,
|
|
377
|
+
"INVALID_RESPONSE"
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
this.shaCache.set(filePath, data.sha);
|
|
381
|
+
const decoded = Buffer.from(data.content, "base64").toString("utf-8");
|
|
382
|
+
return JSON.parse(decoded);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error instanceof GitHubApiError) throw error;
|
|
385
|
+
if (isOctokitRequestError(error) && error.status === 404) return null;
|
|
386
|
+
throw mapOctokitError(error, `GET ${filePath}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async put(id, value, opts) {
|
|
390
|
+
this.validateId(id);
|
|
391
|
+
const filePath = this.buildFilePath(id);
|
|
392
|
+
const content = Buffer.from(JSON.stringify(value, null, 2)).toString("base64");
|
|
393
|
+
const cachedSha = this.shaCache.get(filePath);
|
|
394
|
+
try {
|
|
395
|
+
const { data } = await this.octokit.rest.repos.createOrUpdateFileContents({
|
|
396
|
+
owner: this.owner,
|
|
397
|
+
repo: this.repo,
|
|
398
|
+
path: filePath,
|
|
399
|
+
message: opts?.commitMessage ?? `put ${id}`,
|
|
400
|
+
content,
|
|
401
|
+
branch: this.ref,
|
|
402
|
+
...cachedSha ? { sha: cachedSha } : {}
|
|
403
|
+
});
|
|
404
|
+
if (data.content?.sha) {
|
|
405
|
+
this.shaCache.set(filePath, data.content.sha);
|
|
406
|
+
}
|
|
407
|
+
return { commitSha: data.commit.sha };
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw mapOctokitError(error, `PUT ${filePath}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* [EARS-A11, EARS-A12, EARS-B8] Persists multiple records in a single atomic commit.
|
|
414
|
+
* Uses GitHubGitModule staging buffer: add() with contentMap, then commit().
|
|
415
|
+
* Empty entries array returns { commitSha: undefined } without API calls.
|
|
416
|
+
* Requires gitModule dependency — throws if not injected.
|
|
417
|
+
*/
|
|
418
|
+
async putMany(entries, opts) {
|
|
419
|
+
if (entries.length === 0) {
|
|
420
|
+
return {};
|
|
421
|
+
}
|
|
422
|
+
if (!this.gitModule) {
|
|
423
|
+
throw new Error("putMany requires IGitModule dependency for atomic commits");
|
|
424
|
+
}
|
|
425
|
+
for (const { id } of entries) {
|
|
426
|
+
this.validateId(id);
|
|
427
|
+
}
|
|
428
|
+
const contentMap = {};
|
|
429
|
+
const filePaths = [];
|
|
430
|
+
for (const { id, value } of entries) {
|
|
431
|
+
const filePath = this.buildFilePath(id);
|
|
432
|
+
contentMap[filePath] = JSON.stringify(value, null, 2);
|
|
433
|
+
filePaths.push(filePath);
|
|
434
|
+
}
|
|
435
|
+
await this.gitModule.add(filePaths, { contentMap });
|
|
436
|
+
const message = opts?.commitMessage ?? `putMany ${entries.length} records`;
|
|
437
|
+
const commitSha = await this.gitModule.commit(message);
|
|
438
|
+
return { commitSha };
|
|
439
|
+
}
|
|
440
|
+
async delete(id, opts) {
|
|
441
|
+
this.validateId(id);
|
|
442
|
+
const filePath = this.buildFilePath(id);
|
|
443
|
+
let sha = this.shaCache.get(filePath);
|
|
444
|
+
if (sha === void 0) {
|
|
445
|
+
try {
|
|
446
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
447
|
+
owner: this.owner,
|
|
448
|
+
repo: this.repo,
|
|
449
|
+
path: filePath,
|
|
450
|
+
ref: this.ref
|
|
451
|
+
});
|
|
452
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
453
|
+
return {};
|
|
454
|
+
}
|
|
455
|
+
sha = data.sha;
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (isOctokitRequestError(error) && error.status === 404) {
|
|
458
|
+
return {};
|
|
459
|
+
}
|
|
460
|
+
throw mapOctokitError(error, `GET ${filePath} (for delete)`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const { data } = await this.octokit.rest.repos.deleteFile({
|
|
465
|
+
owner: this.owner,
|
|
466
|
+
repo: this.repo,
|
|
467
|
+
path: filePath,
|
|
468
|
+
message: opts?.commitMessage ?? `delete ${id}`,
|
|
469
|
+
sha,
|
|
470
|
+
branch: this.ref
|
|
471
|
+
});
|
|
472
|
+
this.shaCache.delete(filePath);
|
|
473
|
+
return { commitSha: data.commit.sha };
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (isOctokitRequestError(error) && error.status === 404) {
|
|
476
|
+
this.shaCache.delete(filePath);
|
|
477
|
+
return {};
|
|
478
|
+
}
|
|
479
|
+
throw mapOctokitError(error, `DELETE ${filePath}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async list() {
|
|
483
|
+
try {
|
|
484
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
485
|
+
owner: this.owner,
|
|
486
|
+
repo: this.repo,
|
|
487
|
+
path: this.basePath,
|
|
488
|
+
ref: this.ref
|
|
489
|
+
});
|
|
490
|
+
if (!Array.isArray(data)) {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
const ids = data.filter((entry) => entry.name.endsWith(this.extension)).map((entry) => entry.name.slice(0, -this.extension.length));
|
|
494
|
+
return this.idEncoder ? ids.map((encoded) => this.idEncoder.decode(encoded)) : ids;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if (isOctokitRequestError(error) && error.status === 404) {
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
throw mapOctokitError(error, `GET ${this.basePath} (list)`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async exists(id) {
|
|
503
|
+
this.validateId(id);
|
|
504
|
+
const filePath = this.buildFilePath(id);
|
|
505
|
+
try {
|
|
506
|
+
await this.octokit.rest.repos.getContent({
|
|
507
|
+
owner: this.owner,
|
|
508
|
+
repo: this.repo,
|
|
509
|
+
path: filePath,
|
|
510
|
+
ref: this.ref
|
|
511
|
+
});
|
|
512
|
+
return true;
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (isOctokitRequestError(error) && error.status === 404) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
throw mapOctokitError(error, `GET ${filePath} (exists)`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// ─────────────────────────────────────────────────────────
|
|
521
|
+
// Private helpers
|
|
522
|
+
// ─────────────────────────────────────────────────────────
|
|
523
|
+
validateId(id) {
|
|
524
|
+
if (!id || typeof id !== "string") {
|
|
525
|
+
throw new GitHubApiError("ID must be a non-empty string", "INVALID_ID");
|
|
526
|
+
}
|
|
527
|
+
if (id.includes("..") || /[\/\\]/.test(id)) {
|
|
528
|
+
throw new GitHubApiError(
|
|
529
|
+
`Invalid ID: "${id}". IDs cannot contain /, \\, or ..`,
|
|
530
|
+
"INVALID_ID"
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
buildFilePath(id) {
|
|
535
|
+
const encoded = this.idEncoder ? this.idEncoder.encode(id) : id;
|
|
536
|
+
return `${this.basePath}/${encoded}${this.extension}`;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// src/git/errors.ts
|
|
541
|
+
var GitError = class _GitError extends Error {
|
|
542
|
+
constructor(message) {
|
|
543
|
+
super(message);
|
|
544
|
+
this.name = "GitError";
|
|
545
|
+
Object.setPrototypeOf(this, _GitError.prototype);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
var BranchNotFoundError = class _BranchNotFoundError extends GitError {
|
|
549
|
+
branchName;
|
|
550
|
+
constructor(branchName) {
|
|
551
|
+
super(`Branch not found: ${branchName}`);
|
|
552
|
+
this.name = "BranchNotFoundError";
|
|
553
|
+
this.branchName = branchName;
|
|
554
|
+
Object.setPrototypeOf(this, _BranchNotFoundError.prototype);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
var FileNotFoundError = class _FileNotFoundError extends GitError {
|
|
558
|
+
filePath;
|
|
559
|
+
commitHash;
|
|
560
|
+
constructor(filePath, commitHash) {
|
|
561
|
+
super(`File not found: ${filePath} in commit ${commitHash}`);
|
|
562
|
+
this.name = "FileNotFoundError";
|
|
563
|
+
this.filePath = filePath;
|
|
564
|
+
this.commitHash = commitHash;
|
|
565
|
+
Object.setPrototypeOf(this, _FileNotFoundError.prototype);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
var BranchAlreadyExistsError = class _BranchAlreadyExistsError extends GitError {
|
|
569
|
+
branchName;
|
|
570
|
+
constructor(branchName) {
|
|
571
|
+
super(`Branch already exists: ${branchName}`);
|
|
572
|
+
this.name = "BranchAlreadyExistsError";
|
|
573
|
+
this.branchName = branchName;
|
|
574
|
+
Object.setPrototypeOf(this, _BranchAlreadyExistsError.prototype);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// src/git/github/github_git_module.ts
|
|
579
|
+
var GitHubGitModule = class {
|
|
580
|
+
owner;
|
|
581
|
+
repo;
|
|
582
|
+
defaultBranch;
|
|
583
|
+
octokit;
|
|
584
|
+
/** Staging buffer: path → content (null = delete) */
|
|
585
|
+
stagingBuffer = /* @__PURE__ */ new Map();
|
|
586
|
+
/** Active ref for operations (can be changed via checkoutBranch) */
|
|
587
|
+
activeRef;
|
|
588
|
+
constructor(options, octokit) {
|
|
589
|
+
this.owner = options.owner;
|
|
590
|
+
this.repo = options.repo;
|
|
591
|
+
this.defaultBranch = options.defaultBranch ?? "gitgov-state";
|
|
592
|
+
this.octokit = octokit;
|
|
593
|
+
this.activeRef = this.defaultBranch;
|
|
594
|
+
}
|
|
595
|
+
// ═══════════════════════════════════════════════════════════════
|
|
596
|
+
// PRIVATE HELPERS
|
|
597
|
+
// ═══════════════════════════════════════════════════════════════
|
|
598
|
+
/** Category C: Not supported via GitHub API */
|
|
599
|
+
notSupported(method) {
|
|
600
|
+
throw new GitError(
|
|
601
|
+
`${method} is not supported via GitHub API`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
// ═══════════════════════════════════════════════════════════════
|
|
605
|
+
// CATEGORY A: READ OPERATIONS (EARS-A1 to A6)
|
|
606
|
+
// ═══════════════════════════════════════════════════════════════
|
|
607
|
+
/**
|
|
608
|
+
* [EARS-A1] Read file content via Contents API + base64 decode
|
|
609
|
+
* [EARS-A2] Fallback to Blobs API for files >1MB
|
|
610
|
+
*/
|
|
611
|
+
async getFileContent(commitHash, filePath) {
|
|
612
|
+
try {
|
|
613
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
614
|
+
owner: this.owner,
|
|
615
|
+
repo: this.repo,
|
|
616
|
+
path: filePath,
|
|
617
|
+
ref: commitHash
|
|
618
|
+
});
|
|
619
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
620
|
+
throw new GitError(`Not a file: ${filePath}`);
|
|
621
|
+
}
|
|
622
|
+
if (data.content !== null && data.content !== void 0) {
|
|
623
|
+
return Buffer.from(data.content, "base64").toString("utf-8");
|
|
624
|
+
}
|
|
625
|
+
const { data: blobData } = await this.octokit.rest.git.getBlob({
|
|
626
|
+
owner: this.owner,
|
|
627
|
+
repo: this.repo,
|
|
628
|
+
file_sha: data.sha
|
|
629
|
+
});
|
|
630
|
+
return Buffer.from(blobData.content, "base64").toString("utf-8");
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (error instanceof GitError) throw error;
|
|
633
|
+
if (isOctokitRequestError(error)) {
|
|
634
|
+
if (error.status === 404) {
|
|
635
|
+
throw new FileNotFoundError(filePath, commitHash);
|
|
636
|
+
}
|
|
637
|
+
if (error.status === 401 || error.status === 403) {
|
|
638
|
+
throw new GitError(`authentication/permission error (${error.status}): getFileContent ${filePath}`);
|
|
639
|
+
}
|
|
640
|
+
if (error.status >= 500) {
|
|
641
|
+
throw new GitError(`GitHub server error (${error.status}): getFileContent ${filePath}`);
|
|
642
|
+
}
|
|
643
|
+
throw new GitError(`GitHub API error (${error.status}): getFileContent ${filePath}`);
|
|
644
|
+
}
|
|
645
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
646
|
+
throw new GitError(`network error: ${msg}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* [EARS-A3] Get commit SHA from branch via Refs API
|
|
651
|
+
* [EARS-B4] Return SHA directly if already a 40-char hex
|
|
652
|
+
*/
|
|
653
|
+
async getCommitHash(ref = this.activeRef) {
|
|
654
|
+
if (/^[0-9a-f]{40}$/i.test(ref)) {
|
|
655
|
+
return ref;
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
const { data } = await this.octokit.rest.git.getRef({
|
|
659
|
+
owner: this.owner,
|
|
660
|
+
repo: this.repo,
|
|
661
|
+
ref: `heads/${ref}`
|
|
662
|
+
});
|
|
663
|
+
return data.object.sha;
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (isOctokitRequestError(error)) {
|
|
666
|
+
if (error.status === 404) {
|
|
667
|
+
throw new BranchNotFoundError(ref);
|
|
668
|
+
}
|
|
669
|
+
if (error.status === 401 || error.status === 403) {
|
|
670
|
+
throw new GitError(`authentication/permission error (${error.status}): getCommitHash ${ref}`);
|
|
671
|
+
}
|
|
672
|
+
if (error.status >= 500) {
|
|
673
|
+
throw new GitError(`GitHub server error (${error.status}): getCommitHash ${ref}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
677
|
+
throw new GitError(`network error: ${msg}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* [EARS-A4] List changed files via Compare API
|
|
682
|
+
*/
|
|
683
|
+
async getChangedFiles(fromCommit, toCommit, pathFilter) {
|
|
684
|
+
try {
|
|
685
|
+
const { data } = await this.octokit.rest.repos.compareCommits({
|
|
686
|
+
owner: this.owner,
|
|
687
|
+
repo: this.repo,
|
|
688
|
+
base: fromCommit,
|
|
689
|
+
head: toCommit
|
|
690
|
+
});
|
|
691
|
+
const statusMap = {
|
|
692
|
+
added: "A",
|
|
693
|
+
modified: "M",
|
|
694
|
+
removed: "D",
|
|
695
|
+
renamed: "M"
|
|
696
|
+
};
|
|
697
|
+
const files = (data.files ?? []).map((f) => ({
|
|
698
|
+
status: statusMap[f.status] ?? "M",
|
|
699
|
+
file: f.filename
|
|
700
|
+
})).filter((f) => !pathFilter || f.file.startsWith(pathFilter));
|
|
701
|
+
return files;
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (isOctokitRequestError(error)) {
|
|
704
|
+
if (error.status === 401 || error.status === 403) {
|
|
705
|
+
throw new GitError(`authentication/permission error (${error.status}): getChangedFiles ${fromCommit}...${toCommit}`);
|
|
706
|
+
}
|
|
707
|
+
if (error.status >= 500) {
|
|
708
|
+
throw new GitError(`GitHub server error (${error.status}): getChangedFiles`);
|
|
709
|
+
}
|
|
710
|
+
throw new GitError(`Failed to compare ${fromCommit}...${toCommit}: HTTP ${error.status}`);
|
|
711
|
+
}
|
|
712
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
713
|
+
throw new GitError(`network error: ${msg}`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* [EARS-A5] Get commit history via Commits API
|
|
718
|
+
*/
|
|
719
|
+
async getCommitHistory(branch, options) {
|
|
720
|
+
try {
|
|
721
|
+
const { data } = await this.octokit.rest.repos.listCommits({
|
|
722
|
+
owner: this.owner,
|
|
723
|
+
repo: this.repo,
|
|
724
|
+
sha: branch,
|
|
725
|
+
...options?.maxCount !== void 0 && { per_page: options.maxCount },
|
|
726
|
+
...options?.pathFilter !== void 0 && { path: options.pathFilter }
|
|
727
|
+
});
|
|
728
|
+
return data.map((c) => ({
|
|
729
|
+
hash: c.sha,
|
|
730
|
+
message: c.commit.message,
|
|
731
|
+
author: `${c.commit.author?.name ?? "unknown"} <${c.commit.author?.email ?? "unknown"}>`,
|
|
732
|
+
date: c.commit.author?.date ?? ""
|
|
733
|
+
}));
|
|
734
|
+
} catch (error) {
|
|
735
|
+
if (isOctokitRequestError(error)) {
|
|
736
|
+
if (error.status === 401 || error.status === 403) {
|
|
737
|
+
throw new GitError(`authentication/permission error (${error.status}): getCommitHistory ${branch}`);
|
|
738
|
+
}
|
|
739
|
+
if (error.status >= 500) {
|
|
740
|
+
throw new GitError(`GitHub server error (${error.status}): getCommitHistory`);
|
|
741
|
+
}
|
|
742
|
+
throw new GitError(`Failed to get commit history: HTTP ${error.status}`);
|
|
743
|
+
}
|
|
744
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
745
|
+
throw new GitError(`network error: ${msg}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* [EARS-B3] Get commit history between two commits via Compare API
|
|
750
|
+
*/
|
|
751
|
+
async getCommitHistoryRange(fromHash, toHash, options) {
|
|
752
|
+
try {
|
|
753
|
+
const { data } = await this.octokit.rest.repos.compareCommits({
|
|
754
|
+
owner: this.owner,
|
|
755
|
+
repo: this.repo,
|
|
756
|
+
base: fromHash,
|
|
757
|
+
head: toHash
|
|
758
|
+
});
|
|
759
|
+
let commits = data.commits.map((c) => ({
|
|
760
|
+
hash: c.sha,
|
|
761
|
+
message: c.commit.message,
|
|
762
|
+
author: `${c.commit.author?.name ?? "unknown"} <${c.commit.author?.email ?? "unknown"}>`,
|
|
763
|
+
date: c.commit.author?.date ?? ""
|
|
764
|
+
}));
|
|
765
|
+
if (options?.pathFilter) {
|
|
766
|
+
const changedPaths = new Set((data.files ?? []).map((f) => f.filename));
|
|
767
|
+
commits = commits.filter(
|
|
768
|
+
() => Array.from(changedPaths).some((f) => f.startsWith(options.pathFilter))
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
if (options?.maxCount) {
|
|
772
|
+
commits = commits.slice(0, options.maxCount);
|
|
773
|
+
}
|
|
774
|
+
return commits;
|
|
775
|
+
} catch (error) {
|
|
776
|
+
if (isOctokitRequestError(error)) {
|
|
777
|
+
if (error.status === 401 || error.status === 403) {
|
|
778
|
+
throw new GitError(`authentication/permission error (${error.status}): getCommitHistoryRange ${fromHash}...${toHash}`);
|
|
779
|
+
}
|
|
780
|
+
if (error.status >= 500) {
|
|
781
|
+
throw new GitError(`GitHub server error (${error.status}): getCommitHistoryRange`);
|
|
782
|
+
}
|
|
783
|
+
throw new GitError(`Failed to get commit range: HTTP ${error.status}`);
|
|
784
|
+
}
|
|
785
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
786
|
+
throw new GitError(`network error: ${msg}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* [EARS-A6] Get commit message via Commits API
|
|
791
|
+
*/
|
|
792
|
+
async getCommitMessage(commitHash) {
|
|
793
|
+
try {
|
|
794
|
+
const { data } = await this.octokit.rest.repos.getCommit({
|
|
795
|
+
owner: this.owner,
|
|
796
|
+
repo: this.repo,
|
|
797
|
+
ref: commitHash
|
|
798
|
+
});
|
|
799
|
+
return data.commit.message;
|
|
800
|
+
} catch (error) {
|
|
801
|
+
if (isOctokitRequestError(error)) {
|
|
802
|
+
if (error.status === 404) {
|
|
803
|
+
throw new GitError(`Commit not found: ${commitHash}`);
|
|
804
|
+
}
|
|
805
|
+
if (error.status === 401 || error.status === 403) {
|
|
806
|
+
throw new GitError(`authentication/permission error (${error.status}): getCommitMessage ${commitHash}`);
|
|
807
|
+
}
|
|
808
|
+
if (error.status >= 500) {
|
|
809
|
+
throw new GitError(`GitHub server error (${error.status}): getCommitMessage`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
813
|
+
throw new GitError(`network error: ${msg}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// ═══════════════════════════════════════════════════════════════
|
|
817
|
+
// CATEGORY A: BRANCH OPERATIONS (EARS-B1 to B2)
|
|
818
|
+
// ═══════════════════════════════════════════════════════════════
|
|
819
|
+
/**
|
|
820
|
+
* [EARS-B1] Check if branch exists via Branches API
|
|
821
|
+
*/
|
|
822
|
+
async branchExists(branchName) {
|
|
823
|
+
try {
|
|
824
|
+
await this.octokit.rest.repos.getBranch({
|
|
825
|
+
owner: this.owner,
|
|
826
|
+
repo: this.repo,
|
|
827
|
+
branch: branchName
|
|
828
|
+
});
|
|
829
|
+
return true;
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (isOctokitRequestError(error)) {
|
|
832
|
+
if (error.status === 404) return false;
|
|
833
|
+
if (error.status === 401 || error.status === 403) {
|
|
834
|
+
throw new GitError(`authentication/permission error (${error.status}): branchExists ${branchName}`);
|
|
835
|
+
}
|
|
836
|
+
throw new GitError(`Failed to check branch: HTTP ${error.status}`);
|
|
837
|
+
}
|
|
838
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
839
|
+
throw new GitError(`network error: ${msg}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* [EARS-B2] List remote branches via Branches API
|
|
844
|
+
* remoteName is ignored — repo itself is the implicit remote
|
|
845
|
+
*/
|
|
846
|
+
async listRemoteBranches(_remoteName) {
|
|
847
|
+
try {
|
|
848
|
+
const { data } = await this.octokit.rest.repos.listBranches({
|
|
849
|
+
owner: this.owner,
|
|
850
|
+
repo: this.repo
|
|
851
|
+
});
|
|
852
|
+
return data.map((b) => b.name);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (isOctokitRequestError(error)) {
|
|
855
|
+
if (error.status === 401 || error.status === 403) {
|
|
856
|
+
throw new GitError(`authentication/permission error (${error.status}): listRemoteBranches`);
|
|
857
|
+
}
|
|
858
|
+
if (error.status >= 500) {
|
|
859
|
+
throw new GitError(`GitHub server error (${error.status}): listRemoteBranches`);
|
|
860
|
+
}
|
|
861
|
+
throw new GitError(`Failed to list branches: HTTP ${error.status}`);
|
|
862
|
+
}
|
|
863
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
864
|
+
throw new GitError(`network error: ${msg}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// ═══════════════════════════════════════════════════════════════
|
|
868
|
+
// CATEGORY A: WRITE OPERATIONS (EARS-C1 to C7)
|
|
869
|
+
// ═══════════════════════════════════════════════════════════════
|
|
870
|
+
/** [EARS-C1] Read file content and store in staging buffer */
|
|
871
|
+
async add(filePaths, options) {
|
|
872
|
+
for (const filePath of filePaths) {
|
|
873
|
+
const content = options?.contentMap?.[filePath] ?? await this.getFileContent(this.activeRef, filePath);
|
|
874
|
+
this.stagingBuffer.set(filePath, content);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/** [EARS-C2] Mark files as deleted in staging buffer */
|
|
878
|
+
async rm(filePaths) {
|
|
879
|
+
for (const filePath of filePaths) {
|
|
880
|
+
this.stagingBuffer.set(filePath, null);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** [EARS-C7] Return staged file paths from buffer */
|
|
884
|
+
async getStagedFiles() {
|
|
885
|
+
return Array.from(this.stagingBuffer.keys());
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* [EARS-C6] Create branch via Refs API POST
|
|
889
|
+
*/
|
|
890
|
+
async createBranch(branchName, startPoint) {
|
|
891
|
+
const sha = startPoint ? await this.getCommitHash(startPoint) : await this.getCommitHash(this.activeRef);
|
|
892
|
+
try {
|
|
893
|
+
await this.octokit.rest.git.createRef({
|
|
894
|
+
owner: this.owner,
|
|
895
|
+
repo: this.repo,
|
|
896
|
+
ref: `refs/heads/${branchName}`,
|
|
897
|
+
sha
|
|
898
|
+
});
|
|
899
|
+
} catch (error) {
|
|
900
|
+
if (isOctokitRequestError(error)) {
|
|
901
|
+
if (error.status === 422) {
|
|
902
|
+
throw new BranchAlreadyExistsError(branchName);
|
|
903
|
+
}
|
|
904
|
+
if (error.status === 401 || error.status === 403) {
|
|
905
|
+
throw new GitError(`authentication/permission error (${error.status}): createBranch ${branchName}`);
|
|
906
|
+
}
|
|
907
|
+
throw new GitError(`Failed to create branch ${branchName}: HTTP ${error.status}`);
|
|
908
|
+
}
|
|
909
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
910
|
+
throw new GitError(`network error: ${msg}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Internal commit implementation shared by commit() and commitAllowEmpty().
|
|
915
|
+
*
|
|
916
|
+
* [EARS-C3] 6-step atomic transaction
|
|
917
|
+
* [EARS-C4] Clears staging buffer after successful commit
|
|
918
|
+
* [EARS-C5] Throws if staging buffer is empty (unless allowEmpty)
|
|
919
|
+
*/
|
|
920
|
+
async commitInternal(message, author, allowEmpty = false) {
|
|
921
|
+
if (!allowEmpty && this.stagingBuffer.size === 0) {
|
|
922
|
+
throw new GitError("Nothing to commit: staging buffer is empty");
|
|
923
|
+
}
|
|
924
|
+
try {
|
|
925
|
+
const { data: refData } = await this.octokit.rest.git.getRef({
|
|
926
|
+
owner: this.owner,
|
|
927
|
+
repo: this.repo,
|
|
928
|
+
ref: `heads/${this.activeRef}`
|
|
929
|
+
});
|
|
930
|
+
const currentSha = refData.object.sha;
|
|
931
|
+
const { data: commitData } = await this.octokit.rest.git.getCommit({
|
|
932
|
+
owner: this.owner,
|
|
933
|
+
repo: this.repo,
|
|
934
|
+
commit_sha: currentSha
|
|
935
|
+
});
|
|
936
|
+
const treeSha = commitData.tree.sha;
|
|
937
|
+
const treeEntries = [];
|
|
938
|
+
for (const [path, content] of this.stagingBuffer) {
|
|
939
|
+
if (content === null) {
|
|
940
|
+
treeEntries.push({
|
|
941
|
+
path,
|
|
942
|
+
mode: "100644",
|
|
943
|
+
type: "blob",
|
|
944
|
+
sha: null
|
|
945
|
+
});
|
|
946
|
+
} else {
|
|
947
|
+
const { data: blobData } = await this.octokit.rest.git.createBlob({
|
|
948
|
+
owner: this.owner,
|
|
949
|
+
repo: this.repo,
|
|
950
|
+
content: Buffer.from(content).toString("base64"),
|
|
951
|
+
encoding: "base64"
|
|
952
|
+
});
|
|
953
|
+
treeEntries.push({
|
|
954
|
+
path,
|
|
955
|
+
mode: "100644",
|
|
956
|
+
type: "blob",
|
|
957
|
+
sha: blobData.sha
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
const { data: treeData } = await this.octokit.rest.git.createTree({
|
|
962
|
+
owner: this.owner,
|
|
963
|
+
repo: this.repo,
|
|
964
|
+
base_tree: treeSha,
|
|
965
|
+
tree: treeEntries
|
|
966
|
+
});
|
|
967
|
+
const newTreeSha = treeData.sha;
|
|
968
|
+
const commitParams = {
|
|
969
|
+
owner: this.owner,
|
|
970
|
+
repo: this.repo,
|
|
971
|
+
message,
|
|
972
|
+
tree: newTreeSha,
|
|
973
|
+
parents: [currentSha]
|
|
974
|
+
};
|
|
975
|
+
if (author) {
|
|
976
|
+
commitParams.author = {
|
|
977
|
+
name: author.name,
|
|
978
|
+
email: author.email,
|
|
979
|
+
date: (/* @__PURE__ */ new Date()).toISOString()
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const { data: newCommitData } = await this.octokit.rest.git.createCommit(commitParams);
|
|
983
|
+
const newCommitSha = newCommitData.sha;
|
|
984
|
+
try {
|
|
985
|
+
await this.octokit.rest.git.updateRef({
|
|
986
|
+
owner: this.owner,
|
|
987
|
+
repo: this.repo,
|
|
988
|
+
ref: `heads/${this.activeRef}`,
|
|
989
|
+
sha: newCommitSha
|
|
990
|
+
});
|
|
991
|
+
} catch (error) {
|
|
992
|
+
if (isOctokitRequestError(error) && error.status === 422) {
|
|
993
|
+
throw new GitError("non-fast-forward update rejected");
|
|
994
|
+
}
|
|
995
|
+
throw error;
|
|
996
|
+
}
|
|
997
|
+
this.stagingBuffer.clear();
|
|
998
|
+
return newCommitSha;
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
if (error instanceof GitError) throw error;
|
|
1001
|
+
if (isOctokitRequestError(error)) {
|
|
1002
|
+
if (error.status === 401 || error.status === 403) {
|
|
1003
|
+
throw new GitError(`authentication/permission error (${error.status}): commit`);
|
|
1004
|
+
}
|
|
1005
|
+
if (error.status >= 500) {
|
|
1006
|
+
throw new GitError(`GitHub server error (${error.status}): commit`);
|
|
1007
|
+
}
|
|
1008
|
+
throw new GitError(`GitHub API error (${error.status}): commit`);
|
|
1009
|
+
}
|
|
1010
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1011
|
+
throw new GitError(`network error: ${msg}`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* [EARS-C3] Commit staged changes via 6-step atomic transaction
|
|
1016
|
+
* [EARS-C5] Throws if staging buffer is empty
|
|
1017
|
+
*/
|
|
1018
|
+
async commit(message, author) {
|
|
1019
|
+
return this.commitInternal(message, author, false);
|
|
1020
|
+
}
|
|
1021
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1022
|
+
// CATEGORY B: NO-OPS (sensible defaults)
|
|
1023
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1024
|
+
/** [EARS-D5] exec not supported in API mode */
|
|
1025
|
+
async exec(_command, _args, _options) {
|
|
1026
|
+
return { exitCode: 1, stdout: "", stderr: "exec() not supported in GitHub API mode" };
|
|
1027
|
+
}
|
|
1028
|
+
/** No-op: repos are created via GitHub API, not initialized locally */
|
|
1029
|
+
async init() {
|
|
1030
|
+
}
|
|
1031
|
+
/** [EARS-D1] Return virtual path representing the repo */
|
|
1032
|
+
async getRepoRoot() {
|
|
1033
|
+
return `github://${this.owner}/${this.repo}`;
|
|
1034
|
+
}
|
|
1035
|
+
/** [EARS-D1] Return active ref (starts as defaultBranch) */
|
|
1036
|
+
async getCurrentBranch() {
|
|
1037
|
+
return this.activeRef;
|
|
1038
|
+
}
|
|
1039
|
+
/** No-op: git config doesn't apply to GitHub API */
|
|
1040
|
+
async setConfig(_key, _value, _scope) {
|
|
1041
|
+
}
|
|
1042
|
+
/** [EARS-D1] Return true if staging buffer has entries */
|
|
1043
|
+
async hasUncommittedChanges(_pathFilter) {
|
|
1044
|
+
return this.stagingBuffer.size > 0;
|
|
1045
|
+
}
|
|
1046
|
+
/** No-op: GitHub API doesn't have rebase-in-progress concept */
|
|
1047
|
+
async isRebaseInProgress() {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
/** [EARS-D1] GitHub repos always have 'origin' conceptually */
|
|
1051
|
+
async isRemoteConfigured(_remoteName) {
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
/** No-op: always 'origin' */
|
|
1055
|
+
async getBranchRemote(_branchName) {
|
|
1056
|
+
return "origin";
|
|
1057
|
+
}
|
|
1058
|
+
/** No-op: GitHub API handles merges atomically */
|
|
1059
|
+
async getConflictedFiles() {
|
|
1060
|
+
return [];
|
|
1061
|
+
}
|
|
1062
|
+
/** [EARS-D2] Update activeRef for subsequent operations */
|
|
1063
|
+
async checkoutBranch(branchName) {
|
|
1064
|
+
this.activeRef = branchName;
|
|
1065
|
+
}
|
|
1066
|
+
/** No-op: GitHub API doesn't have stash concept */
|
|
1067
|
+
async stash(_message) {
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
/** No-op */
|
|
1071
|
+
async stashPop() {
|
|
1072
|
+
return false;
|
|
1073
|
+
}
|
|
1074
|
+
/** No-op */
|
|
1075
|
+
async stashDrop(_stashHash) {
|
|
1076
|
+
}
|
|
1077
|
+
/** No-op: API always fresh */
|
|
1078
|
+
async fetch(_remote) {
|
|
1079
|
+
}
|
|
1080
|
+
/** No-op: API mode */
|
|
1081
|
+
async pull(_remote, _branchName) {
|
|
1082
|
+
}
|
|
1083
|
+
/** No-op: API mode */
|
|
1084
|
+
async pullRebase(_remote, _branchName) {
|
|
1085
|
+
}
|
|
1086
|
+
/** [EARS-D4] No-op: commits via API are already remote */
|
|
1087
|
+
async push(_remote, _branchName) {
|
|
1088
|
+
}
|
|
1089
|
+
/** [EARS-D4] No-op: commits via API are already remote */
|
|
1090
|
+
async pushWithUpstream(_remote, _branchName) {
|
|
1091
|
+
}
|
|
1092
|
+
/** No-op: API mode */
|
|
1093
|
+
async setUpstream(_branchName, _remote, _remoteBranch) {
|
|
1094
|
+
}
|
|
1095
|
+
/** No-op */
|
|
1096
|
+
async rebaseAbort() {
|
|
1097
|
+
}
|
|
1098
|
+
/** [EARS-D1] Delegates to commitInternal, allowing empty staging buffer */
|
|
1099
|
+
async commitAllowEmpty(message, author) {
|
|
1100
|
+
return this.commitInternal(message, author, true);
|
|
1101
|
+
}
|
|
1102
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1103
|
+
// CATEGORY C: NOT SUPPORTED (throw GitError)
|
|
1104
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1105
|
+
/** [EARS-D3] Not supported via GitHub API */
|
|
1106
|
+
async rebase(_targetBranch) {
|
|
1107
|
+
this.notSupported("rebase");
|
|
1108
|
+
}
|
|
1109
|
+
/** [EARS-D3] Not supported via GitHub API */
|
|
1110
|
+
async rebaseContinue() {
|
|
1111
|
+
this.notSupported("rebaseContinue");
|
|
1112
|
+
}
|
|
1113
|
+
/** [EARS-D3] Not supported via GitHub API */
|
|
1114
|
+
async resetHard(_target) {
|
|
1115
|
+
this.notSupported("resetHard");
|
|
1116
|
+
}
|
|
1117
|
+
/** [EARS-D3] Not supported via GitHub API */
|
|
1118
|
+
async checkoutOrphanBranch(_branchName) {
|
|
1119
|
+
this.notSupported("checkoutOrphanBranch");
|
|
1120
|
+
}
|
|
1121
|
+
/** [EARS-D3] Not supported via GitHub API */
|
|
1122
|
+
async checkoutFilesFromBranch(_sourceBranch, _filePaths) {
|
|
1123
|
+
this.notSupported("checkoutFilesFromBranch");
|
|
1124
|
+
}
|
|
1125
|
+
/** [EARS-D3] Not supported via GitHub API */
|
|
1126
|
+
async getMergeBase(_branchA, _branchB) {
|
|
1127
|
+
this.notSupported("getMergeBase");
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
// src/config_store/github/github_config_store.ts
|
|
1132
|
+
var GitHubConfigStore = class {
|
|
1133
|
+
owner;
|
|
1134
|
+
repo;
|
|
1135
|
+
ref;
|
|
1136
|
+
basePath;
|
|
1137
|
+
octokit;
|
|
1138
|
+
/** Cached blob SHA from the last loadConfig call, used for PUT updates */
|
|
1139
|
+
cachedSha = null;
|
|
1140
|
+
constructor(options, octokit) {
|
|
1141
|
+
this.owner = options.owner;
|
|
1142
|
+
this.repo = options.repo;
|
|
1143
|
+
this.ref = options.ref ?? "gitgov-state";
|
|
1144
|
+
this.basePath = options.basePath ?? ".gitgov";
|
|
1145
|
+
this.octokit = octokit;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Load project configuration from GitHub Contents API.
|
|
1149
|
+
*
|
|
1150
|
+
* [EARS-A1] Returns GitGovConfig when valid JSON is found.
|
|
1151
|
+
* [EARS-A2] Returns null on 404 (fail-safe).
|
|
1152
|
+
* [EARS-A3] Returns null on invalid JSON (fail-safe).
|
|
1153
|
+
* [EARS-B1] Fetches via Contents API with base64 decode.
|
|
1154
|
+
* [EARS-B2] Caches SHA from response for subsequent saveConfig.
|
|
1155
|
+
*/
|
|
1156
|
+
async loadConfig() {
|
|
1157
|
+
const path = `${this.basePath}/config.json`;
|
|
1158
|
+
try {
|
|
1159
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
1160
|
+
owner: this.owner,
|
|
1161
|
+
repo: this.repo,
|
|
1162
|
+
path,
|
|
1163
|
+
ref: this.ref
|
|
1164
|
+
});
|
|
1165
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
if (!data.content) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
this.cachedSha = data.sha;
|
|
1172
|
+
try {
|
|
1173
|
+
const decoded = Buffer.from(data.content, "base64").toString("utf-8");
|
|
1174
|
+
return JSON.parse(decoded);
|
|
1175
|
+
} catch {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
if (isOctokitRequestError(error) && error.status === 404) {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
throw mapOctokitError(error, `loadConfig ${this.owner}/${this.repo}/${path}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Save project configuration to GitHub via Contents API PUT.
|
|
1187
|
+
*
|
|
1188
|
+
* [EARS-A4] Writes config via PUT to Contents API.
|
|
1189
|
+
* [EARS-B3] Includes cached SHA for updates (optimistic concurrency).
|
|
1190
|
+
* [EARS-B4] Omits SHA for initial creation.
|
|
1191
|
+
* [EARS-C1] Throws PERMISSION_DENIED on 401/403.
|
|
1192
|
+
* [EARS-C2] Throws CONFLICT on 409.
|
|
1193
|
+
* [EARS-C3] Throws SERVER_ERROR on 5xx.
|
|
1194
|
+
*/
|
|
1195
|
+
async saveConfig(config) {
|
|
1196
|
+
const path = `${this.basePath}/config.json`;
|
|
1197
|
+
const content = Buffer.from(JSON.stringify(config, null, 2)).toString("base64");
|
|
1198
|
+
try {
|
|
1199
|
+
const { data } = await this.octokit.rest.repos.createOrUpdateFileContents({
|
|
1200
|
+
owner: this.owner,
|
|
1201
|
+
repo: this.repo,
|
|
1202
|
+
path,
|
|
1203
|
+
message: "chore(config): update gitgov config.json",
|
|
1204
|
+
content,
|
|
1205
|
+
branch: this.ref,
|
|
1206
|
+
...this.cachedSha ? { sha: this.cachedSha } : {}
|
|
1207
|
+
});
|
|
1208
|
+
if (data.content?.sha) {
|
|
1209
|
+
this.cachedSha = data.content.sha;
|
|
1210
|
+
}
|
|
1211
|
+
return { commitSha: data.commit.sha };
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
throw mapOctokitError(error, `saveConfig ${this.owner}/${this.repo}/${path}`);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
// src/github.ts
|
|
1219
|
+
var GitHubApiError = class _GitHubApiError extends Error {
|
|
1220
|
+
constructor(message, code, statusCode) {
|
|
1221
|
+
super(message);
|
|
1222
|
+
this.code = code;
|
|
1223
|
+
this.statusCode = statusCode;
|
|
1224
|
+
this.name = "GitHubApiError";
|
|
1225
|
+
Object.setPrototypeOf(this, _GitHubApiError.prototype);
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
function isOctokitRequestError(error) {
|
|
1229
|
+
return error instanceof Error && typeof error["status"] === "number";
|
|
1230
|
+
}
|
|
1231
|
+
function mapOctokitError(error, context) {
|
|
1232
|
+
if (isOctokitRequestError(error)) {
|
|
1233
|
+
const status = error.status;
|
|
1234
|
+
if (status === 401 || status === 403) {
|
|
1235
|
+
return new GitHubApiError(
|
|
1236
|
+
`Permission denied: ${context}`,
|
|
1237
|
+
"PERMISSION_DENIED",
|
|
1238
|
+
status
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
if (status === 404) {
|
|
1242
|
+
return new GitHubApiError(
|
|
1243
|
+
`Not found: ${context}`,
|
|
1244
|
+
"NOT_FOUND",
|
|
1245
|
+
status
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
if (status === 409) {
|
|
1249
|
+
return new GitHubApiError(
|
|
1250
|
+
`Conflict: ${context}`,
|
|
1251
|
+
"CONFLICT",
|
|
1252
|
+
status
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
if (status === 422) {
|
|
1256
|
+
return new GitHubApiError(
|
|
1257
|
+
`Validation failed: ${context}`,
|
|
1258
|
+
"CONFLICT",
|
|
1259
|
+
status
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
if (status >= 500) {
|
|
1263
|
+
return new GitHubApiError(
|
|
1264
|
+
`Server error (${status}): ${context}`,
|
|
1265
|
+
"SERVER_ERROR",
|
|
1266
|
+
status
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
return new GitHubApiError(
|
|
1270
|
+
`GitHub API error (${status}): ${context}`,
|
|
1271
|
+
"SERVER_ERROR",
|
|
1272
|
+
status
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1276
|
+
return new GitHubApiError(`Network error: ${message}`, "NETWORK_ERROR");
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
export { GitHubApiError, GitHubConfigStore, GitHubFileLister, GitHubGitModule, GitHubRecordStore, isOctokitRequestError, mapOctokitError };
|
|
1280
|
+
//# sourceMappingURL=github.js.map
|
|
1281
|
+
//# sourceMappingURL=github.js.map
|