@gitgov/core 2.7.1 → 2.8.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.
@@ -1,1860 +1,1858 @@
1
- import picomatch from 'picomatch';
2
1
  import path from 'path';
2
+ import picomatch from 'picomatch';
3
3
 
4
- // src/file_lister/github/github_file_lister.ts
4
+ // src/sync_state/sync_state.utils.ts
5
5
 
6
- // src/file_lister/file_lister.errors.ts
7
- var FileListerError = class extends Error {
8
- constructor(message, code, filePath) {
9
- super(message);
10
- this.code = code;
11
- this.filePath = filePath;
12
- this.name = "FileListerError";
6
+ // src/sync_state/sync_state.types.ts
7
+ var SYNC_DIRECTORIES = [
8
+ "tasks",
9
+ "cycles",
10
+ "actors",
11
+ "agents",
12
+ "feedbacks",
13
+ "executions",
14
+ "workflows"
15
+ ];
16
+ var SYNC_ROOT_FILES = [
17
+ "config.json"
18
+ ];
19
+ var SYNC_ALLOWED_EXTENSIONS = [".json"];
20
+ var SYNC_EXCLUDED_PATTERNS = [
21
+ /\.key$/,
22
+ // Private keys (e.g., keys/*.key)
23
+ /\.backup$/,
24
+ // Backup files from lint
25
+ /\.backup-\d+$/,
26
+ // Numbered backup files
27
+ /\.tmp$/,
28
+ // Temporary files
29
+ /\.bak$/
30
+ // Backup files
31
+ ];
32
+ var LOCAL_ONLY_FILES = [
33
+ "index.json",
34
+ // Generated index, rebuilt on each machine
35
+ ".session.json",
36
+ // Local session state for current user/agent
37
+ "gitgov"
38
+ // Local binary/script
39
+ ];
40
+
41
+ // src/sync_state/sync_state.utils.ts
42
+ function shouldSyncFile(filePath) {
43
+ const fileName = path.basename(filePath);
44
+ const ext = path.extname(filePath);
45
+ if (!SYNC_ALLOWED_EXTENSIONS.includes(ext)) {
46
+ return false;
13
47
  }
14
- };
48
+ for (const pattern of SYNC_EXCLUDED_PATTERNS) {
49
+ if (pattern.test(fileName)) {
50
+ return false;
51
+ }
52
+ }
53
+ if (LOCAL_ONLY_FILES.includes(fileName)) {
54
+ return false;
55
+ }
56
+ const normalizedPath = filePath.replace(/\\/g, "/");
57
+ const parts = normalizedPath.split("/");
58
+ const gitgovIndex = parts.findIndex((p) => p === ".gitgov");
59
+ let relativeParts;
60
+ if (gitgovIndex !== -1) {
61
+ relativeParts = parts.slice(gitgovIndex + 1);
62
+ } else {
63
+ const syncDirIndex = parts.findIndex(
64
+ (p) => SYNC_DIRECTORIES.includes(p)
65
+ );
66
+ if (syncDirIndex !== -1) {
67
+ relativeParts = parts.slice(syncDirIndex);
68
+ } else if (SYNC_ROOT_FILES.includes(fileName)) {
69
+ return true;
70
+ } else {
71
+ return false;
72
+ }
73
+ }
74
+ if (relativeParts.length === 1) {
75
+ return SYNC_ROOT_FILES.includes(relativeParts[0]);
76
+ } else if (relativeParts.length >= 2) {
77
+ const dirName = relativeParts[0];
78
+ return SYNC_DIRECTORIES.includes(dirName);
79
+ }
80
+ return false;
81
+ }
15
82
 
16
- // src/file_lister/github/github_file_lister.ts
17
- var GitHubFileLister = class {
18
- owner;
19
- repo;
20
- ref;
21
- basePath;
22
- octokit;
23
- /** Cached tree entries from the Trees API */
24
- treeCache = null;
25
- constructor(options, octokit) {
26
- this.owner = options.owner;
27
- this.repo = options.repo;
28
- this.ref = options.ref ?? "gitgov-state";
29
- this.basePath = options.basePath ?? "";
30
- this.octokit = octokit;
83
+ // src/sync_state/github_sync_state/github_sync_state.ts
84
+ var GithubSyncStateModule = class {
85
+ deps;
86
+ lastKnownSha = null;
87
+ constructor(deps) {
88
+ this.deps = deps;
31
89
  }
32
- // ═══════════════════════════════════════════════════════════════════════
33
- // FileLister Interface
34
- // ═══════════════════════════════════════════════════════════════════════
90
+ // ==================== Block A: Branch Management ====================
35
91
  /**
36
- * [EARS-A1] Lists files matching glob patterns.
37
- * [EARS-B1] Uses Trees API with recursive=1 and picomatch filter.
38
- * [EARS-B3] Applies basePath prefix for tree entries, strips from results.
39
- * [EARS-B6] Caches tree between list() calls.
92
+ * [EARS-GS-A3] Returns the configured state branch name.
40
93
  */
41
- async list(patterns, options) {
42
- const entries = await this.fetchTree();
43
- const blobs = entries.filter((entry) => entry.type === "blob");
44
- const prefix = this.basePath ? `${this.basePath}/` : "";
45
- const relativePaths = [];
46
- for (const blob of blobs) {
47
- if (!blob.path) continue;
48
- if (prefix && !blob.path.startsWith(prefix)) {
49
- continue;
50
- }
51
- const relativePath = prefix ? blob.path.slice(prefix.length) : blob.path;
52
- relativePaths.push(relativePath);
53
- }
54
- const isMatch = picomatch(patterns, {
55
- ignore: options?.ignore
56
- });
57
- return relativePaths.filter((p) => isMatch(p)).sort();
94
+ async getStateBranchName() {
95
+ return "gitgov-state";
58
96
  }
59
97
  /**
60
- * [EARS-A2] Checks if a file exists via Contents API.
61
- * [EARS-B4] Returns false for 404 responses.
98
+ * [EARS-GS-A1] Creates gitgov-state branch if it does not exist.
99
+ * [EARS-GS-A2] Idempotent no-op if branch already exists.
62
100
  */
63
- async exists(filePath) {
64
- const fullPath = this.buildFullPath(filePath);
101
+ async ensureStateBranch() {
102
+ const branchName = await this.getStateBranchName();
65
103
  try {
66
- const { data } = await this.octokit.rest.repos.getContent({
67
- owner: this.owner,
68
- repo: this.repo,
69
- path: fullPath,
70
- ref: this.ref
104
+ await this.deps.octokit.rest.repos.getBranch({
105
+ owner: this.deps.owner,
106
+ repo: this.deps.repo,
107
+ branch: branchName
71
108
  });
72
- if (Array.isArray(data) || data.type !== "file") {
73
- return false;
74
- }
75
- return true;
109
+ return;
76
110
  } catch (error) {
77
- if (isOctokitRequestError(error)) {
78
- if (error.status === 404) return false;
79
- if (error.status === 401 || error.status === 403) {
80
- throw new FileListerError(
81
- `Permission denied: ${filePath}`,
82
- "PERMISSION_DENIED",
83
- filePath
84
- );
85
- }
86
- if (error.status >= 500) {
87
- throw new FileListerError(
88
- `GitHub API server error (${error.status}): ${filePath}`,
89
- "READ_ERROR",
90
- filePath
91
- );
92
- }
93
- throw new FileListerError(
94
- `Unexpected GitHub API response (${error.status}): ${filePath}`,
95
- "READ_ERROR",
96
- filePath
97
- );
111
+ if (!isOctokitRequestError(error) || error.status !== 404) {
112
+ throw error;
98
113
  }
99
- throw new FileListerError(
100
- `Network error checking file: ${filePath}`,
101
- "NETWORK_ERROR",
102
- filePath
103
- );
104
114
  }
115
+ const { data: repoData } = await this.deps.octokit.rest.repos.get({
116
+ owner: this.deps.owner,
117
+ repo: this.deps.repo
118
+ });
119
+ const defaultBranch = repoData.default_branch;
120
+ const { data: refData } = await this.deps.octokit.rest.git.getRef({
121
+ owner: this.deps.owner,
122
+ repo: this.deps.repo,
123
+ ref: `heads/${defaultBranch}`
124
+ });
125
+ await this.deps.octokit.rest.git.createRef({
126
+ owner: this.deps.owner,
127
+ repo: this.deps.repo,
128
+ ref: `refs/heads/${branchName}`,
129
+ sha: refData.object.sha
130
+ });
105
131
  }
132
+ // ==================== Block B: Push State ====================
106
133
  /**
107
- * [EARS-A3] Reads file content as string.
108
- * [EARS-B2] Decodes base64 content from Contents API.
109
- * [EARS-B7] Falls back to Blobs API for files >1MB (null content).
134
+ * [EARS-GS-B1..B5] Push local .gitgov/ state to gitgov-state branch via API.
135
+ *
136
+ * Uses the 6-step atomic commit pattern:
137
+ * getRef → getCommit → createBlob → createTree → createCommit → updateRef
138
+ *
139
+ * Optimistic concurrency: if remote ref advanced since our read, updateRef
140
+ * fails with 422 → return conflictDetected: true.
110
141
  */
111
- async read(filePath) {
112
- const fullPath = this.buildFullPath(filePath);
142
+ async pushState(options) {
143
+ const branchName = await this.getStateBranchName();
144
+ const sourceBranch = options.sourceBranch ?? "main";
113
145
  try {
114
- const { data } = await this.octokit.rest.repos.getContent({
115
- owner: this.owner,
116
- repo: this.repo,
117
- path: fullPath,
118
- ref: this.ref
146
+ const { data: stateRefData } = await this.deps.octokit.rest.git.getRef({
147
+ owner: this.deps.owner,
148
+ repo: this.deps.repo,
149
+ ref: `heads/${branchName}`
119
150
  });
120
- if (Array.isArray(data) || data.type !== "file") {
121
- throw new FileListerError(
122
- `Not a file: ${filePath}`,
123
- "READ_ERROR",
124
- filePath
125
- );
126
- }
127
- if (data.content !== null && data.content !== void 0) {
128
- return Buffer.from(data.content, "base64").toString("utf-8");
129
- }
130
- return this.readViaBlobs(data.sha, filePath);
131
- } catch (error) {
132
- if (error instanceof FileListerError) throw error;
133
- if (isOctokitRequestError(error)) {
134
- if (error.status === 404) {
135
- throw new FileListerError(
136
- `File not found: ${filePath}`,
137
- "FILE_NOT_FOUND",
138
- filePath
139
- );
140
- }
141
- if (error.status === 401 || error.status === 403) {
142
- throw new FileListerError(
143
- `Permission denied: ${filePath}`,
144
- "PERMISSION_DENIED",
145
- filePath
146
- );
147
- }
148
- if (error.status >= 500) {
149
- throw new FileListerError(
150
- `GitHub API server error (${error.status}): ${filePath}`,
151
- "READ_ERROR",
152
- filePath
153
- );
151
+ const currentSha = stateRefData.object.sha;
152
+ const { data: sourceTree } = await this.deps.octokit.rest.git.getTree({
153
+ owner: this.deps.owner,
154
+ repo: this.deps.repo,
155
+ tree_sha: sourceBranch,
156
+ recursive: "true"
157
+ });
158
+ const sourceFiles = (sourceTree.tree ?? []).filter(
159
+ (item) => item.type === "blob" && item.path?.startsWith(".gitgov/") && shouldSyncFile(item.path)
160
+ );
161
+ const { data: targetCommit } = await this.deps.octokit.rest.git.getCommit({
162
+ owner: this.deps.owner,
163
+ repo: this.deps.repo,
164
+ commit_sha: currentSha
165
+ });
166
+ const { data: targetTree } = await this.deps.octokit.rest.git.getTree({
167
+ owner: this.deps.owner,
168
+ repo: this.deps.repo,
169
+ tree_sha: targetCommit.tree.sha,
170
+ recursive: "true"
171
+ });
172
+ const targetFileMap = /* @__PURE__ */ new Map();
173
+ for (const item of targetTree.tree ?? []) {
174
+ if (item.type === "blob" && item.path && item.sha) {
175
+ targetFileMap.set(item.path, item.sha);
154
176
  }
155
- throw new FileListerError(
156
- `Unexpected GitHub API response (${error.status}): ${filePath}`,
157
- "READ_ERROR",
158
- filePath
159
- );
160
177
  }
161
- throw new FileListerError(
162
- `Network error reading file: ${filePath}`,
163
- "NETWORK_ERROR",
164
- filePath
165
- );
166
- }
167
- }
168
- /**
169
- * [EARS-A4] Gets file statistics via Contents API.
170
- * Returns size from API, mtime as 0 (not available via Contents API), isFile as true.
171
- */
172
- async stat(filePath) {
173
- const fullPath = this.buildFullPath(filePath);
174
- try {
175
- const { data } = await this.octokit.rest.repos.getContent({
176
- owner: this.owner,
177
- repo: this.repo,
178
- path: fullPath,
179
- ref: this.ref
178
+ const delta = [];
179
+ const treeEntries = [];
180
+ for (const sourceFile of sourceFiles) {
181
+ if (!sourceFile.path || !sourceFile.sha) continue;
182
+ const statePath = sourceFile.path.replace(/^\.gitgov\//, "");
183
+ const targetSha = targetFileMap.get(statePath);
184
+ if (targetSha !== sourceFile.sha) {
185
+ delta.push({
186
+ status: targetSha ? "M" : "A",
187
+ file: statePath
188
+ });
189
+ treeEntries.push({
190
+ path: statePath,
191
+ mode: "100644",
192
+ type: "blob",
193
+ sha: sourceFile.sha
194
+ });
195
+ }
196
+ targetFileMap.delete(statePath);
197
+ }
198
+ for (const [deletedPath] of targetFileMap) {
199
+ if (shouldSyncFile(deletedPath)) {
200
+ delta.push({ status: "D", file: deletedPath });
201
+ treeEntries.push({
202
+ path: deletedPath,
203
+ mode: "100644",
204
+ type: "blob",
205
+ sha: null
206
+ });
207
+ }
208
+ }
209
+ if (delta.length === 0) {
210
+ return {
211
+ success: true,
212
+ filesSynced: 0,
213
+ sourceBranch,
214
+ commitHash: null,
215
+ commitMessage: null,
216
+ conflictDetected: false
217
+ };
218
+ }
219
+ if (options.dryRun) {
220
+ return {
221
+ success: true,
222
+ filesSynced: delta.length,
223
+ sourceBranch,
224
+ commitHash: null,
225
+ commitMessage: `[dry-run] gitgov sync: ${delta.length} files`,
226
+ conflictDetected: false
227
+ };
228
+ }
229
+ const { data: newTreeData } = await this.deps.octokit.rest.git.createTree({
230
+ owner: this.deps.owner,
231
+ repo: this.deps.repo,
232
+ base_tree: targetCommit.tree.sha,
233
+ tree: treeEntries
180
234
  });
181
- if (Array.isArray(data) || data.type !== "file") {
182
- throw new FileListerError(
183
- `Not a file: ${filePath}`,
184
- "READ_ERROR",
185
- filePath
186
- );
235
+ const commitMessage = `gitgov sync: ${delta.length} files from ${sourceBranch}`;
236
+ const { data: newCommitData } = await this.deps.octokit.rest.git.createCommit({
237
+ owner: this.deps.owner,
238
+ repo: this.deps.repo,
239
+ message: commitMessage,
240
+ tree: newTreeData.sha,
241
+ parents: [currentSha]
242
+ });
243
+ try {
244
+ await this.deps.octokit.rest.git.updateRef({
245
+ owner: this.deps.owner,
246
+ repo: this.deps.repo,
247
+ ref: `heads/${branchName}`,
248
+ sha: newCommitData.sha
249
+ });
250
+ } catch (error) {
251
+ if (isOctokitRequestError(error) && (error.status === 422 || error.status === 409)) {
252
+ return {
253
+ success: false,
254
+ filesSynced: 0,
255
+ sourceBranch,
256
+ commitHash: null,
257
+ commitMessage: null,
258
+ conflictDetected: true,
259
+ conflictInfo: {
260
+ type: "rebase_conflict",
261
+ affectedFiles: delta.map((d) => d.file),
262
+ message: "Remote gitgov-state ref has advanced since last read. Pull and retry.",
263
+ resolutionSteps: [
264
+ "Call pullState() to fetch latest remote state",
265
+ "Retry pushState() with updated parent SHA"
266
+ ]
267
+ }
268
+ };
269
+ }
270
+ throw error;
187
271
  }
272
+ this.lastKnownSha = newCommitData.sha;
188
273
  return {
189
- size: data.size,
190
- mtime: 0,
191
- isFile: true
274
+ success: true,
275
+ filesSynced: delta.length,
276
+ sourceBranch,
277
+ commitHash: newCommitData.sha,
278
+ commitMessage,
279
+ conflictDetected: false
192
280
  };
193
281
  } catch (error) {
194
- if (error instanceof FileListerError) throw error;
195
282
  if (isOctokitRequestError(error)) {
196
- if (error.status === 404) {
197
- throw new FileListerError(
198
- `File not found: ${filePath}`,
199
- "FILE_NOT_FOUND",
200
- filePath
201
- );
202
- }
203
- if (error.status === 401 || error.status === 403) {
204
- throw new FileListerError(
205
- `Permission denied: ${filePath}`,
206
- "PERMISSION_DENIED",
207
- filePath
208
- );
209
- }
210
- if (error.status >= 500) {
211
- throw new FileListerError(
212
- `GitHub API server error (${error.status}): ${filePath}`,
213
- "READ_ERROR",
214
- filePath
215
- );
216
- }
217
- throw new FileListerError(
218
- `Unexpected GitHub API response (${error.status}): ${filePath}`,
219
- "READ_ERROR",
220
- filePath
221
- );
283
+ return {
284
+ success: false,
285
+ filesSynced: 0,
286
+ sourceBranch,
287
+ commitHash: null,
288
+ commitMessage: null,
289
+ conflictDetected: false,
290
+ error: `GitHub API error (${error.status}): ${error.message}`
291
+ };
222
292
  }
223
- throw new FileListerError(
224
- `Network error getting file stats: ${filePath}`,
225
- "NETWORK_ERROR",
226
- filePath
227
- );
228
- }
229
- }
230
- // ═══════════════════════════════════════════════════════════════════════
231
- // Private Helpers
232
- // ═══════════════════════════════════════════════════════════════════════
233
- /**
234
- * Builds the full file path including basePath prefix.
235
- */
236
- buildFullPath(filePath) {
237
- if (this.basePath) {
238
- return `${this.basePath}/${filePath}`;
293
+ const msg = error instanceof Error ? error.message : String(error);
294
+ return {
295
+ success: false,
296
+ filesSynced: 0,
297
+ sourceBranch,
298
+ commitHash: null,
299
+ commitMessage: null,
300
+ conflictDetected: false,
301
+ error: msg
302
+ };
239
303
  }
240
- return filePath;
241
304
  }
305
+ // ==================== Block C: Pull State ====================
242
306
  /**
243
- * [EARS-B6] Fetches and caches the full repository tree.
244
- * [EARS-C3] Throws READ_ERROR if the tree response is truncated.
307
+ * [EARS-GS-C1..C4] Pull remote state from gitgov-state branch.
308
+ *
309
+ * Fetches tree + blobs, updates lastKnownSha, triggers re-indexing.
245
310
  */
246
- async fetchTree() {
247
- if (this.treeCache !== null) {
248
- return this.treeCache;
249
- }
311
+ async pullState(options) {
312
+ const branchName = await this.getStateBranchName();
313
+ let remoteSha;
250
314
  try {
251
- const { data } = await this.octokit.rest.git.getTree({
252
- owner: this.owner,
253
- repo: this.repo,
254
- tree_sha: this.ref,
255
- recursive: "1"
315
+ const { data: refData } = await this.deps.octokit.rest.git.getRef({
316
+ owner: this.deps.owner,
317
+ repo: this.deps.repo,
318
+ ref: `heads/${branchName}`
256
319
  });
257
- if (data.truncated) {
258
- throw new FileListerError(
259
- "Repository tree is truncated; too many files to list via Trees API",
260
- "READ_ERROR"
261
- );
262
- }
263
- this.treeCache = data.tree;
264
- return this.treeCache;
320
+ remoteSha = refData.object.sha;
265
321
  } catch (error) {
266
- if (error instanceof FileListerError) throw error;
267
- if (isOctokitRequestError(error)) {
268
- if (error.status === 404) {
269
- throw new FileListerError(
270
- "Repository or ref not found",
271
- "FILE_NOT_FOUND"
272
- );
273
- }
274
- if (error.status === 401 || error.status === 403) {
275
- throw new FileListerError(
276
- "Permission denied accessing repository tree",
277
- "PERMISSION_DENIED"
278
- );
279
- }
280
- if (error.status >= 500) {
281
- throw new FileListerError(
282
- `GitHub API server error (${error.status}) fetching tree`,
283
- "READ_ERROR"
284
- );
285
- }
286
- throw new FileListerError(
287
- `Unexpected GitHub API response (${error.status}) fetching tree`,
288
- "READ_ERROR"
289
- );
322
+ if (isOctokitRequestError(error) && error.status === 404) {
323
+ return {
324
+ success: true,
325
+ hasChanges: false,
326
+ filesUpdated: 0,
327
+ reindexed: false,
328
+ conflictDetected: false
329
+ };
330
+ }
331
+ throw error;
332
+ }
333
+ if (this.lastKnownSha === remoteSha && !options?.forceReindex) {
334
+ return {
335
+ success: true,
336
+ hasChanges: false,
337
+ filesUpdated: 0,
338
+ reindexed: false,
339
+ conflictDetected: false
340
+ };
341
+ }
342
+ const { data: commitData } = await this.deps.octokit.rest.git.getCommit({
343
+ owner: this.deps.owner,
344
+ repo: this.deps.repo,
345
+ commit_sha: remoteSha
346
+ });
347
+ const { data: treeData } = await this.deps.octokit.rest.git.getTree({
348
+ owner: this.deps.owner,
349
+ repo: this.deps.repo,
350
+ tree_sha: commitData.tree.sha,
351
+ recursive: "true"
352
+ });
353
+ const syncableFiles = (treeData.tree ?? []).filter(
354
+ (item) => item.type === "blob" && item.path && shouldSyncFile(item.path)
355
+ );
356
+ const filesUpdated = syncableFiles.length;
357
+ this.lastKnownSha = remoteSha;
358
+ let reindexed = false;
359
+ if (filesUpdated > 0 || options?.forceReindex) {
360
+ try {
361
+ await this.deps.indexer.computeProjection();
362
+ reindexed = true;
363
+ } catch {
364
+ reindexed = false;
290
365
  }
291
- throw new FileListerError(
292
- "Network error fetching repository tree",
293
- "NETWORK_ERROR"
294
- );
295
366
  }
367
+ return {
368
+ success: true,
369
+ hasChanges: filesUpdated > 0,
370
+ filesUpdated,
371
+ reindexed,
372
+ conflictDetected: false
373
+ };
296
374
  }
375
+ // ==================== Block D: Change Detection ====================
297
376
  /**
298
- * [EARS-B7] Reads file content via the Blobs API (fallback for >1MB files).
377
+ * [EARS-GS-D1..D3] Calculate file delta between known state and current remote.
299
378
  */
300
- async readViaBlobs(sha, filePath) {
379
+ async calculateStateDelta(_sourceBranch) {
380
+ const branchName = await this.getStateBranchName();
381
+ let currentSha;
301
382
  try {
302
- const { data } = await this.octokit.rest.git.getBlob({
303
- owner: this.owner,
304
- repo: this.repo,
305
- file_sha: sha
383
+ const { data: refData } = await this.deps.octokit.rest.git.getRef({
384
+ owner: this.deps.owner,
385
+ repo: this.deps.repo,
386
+ ref: `heads/${branchName}`
306
387
  });
307
- return Buffer.from(data.content, "base64").toString("utf-8");
388
+ currentSha = refData.object.sha;
308
389
  } catch (error) {
309
- if (isOctokitRequestError(error)) {
310
- if (error.status === 404) {
311
- throw new FileListerError(
312
- `File not found: ${filePath}`,
313
- "FILE_NOT_FOUND",
314
- filePath
315
- );
316
- }
317
- if (error.status === 401 || error.status === 403) {
318
- throw new FileListerError(
319
- `Permission denied: ${filePath}`,
320
- "PERMISSION_DENIED",
321
- filePath
322
- );
323
- }
324
- throw new FileListerError(
325
- `GitHub API error (${error.status}): ${filePath}`,
326
- "READ_ERROR",
327
- filePath
328
- );
390
+ if (isOctokitRequestError(error) && error.status === 404) {
391
+ return [];
329
392
  }
330
- throw new FileListerError(
331
- `Network error reading blob for file: ${filePath}`,
332
- "NETWORK_ERROR",
333
- filePath
334
- );
393
+ throw error;
335
394
  }
336
- }
337
- };
338
-
339
- // src/record_store/github/github_record_store.ts
340
- var GitHubRecordStore = class {
341
- owner;
342
- repo;
343
- ref;
344
- basePath;
345
- extension;
346
- idEncoder;
347
- octokit;
348
- /** SHA cache keyed by full file path (basePath/encoded + extension) */
349
- shaCache = /* @__PURE__ */ new Map();
350
- /** IGitModule dependency for putMany() atomic commits. Optional — only needed for putMany(). */
351
- gitModule;
352
- constructor(options, octokit, gitModule) {
353
- this.owner = options.owner;
354
- this.repo = options.repo;
355
- this.ref = options.ref ?? "gitgov-state";
356
- this.basePath = options.basePath;
357
- this.extension = options.extension ?? ".json";
358
- this.idEncoder = options.idEncoder;
359
- this.octokit = octokit;
360
- this.gitModule = gitModule;
361
- }
362
- async get(id) {
363
- this.validateId(id);
364
- const filePath = this.buildFilePath(id);
365
- try {
366
- const { data } = await this.octokit.rest.repos.getContent({
367
- owner: this.owner,
368
- repo: this.repo,
369
- path: filePath,
370
- ref: this.ref
371
- });
372
- if (Array.isArray(data) || data.type !== "file") {
373
- throw new GitHubApiError(`Not a file: ${filePath}`, "INVALID_RESPONSE");
374
- }
375
- if (data.content === null || data.content === void 0) {
376
- throw new GitHubApiError(
377
- `File content is null (file may exceed 1MB): ${filePath}`,
378
- "INVALID_RESPONSE"
379
- );
380
- }
381
- this.shaCache.set(filePath, data.sha);
382
- const decoded = Buffer.from(data.content, "base64").toString("utf-8");
383
- return JSON.parse(decoded);
384
- } catch (error) {
385
- if (error instanceof GitHubApiError) throw error;
386
- if (isOctokitRequestError(error) && error.status === 404) return null;
387
- throw mapOctokitError(error, `GET ${filePath}`);
395
+ if (this.lastKnownSha === currentSha) {
396
+ return [];
388
397
  }
389
- }
390
- async put(id, value, opts) {
391
- this.validateId(id);
392
- const filePath = this.buildFilePath(id);
393
- const content = Buffer.from(JSON.stringify(value, null, 2)).toString("base64");
394
- const cachedSha = this.shaCache.get(filePath);
395
- try {
396
- const { data } = await this.octokit.rest.repos.createOrUpdateFileContents({
397
- owner: this.owner,
398
- repo: this.repo,
399
- path: filePath,
400
- message: opts?.commitMessage ?? `put ${id}`,
401
- content,
402
- branch: this.ref,
403
- ...cachedSha ? { sha: cachedSha } : {}
398
+ if (this.lastKnownSha === null) {
399
+ const { data: commitData } = await this.deps.octokit.rest.git.getCommit({
400
+ owner: this.deps.owner,
401
+ repo: this.deps.repo,
402
+ commit_sha: currentSha
404
403
  });
405
- if (data.content?.sha) {
406
- this.shaCache.set(filePath, data.content.sha);
407
- }
408
- return { commitSha: data.commit.sha };
409
- } catch (error) {
410
- throw mapOctokitError(error, `PUT ${filePath}`);
404
+ const { data: treeData } = await this.deps.octokit.rest.git.getTree({
405
+ owner: this.deps.owner,
406
+ repo: this.deps.repo,
407
+ tree_sha: commitData.tree.sha,
408
+ recursive: "true"
409
+ });
410
+ return (treeData.tree ?? []).filter((item) => item.type === "blob" && item.path && shouldSyncFile(item.path)).map((item) => ({
411
+ status: "A",
412
+ file: item.path
413
+ }));
411
414
  }
415
+ const { data: comparison } = await this.deps.octokit.rest.repos.compareCommits({
416
+ owner: this.deps.owner,
417
+ repo: this.deps.repo,
418
+ base: this.lastKnownSha,
419
+ head: currentSha
420
+ });
421
+ return (comparison.files ?? []).filter((file) => shouldSyncFile(file.filename)).map((file) => ({
422
+ status: file.status === "added" ? "A" : file.status === "removed" ? "D" : "M",
423
+ file: file.filename
424
+ }));
412
425
  }
413
426
  /**
414
- * [EARS-A11, EARS-A12, EARS-B8] Persists multiple records in a single atomic commit.
415
- * Uses GitHubGitModule staging buffer: add() with contentMap, then commit().
416
- * Empty entries array returns { commitSha: undefined } without API calls.
417
- * Requires gitModule dependency — throws if not injected.
427
+ * Always empty no local pending changes in API mode.
428
+ * In API mode there is no local filesystem; all state is remote.
418
429
  */
419
- async putMany(entries, opts) {
420
- if (entries.length === 0) {
421
- return {};
422
- }
423
- if (!this.gitModule) {
424
- throw new Error("putMany requires IGitModule dependency for atomic commits");
425
- }
426
- for (const { id } of entries) {
427
- this.validateId(id);
428
- }
429
- const contentMap = {};
430
- const filePaths = [];
431
- for (const { id, value } of entries) {
432
- const filePath = this.buildFilePath(id);
433
- contentMap[filePath] = JSON.stringify(value, null, 2);
434
- filePaths.push(filePath);
435
- }
436
- await this.gitModule.add(filePaths, { contentMap });
437
- const message = opts?.commitMessage ?? `putMany ${entries.length} records`;
438
- const commitSha = await this.gitModule.commit(message);
439
- return { commitSha };
430
+ async getPendingChanges() {
431
+ return [];
440
432
  }
441
- async delete(id, opts) {
442
- this.validateId(id);
443
- const filePath = this.buildFilePath(id);
444
- let sha = this.shaCache.get(filePath);
445
- if (sha === void 0) {
446
- try {
447
- const { data } = await this.octokit.rest.repos.getContent({
448
- owner: this.owner,
449
- repo: this.repo,
450
- path: filePath,
451
- ref: this.ref
452
- });
453
- if (Array.isArray(data) || data.type !== "file") {
454
- return {};
455
- }
456
- sha = data.sha;
457
- } catch (error) {
458
- if (isOctokitRequestError(error) && error.status === 404) {
459
- return {};
460
- }
461
- throw mapOctokitError(error, `GET ${filePath} (for delete)`);
462
- }
463
- }
464
- try {
465
- const { data } = await this.octokit.rest.repos.deleteFile({
466
- owner: this.owner,
467
- repo: this.repo,
468
- path: filePath,
469
- message: opts?.commitMessage ?? `delete ${id}`,
470
- sha,
471
- branch: this.ref
472
- });
473
- this.shaCache.delete(filePath);
474
- return { commitSha: data.commit.sha };
475
- } catch (error) {
476
- if (isOctokitRequestError(error) && error.status === 404) {
477
- this.shaCache.delete(filePath);
478
- return {};
479
- }
480
- throw mapOctokitError(error, `DELETE ${filePath}`);
481
- }
433
+ // ==================== Block E: Conflict Handling ====================
434
+ /**
435
+ * Always false — no rebase in API mode.
436
+ */
437
+ async isRebaseInProgress() {
438
+ return false;
482
439
  }
483
- async list() {
484
- try {
485
- const { data } = await this.octokit.rest.repos.getContent({
486
- owner: this.owner,
487
- repo: this.repo,
488
- path: this.basePath,
489
- ref: this.ref
490
- });
491
- if (!Array.isArray(data)) {
492
- return [];
493
- }
494
- const ids = data.filter((entry) => entry.name.endsWith(this.extension)).map((entry) => entry.name.slice(0, -this.extension.length));
495
- return this.idEncoder ? ids.map((encoded) => this.idEncoder.decode(encoded)) : ids;
496
- } catch (error) {
497
- if (isOctokitRequestError(error) && error.status === 404) {
498
- return [];
499
- }
500
- throw mapOctokitError(error, `GET ${this.basePath} (list)`);
501
- }
440
+ /**
441
+ * Always empty — no conflict markers in API mode.
442
+ */
443
+ async checkConflictMarkers(_filePaths) {
444
+ return [];
502
445
  }
503
- async exists(id) {
504
- this.validateId(id);
505
- const filePath = this.buildFilePath(id);
506
- try {
507
- await this.octokit.rest.repos.getContent({
508
- owner: this.owner,
509
- repo: this.repo,
510
- path: filePath,
511
- ref: this.ref
512
- });
513
- return true;
514
- } catch (error) {
515
- if (isOctokitRequestError(error) && error.status === 404) {
516
- return false;
517
- }
518
- throw mapOctokitError(error, `GET ${filePath} (exists)`);
519
- }
446
+ /**
447
+ * Empty diff — no git-level conflict markers in API mode.
448
+ */
449
+ async getConflictDiff(_filePaths) {
450
+ return {
451
+ files: [],
452
+ message: "No conflict markers in API mode. Conflicts are SHA-based.",
453
+ resolutionSteps: [
454
+ "Call pullState() to fetch latest remote state",
455
+ "Retry pushState() with updated records"
456
+ ]
457
+ };
520
458
  }
521
- // ─────────────────────────────────────────────────────────
522
- // Private helpers
523
- // ─────────────────────────────────────────────────────────
524
- validateId(id) {
525
- if (!id || typeof id !== "string") {
526
- throw new GitHubApiError("ID must be a non-empty string", "INVALID_ID");
459
+ /**
460
+ * [EARS-GS-E1..E2] Resolve conflict by pulling latest and retrying push.
461
+ */
462
+ async resolveConflict(options) {
463
+ const pullResult = await this.pullState({ forceReindex: false });
464
+ if (!pullResult.success) {
465
+ return {
466
+ success: false,
467
+ rebaseCommitHash: "",
468
+ resolutionCommitHash: "",
469
+ conflictsResolved: 0,
470
+ resolvedBy: options.actorId,
471
+ reason: options.reason,
472
+ error: `Pull failed during conflict resolution: ${pullResult.error}`
473
+ };
527
474
  }
528
- if (id.includes("..") || /[\/\\]/.test(id)) {
529
- throw new GitHubApiError(
530
- `Invalid ID: "${id}". IDs cannot contain /, \\, or ..`,
531
- "INVALID_ID"
532
- );
475
+ const pushResult = await this.pushState({
476
+ actorId: options.actorId
477
+ });
478
+ if (!pushResult.success || pushResult.conflictDetected) {
479
+ const errorMsg = pushResult.conflictDetected ? "Content conflict: same file modified by both sides. Manual resolution required." : pushResult.error ?? "Unknown push error";
480
+ return {
481
+ success: false,
482
+ rebaseCommitHash: "",
483
+ resolutionCommitHash: "",
484
+ conflictsResolved: 0,
485
+ resolvedBy: options.actorId,
486
+ reason: options.reason,
487
+ error: errorMsg
488
+ };
533
489
  }
490
+ return {
491
+ success: true,
492
+ rebaseCommitHash: this.lastKnownSha ?? "",
493
+ resolutionCommitHash: pushResult.commitHash ?? "",
494
+ conflictsResolved: pushResult.filesSynced,
495
+ resolvedBy: options.actorId,
496
+ reason: options.reason
497
+ };
534
498
  }
535
- buildFilePath(id) {
536
- const encoded = this.idEncoder ? this.idEncoder.encode(id) : id;
537
- return `${this.basePath}/${encoded}${this.extension}`;
538
- }
539
- };
540
-
541
- // src/git/errors.ts
542
- var GitError = class _GitError extends Error {
543
- constructor(message) {
544
- super(message);
545
- this.name = "GitError";
546
- Object.setPrototypeOf(this, _GitError.prototype);
547
- }
548
- };
549
- var BranchNotFoundError = class _BranchNotFoundError extends GitError {
550
- branchName;
551
- constructor(branchName) {
552
- super(`Branch not found: ${branchName}`);
553
- this.name = "BranchNotFoundError";
554
- this.branchName = branchName;
555
- Object.setPrototypeOf(this, _BranchNotFoundError.prototype);
556
- }
557
- };
558
- var FileNotFoundError = class _FileNotFoundError extends GitError {
559
- filePath;
560
- commitHash;
561
- constructor(filePath, commitHash) {
562
- super(`File not found: ${filePath} in commit ${commitHash}`);
563
- this.name = "FileNotFoundError";
564
- this.filePath = filePath;
565
- this.commitHash = commitHash;
566
- Object.setPrototypeOf(this, _FileNotFoundError.prototype);
567
- }
568
- };
569
- var BranchAlreadyExistsError = class _BranchAlreadyExistsError extends GitError {
570
- branchName;
571
- constructor(branchName) {
572
- super(`Branch already exists: ${branchName}`);
573
- this.name = "BranchAlreadyExistsError";
574
- this.branchName = branchName;
575
- Object.setPrototypeOf(this, _BranchAlreadyExistsError.prototype);
576
- }
577
- };
578
-
579
- // src/git/github/github_git_module.ts
580
- var GitHubGitModule = class {
581
- owner;
582
- repo;
583
- defaultBranch;
584
- octokit;
585
- /** Staging buffer: path → content (null = delete) */
586
- stagingBuffer = /* @__PURE__ */ new Map();
587
- /** Active ref for operations (can be changed via checkoutBranch) */
588
- activeRef;
589
- constructor(options, octokit) {
590
- this.owner = options.owner;
591
- this.repo = options.repo;
592
- this.defaultBranch = options.defaultBranch ?? "gitgov-state";
593
- this.octokit = octokit;
594
- this.activeRef = this.defaultBranch;
595
- }
596
- // ═══════════════════════════════════════════════════════════════
597
- // PRIVATE HELPERS
598
- // ═══════════════════════════════════════════════════════════════
599
- /** Category C: Not supported via GitHub API */
600
- notSupported(method) {
601
- throw new GitError(
602
- `${method} is not supported via GitHub API`
603
- );
604
- }
605
- // ═══════════════════════════════════════════════════════════════
606
- // CATEGORY A: READ OPERATIONS (EARS-A1 to A6)
607
- // ═══════════════════════════════════════════════════════════════
608
499
  /**
609
- * [EARS-A1] Read file content via Contents API + base64 decode
610
- * [EARS-A2] Fallback to Blobs API for files >1MB
500
+ * No integrity violations in API mode (no rebase commits).
611
501
  */
612
- async getFileContent(commitHash, filePath) {
613
- try {
614
- const { data } = await this.octokit.rest.repos.getContent({
615
- owner: this.owner,
616
- repo: this.repo,
617
- path: filePath,
618
- ref: commitHash
619
- });
620
- if (Array.isArray(data) || data.type !== "file") {
621
- throw new GitError(`Not a file: ${filePath}`);
622
- }
623
- if (data.content !== null && data.content !== void 0) {
624
- return Buffer.from(data.content, "base64").toString("utf-8");
625
- }
626
- const { data: blobData } = await this.octokit.rest.git.getBlob({
627
- owner: this.owner,
628
- repo: this.repo,
629
- file_sha: data.sha
630
- });
631
- return Buffer.from(blobData.content, "base64").toString("utf-8");
632
- } catch (error) {
633
- if (error instanceof GitError) throw error;
634
- if (isOctokitRequestError(error)) {
635
- if (error.status === 404) {
636
- throw new FileNotFoundError(filePath, commitHash);
637
- }
638
- if (error.status === 401 || error.status === 403) {
639
- throw new GitError(`authentication/permission error (${error.status}): getFileContent ${filePath}`);
640
- }
641
- if (error.status >= 500) {
642
- throw new GitError(`GitHub server error (${error.status}): getFileContent ${filePath}`);
643
- }
644
- throw new GitError(`GitHub API error (${error.status}): getFileContent ${filePath}`);
645
- }
646
- const msg = error instanceof Error ? error.message : String(error);
647
- throw new GitError(`network error: ${msg}`);
648
- }
502
+ async verifyResolutionIntegrity() {
503
+ return [];
649
504
  }
505
+ // ==================== Block F: Audit ====================
650
506
  /**
651
- * [EARS-A3] Get commit SHA from branch via Refs API
652
- * [EARS-B4] Return SHA directly if already a 40-char hex
507
+ * [EARS-GS-F1..F2] Audit the remote gitgov-state branch.
653
508
  */
654
- async getCommitHash(ref = this.activeRef) {
655
- if (/^[0-9a-f]{40}$/i.test(ref)) {
656
- return ref;
657
- }
509
+ async auditState(options) {
510
+ const branchName = await this.getStateBranchName();
511
+ const scope = options?.scope ?? "all";
512
+ let totalCommits = 0;
658
513
  try {
659
- const { data } = await this.octokit.rest.git.getRef({
660
- owner: this.owner,
661
- repo: this.repo,
662
- ref: `heads/${ref}`
514
+ const { data: commits } = await this.deps.octokit.rest.repos.listCommits({
515
+ owner: this.deps.owner,
516
+ repo: this.deps.repo,
517
+ sha: branchName,
518
+ per_page: 100
663
519
  });
664
- return data.object.sha;
520
+ totalCommits = commits.length;
665
521
  } catch (error) {
666
- if (isOctokitRequestError(error)) {
667
- if (error.status === 404) {
668
- throw new BranchNotFoundError(ref);
669
- }
670
- if (error.status === 401 || error.status === 403) {
671
- throw new GitError(`authentication/permission error (${error.status}): getCommitHash ${ref}`);
672
- }
673
- if (error.status >= 500) {
674
- throw new GitError(`GitHub server error (${error.status}): getCommitHash ${ref}`);
675
- }
522
+ if (isOctokitRequestError(error) && error.status === 404) {
523
+ return {
524
+ passed: true,
525
+ scope,
526
+ totalCommits: 0,
527
+ rebaseCommits: 0,
528
+ resolutionCommits: 0,
529
+ integrityViolations: [],
530
+ summary: "Branch gitgov-state does not exist. No audit needed."
531
+ };
676
532
  }
677
- const msg = error instanceof Error ? error.message : String(error);
678
- throw new GitError(`network error: ${msg}`);
533
+ throw error;
679
534
  }
680
- }
681
- /**
682
- * [EARS-A4] List changed files via Compare API
683
- */
684
- async getChangedFiles(fromCommit, toCommit, pathFilter) {
685
- try {
686
- const { data } = await this.octokit.rest.repos.compareCommits({
687
- owner: this.owner,
688
- repo: this.repo,
689
- base: fromCommit,
690
- head: toCommit
691
- });
692
- const statusMap = {
693
- added: "A",
694
- modified: "M",
695
- removed: "D",
696
- renamed: "M"
697
- };
698
- const files = (data.files ?? []).map((f) => ({
699
- status: statusMap[f.status] ?? "M",
700
- file: f.filename
701
- })).filter((f) => !pathFilter || f.file.startsWith(pathFilter));
702
- return files;
703
- } catch (error) {
704
- if (isOctokitRequestError(error)) {
705
- if (error.status === 401 || error.status === 403) {
706
- throw new GitError(`authentication/permission error (${error.status}): getChangedFiles ${fromCommit}...${toCommit}`);
535
+ const rebaseCommits = 0;
536
+ const resolutionCommits = 0;
537
+ const integrityViolations = [];
538
+ let lintReport;
539
+ if (options?.verifySignatures !== false || options?.verifyChecksums !== false) {
540
+ try {
541
+ const { data: refData } = await this.deps.octokit.rest.git.getRef({
542
+ owner: this.deps.owner,
543
+ repo: this.deps.repo,
544
+ ref: `heads/${branchName}`
545
+ });
546
+ const { data: commitData } = await this.deps.octokit.rest.git.getCommit({
547
+ owner: this.deps.owner,
548
+ repo: this.deps.repo,
549
+ commit_sha: refData.object.sha
550
+ });
551
+ const { data: treeData } = await this.deps.octokit.rest.git.getTree({
552
+ owner: this.deps.owner,
553
+ repo: this.deps.repo,
554
+ tree_sha: commitData.tree.sha,
555
+ recursive: "true"
556
+ });
557
+ const treeItems = (treeData.tree ?? []).filter((item) => item.type === "blob" && item.path && item.sha && shouldSyncFile(item.path));
558
+ const startTime = Date.now();
559
+ const allResults = [];
560
+ let filesChecked = 0;
561
+ for (const item of treeItems) {
562
+ try {
563
+ const { data: blobData } = await this.deps.octokit.rest.git.getBlob({
564
+ owner: this.deps.owner,
565
+ repo: this.deps.repo,
566
+ file_sha: item.sha
567
+ });
568
+ const content = Buffer.from(blobData.content, "base64").toString("utf-8");
569
+ const record = JSON.parse(content);
570
+ const entityType = pathToEntityType(item.path);
571
+ if (entityType) {
572
+ const results = this.deps.lint.lintRecord(record, {
573
+ recordId: item.path.split("/").pop()?.replace(".json", "") ?? item.path,
574
+ entityType,
575
+ filePath: item.path
576
+ });
577
+ allResults.push(...results);
578
+ }
579
+ filesChecked++;
580
+ } catch {
581
+ }
707
582
  }
708
- if (error.status >= 500) {
709
- throw new GitError(`GitHub server error (${error.status}): getChangedFiles`);
583
+ if (filesChecked > 0) {
584
+ lintReport = {
585
+ summary: {
586
+ filesChecked,
587
+ errors: allResults.filter((r) => r.level === "error").length,
588
+ warnings: allResults.filter((r) => r.level === "warning").length,
589
+ fixable: allResults.filter((r) => r.fixable).length,
590
+ executionTime: Date.now() - startTime
591
+ },
592
+ results: allResults,
593
+ metadata: {
594
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
595
+ options: {},
596
+ version: "1.0.0"
597
+ }
598
+ };
710
599
  }
711
- throw new GitError(`Failed to compare ${fromCommit}...${toCommit}: HTTP ${error.status}`);
600
+ } catch {
712
601
  }
713
- const msg = error instanceof Error ? error.message : String(error);
714
- throw new GitError(`network error: ${msg}`);
715
602
  }
716
- }
717
- /**
718
- * [EARS-A5] Get commit history via Commits API
719
- */
720
- async getCommitHistory(branch, options) {
721
- try {
722
- const { data } = await this.octokit.rest.repos.listCommits({
723
- owner: this.owner,
724
- repo: this.repo,
725
- sha: branch,
726
- ...options?.maxCount !== void 0 && { per_page: options.maxCount },
727
- ...options?.pathFilter !== void 0 && { path: options.pathFilter }
728
- });
729
- return data.map((c) => ({
730
- hash: c.sha,
731
- message: c.commit.message,
732
- author: `${c.commit.author?.name ?? "unknown"} <${c.commit.author?.email ?? "unknown"}>`,
733
- date: c.commit.author?.date ?? ""
734
- }));
735
- } catch (error) {
736
- if (isOctokitRequestError(error)) {
737
- if (error.status === 401 || error.status === 403) {
738
- throw new GitError(`authentication/permission error (${error.status}): getCommitHistory ${branch}`);
739
- }
740
- if (error.status >= 500) {
741
- throw new GitError(`GitHub server error (${error.status}): getCommitHistory`);
742
- }
743
- throw new GitError(`Failed to get commit history: HTTP ${error.status}`);
744
- }
745
- const msg = error instanceof Error ? error.message : String(error);
746
- throw new GitError(`network error: ${msg}`);
603
+ const lintPassed = !lintReport || lintReport.summary.errors === 0;
604
+ const passed = integrityViolations.length === 0 && lintPassed;
605
+ const lintErrors = lintReport?.summary.errors ?? 0;
606
+ let summary;
607
+ if (passed) {
608
+ summary = `Audit passed. ${totalCommits} commits analyzed, 0 violations.`;
609
+ } else if (integrityViolations.length > 0 && lintErrors > 0) {
610
+ summary = `Audit failed. ${integrityViolations.length} integrity violations, ${lintErrors} lint errors.`;
611
+ } else if (lintErrors > 0) {
612
+ summary = `Audit failed. ${lintErrors} lint errors found.`;
613
+ } else {
614
+ summary = `Audit failed. ${integrityViolations.length} integrity violations found.`;
747
615
  }
748
- }
749
- /**
750
- * [EARS-B3] Get commit history between two commits via Compare API
751
- */
752
- async getCommitHistoryRange(fromHash, toHash, options) {
753
- try {
754
- const { data } = await this.octokit.rest.repos.compareCommits({
755
- owner: this.owner,
756
- repo: this.repo,
757
- base: fromHash,
758
- head: toHash
759
- });
760
- let commits = data.commits.map((c) => ({
761
- hash: c.sha,
762
- message: c.commit.message,
763
- author: `${c.commit.author?.name ?? "unknown"} <${c.commit.author?.email ?? "unknown"}>`,
764
- date: c.commit.author?.date ?? ""
765
- }));
766
- if (options?.pathFilter) {
767
- const changedPaths = new Set((data.files ?? []).map((f) => f.filename));
768
- commits = commits.filter(
769
- () => Array.from(changedPaths).some((f) => f.startsWith(options.pathFilter))
770
- );
771
- }
772
- if (options?.maxCount) {
773
- commits = commits.slice(0, options.maxCount);
774
- }
775
- return commits;
776
- } catch (error) {
777
- if (isOctokitRequestError(error)) {
778
- if (error.status === 401 || error.status === 403) {
779
- throw new GitError(`authentication/permission error (${error.status}): getCommitHistoryRange ${fromHash}...${toHash}`);
780
- }
781
- if (error.status >= 500) {
782
- throw new GitError(`GitHub server error (${error.status}): getCommitHistoryRange`);
783
- }
784
- throw new GitError(`Failed to get commit range: HTTP ${error.status}`);
785
- }
786
- const msg = error instanceof Error ? error.message : String(error);
787
- throw new GitError(`network error: ${msg}`);
616
+ const report = {
617
+ passed,
618
+ scope,
619
+ totalCommits,
620
+ rebaseCommits,
621
+ resolutionCommits,
622
+ integrityViolations,
623
+ summary
624
+ };
625
+ if (lintReport) {
626
+ report.lintReport = lintReport;
788
627
  }
628
+ return report;
629
+ }
630
+ };
631
+ function pathToEntityType(filePath) {
632
+ const dirMap = {
633
+ tasks: "task",
634
+ cycles: "cycle",
635
+ actors: "actor",
636
+ agents: "agent",
637
+ feedbacks: "feedback",
638
+ executions: "execution"
639
+ };
640
+ const firstSegment = filePath.split("/")[0] ?? "";
641
+ return dirMap[firstSegment];
642
+ }
643
+
644
+ // src/file_lister/file_lister.errors.ts
645
+ var FileListerError = class extends Error {
646
+ constructor(message, code, filePath) {
647
+ super(message);
648
+ this.code = code;
649
+ this.filePath = filePath;
650
+ this.name = "FileListerError";
651
+ }
652
+ };
653
+
654
+ // src/file_lister/github/github_file_lister.ts
655
+ var GitHubFileLister = class {
656
+ owner;
657
+ repo;
658
+ ref;
659
+ basePath;
660
+ octokit;
661
+ /** Cached tree entries from the Trees API */
662
+ treeCache = null;
663
+ constructor(options, octokit) {
664
+ this.owner = options.owner;
665
+ this.repo = options.repo;
666
+ this.ref = options.ref ?? "gitgov-state";
667
+ this.basePath = options.basePath ?? "";
668
+ this.octokit = octokit;
789
669
  }
670
+ // ═══════════════════════════════════════════════════════════════════════
671
+ // FileLister Interface
672
+ // ═══════════════════════════════════════════════════════════════════════
790
673
  /**
791
- * [EARS-A6] Get commit message via Commits API
674
+ * [EARS-A1] Lists files matching glob patterns.
675
+ * [EARS-B1] Uses Trees API with recursive=1 and picomatch filter.
676
+ * [EARS-B3] Applies basePath prefix for tree entries, strips from results.
677
+ * [EARS-B6] Caches tree between list() calls.
792
678
  */
793
- async getCommitMessage(commitHash) {
794
- try {
795
- const { data } = await this.octokit.rest.repos.getCommit({
796
- owner: this.owner,
797
- repo: this.repo,
798
- ref: commitHash
799
- });
800
- return data.commit.message;
801
- } catch (error) {
802
- if (isOctokitRequestError(error)) {
803
- if (error.status === 404) {
804
- throw new GitError(`Commit not found: ${commitHash}`);
805
- }
806
- if (error.status === 401 || error.status === 403) {
807
- throw new GitError(`authentication/permission error (${error.status}): getCommitMessage ${commitHash}`);
808
- }
809
- if (error.status >= 500) {
810
- throw new GitError(`GitHub server error (${error.status}): getCommitMessage`);
811
- }
679
+ async list(patterns, options) {
680
+ const entries = await this.fetchTree();
681
+ const blobs = entries.filter((entry) => entry.type === "blob");
682
+ const prefix = this.basePath ? `${this.basePath}/` : "";
683
+ const relativePaths = [];
684
+ for (const blob of blobs) {
685
+ if (!blob.path) continue;
686
+ if (prefix && !blob.path.startsWith(prefix)) {
687
+ continue;
812
688
  }
813
- const msg = error instanceof Error ? error.message : String(error);
814
- throw new GitError(`network error: ${msg}`);
689
+ const relativePath = prefix ? blob.path.slice(prefix.length) : blob.path;
690
+ relativePaths.push(relativePath);
815
691
  }
692
+ const isMatch = picomatch(patterns, {
693
+ ignore: options?.ignore
694
+ });
695
+ return relativePaths.filter((p) => isMatch(p)).sort();
816
696
  }
817
- // ═══════════════════════════════════════════════════════════════
818
- // CATEGORY A: BRANCH OPERATIONS (EARS-B1 to B2)
819
- // ═══════════════════════════════════════════════════════════════
820
697
  /**
821
- * [EARS-B1] Check if branch exists via Branches API
698
+ * [EARS-A2] Checks if a file exists via Contents API.
699
+ * [EARS-B4] Returns false for 404 responses.
822
700
  */
823
- async branchExists(branchName) {
701
+ async exists(filePath) {
702
+ const fullPath = this.buildFullPath(filePath);
824
703
  try {
825
- await this.octokit.rest.repos.getBranch({
704
+ const { data } = await this.octokit.rest.repos.getContent({
826
705
  owner: this.owner,
827
706
  repo: this.repo,
828
- branch: branchName
707
+ path: fullPath,
708
+ ref: this.ref
829
709
  });
710
+ if (Array.isArray(data) || data.type !== "file") {
711
+ return false;
712
+ }
830
713
  return true;
831
714
  } catch (error) {
832
715
  if (isOctokitRequestError(error)) {
833
716
  if (error.status === 404) return false;
834
717
  if (error.status === 401 || error.status === 403) {
835
- throw new GitError(`authentication/permission error (${error.status}): branchExists ${branchName}`);
718
+ throw new FileListerError(
719
+ `Permission denied: ${filePath}`,
720
+ "PERMISSION_DENIED",
721
+ filePath
722
+ );
836
723
  }
837
- throw new GitError(`Failed to check branch: HTTP ${error.status}`);
724
+ if (error.status >= 500) {
725
+ throw new FileListerError(
726
+ `GitHub API server error (${error.status}): ${filePath}`,
727
+ "READ_ERROR",
728
+ filePath
729
+ );
730
+ }
731
+ throw new FileListerError(
732
+ `Unexpected GitHub API response (${error.status}): ${filePath}`,
733
+ "READ_ERROR",
734
+ filePath
735
+ );
838
736
  }
839
- const msg = error instanceof Error ? error.message : String(error);
840
- throw new GitError(`network error: ${msg}`);
737
+ throw new FileListerError(
738
+ `Network error checking file: ${filePath}`,
739
+ "NETWORK_ERROR",
740
+ filePath
741
+ );
841
742
  }
842
743
  }
843
744
  /**
844
- * [EARS-B2] List remote branches via Branches API
845
- * remoteName is ignored repo itself is the implicit remote
745
+ * [EARS-A3] Reads file content as string.
746
+ * [EARS-B2] Decodes base64 content from Contents API.
747
+ * [EARS-B7] Falls back to Blobs API for files >1MB (null content).
846
748
  */
847
- async listRemoteBranches(_remoteName) {
749
+ async read(filePath) {
750
+ const fullPath = this.buildFullPath(filePath);
848
751
  try {
849
- const { data } = await this.octokit.rest.repos.listBranches({
752
+ const { data } = await this.octokit.rest.repos.getContent({
850
753
  owner: this.owner,
851
- repo: this.repo
754
+ repo: this.repo,
755
+ path: fullPath,
756
+ ref: this.ref
852
757
  });
853
- return data.map((b) => b.name);
758
+ if (Array.isArray(data) || data.type !== "file") {
759
+ throw new FileListerError(
760
+ `Not a file: ${filePath}`,
761
+ "READ_ERROR",
762
+ filePath
763
+ );
764
+ }
765
+ if (data.content !== null && data.content !== void 0) {
766
+ return Buffer.from(data.content, "base64").toString("utf-8");
767
+ }
768
+ return this.readViaBlobs(data.sha, filePath);
854
769
  } catch (error) {
770
+ if (error instanceof FileListerError) throw error;
855
771
  if (isOctokitRequestError(error)) {
772
+ if (error.status === 404) {
773
+ throw new FileListerError(
774
+ `File not found: ${filePath}`,
775
+ "FILE_NOT_FOUND",
776
+ filePath
777
+ );
778
+ }
856
779
  if (error.status === 401 || error.status === 403) {
857
- throw new GitError(`authentication/permission error (${error.status}): listRemoteBranches`);
780
+ throw new FileListerError(
781
+ `Permission denied: ${filePath}`,
782
+ "PERMISSION_DENIED",
783
+ filePath
784
+ );
858
785
  }
859
786
  if (error.status >= 500) {
860
- throw new GitError(`GitHub server error (${error.status}): listRemoteBranches`);
787
+ throw new FileListerError(
788
+ `GitHub API server error (${error.status}): ${filePath}`,
789
+ "READ_ERROR",
790
+ filePath
791
+ );
861
792
  }
862
- throw new GitError(`Failed to list branches: HTTP ${error.status}`);
793
+ throw new FileListerError(
794
+ `Unexpected GitHub API response (${error.status}): ${filePath}`,
795
+ "READ_ERROR",
796
+ filePath
797
+ );
863
798
  }
864
- const msg = error instanceof Error ? error.message : String(error);
865
- throw new GitError(`network error: ${msg}`);
866
- }
867
- }
868
- // ═══════════════════════════════════════════════════════════════
869
- // CATEGORY A: WRITE OPERATIONS (EARS-C1 to C7)
870
- // ═══════════════════════════════════════════════════════════════
871
- /** [EARS-C1] Read file content and store in staging buffer */
872
- async add(filePaths, options) {
873
- for (const filePath of filePaths) {
874
- const content = options?.contentMap?.[filePath] ?? await this.getFileContent(this.activeRef, filePath);
875
- this.stagingBuffer.set(filePath, content);
876
- }
877
- }
878
- /** [EARS-C2] Mark files as deleted in staging buffer */
879
- async rm(filePaths) {
880
- for (const filePath of filePaths) {
881
- this.stagingBuffer.set(filePath, null);
799
+ throw new FileListerError(
800
+ `Network error reading file: ${filePath}`,
801
+ "NETWORK_ERROR",
802
+ filePath
803
+ );
882
804
  }
883
805
  }
884
- /** [EARS-C7] Return staged file paths from buffer */
885
- async getStagedFiles() {
886
- return Array.from(this.stagingBuffer.keys());
887
- }
888
806
  /**
889
- * [EARS-C6] Create branch via Refs API POST
807
+ * [EARS-A4] Gets file statistics via Contents API.
808
+ * Returns size from API, mtime as 0 (not available via Contents API), isFile as true.
890
809
  */
891
- async createBranch(branchName, startPoint) {
892
- const sha = startPoint ? await this.getCommitHash(startPoint) : await this.getCommitHash(this.activeRef);
810
+ async stat(filePath) {
811
+ const fullPath = this.buildFullPath(filePath);
893
812
  try {
894
- await this.octokit.rest.git.createRef({
813
+ const { data } = await this.octokit.rest.repos.getContent({
895
814
  owner: this.owner,
896
815
  repo: this.repo,
897
- ref: `refs/heads/${branchName}`,
898
- sha
816
+ path: fullPath,
817
+ ref: this.ref
899
818
  });
819
+ if (Array.isArray(data) || data.type !== "file") {
820
+ throw new FileListerError(
821
+ `Not a file: ${filePath}`,
822
+ "READ_ERROR",
823
+ filePath
824
+ );
825
+ }
826
+ return {
827
+ size: data.size,
828
+ mtime: 0,
829
+ isFile: true
830
+ };
900
831
  } catch (error) {
832
+ if (error instanceof FileListerError) throw error;
901
833
  if (isOctokitRequestError(error)) {
902
- if (error.status === 422) {
903
- throw new BranchAlreadyExistsError(branchName);
834
+ if (error.status === 404) {
835
+ throw new FileListerError(
836
+ `File not found: ${filePath}`,
837
+ "FILE_NOT_FOUND",
838
+ filePath
839
+ );
904
840
  }
905
841
  if (error.status === 401 || error.status === 403) {
906
- throw new GitError(`authentication/permission error (${error.status}): createBranch ${branchName}`);
842
+ throw new FileListerError(
843
+ `Permission denied: ${filePath}`,
844
+ "PERMISSION_DENIED",
845
+ filePath
846
+ );
907
847
  }
908
- throw new GitError(`Failed to create branch ${branchName}: HTTP ${error.status}`);
848
+ if (error.status >= 500) {
849
+ throw new FileListerError(
850
+ `GitHub API server error (${error.status}): ${filePath}`,
851
+ "READ_ERROR",
852
+ filePath
853
+ );
854
+ }
855
+ throw new FileListerError(
856
+ `Unexpected GitHub API response (${error.status}): ${filePath}`,
857
+ "READ_ERROR",
858
+ filePath
859
+ );
909
860
  }
910
- const msg = error instanceof Error ? error.message : String(error);
911
- throw new GitError(`network error: ${msg}`);
861
+ throw new FileListerError(
862
+ `Network error getting file stats: ${filePath}`,
863
+ "NETWORK_ERROR",
864
+ filePath
865
+ );
912
866
  }
913
867
  }
868
+ // ═══════════════════════════════════════════════════════════════════════
869
+ // Private Helpers
870
+ // ═══════════════════════════════════════════════════════════════════════
914
871
  /**
915
- * Internal commit implementation shared by commit() and commitAllowEmpty().
916
- *
917
- * [EARS-C3] 6-step atomic transaction
918
- * [EARS-C4] Clears staging buffer after successful commit
919
- * [EARS-C5] Throws if staging buffer is empty (unless allowEmpty)
872
+ * Builds the full file path including basePath prefix.
920
873
  */
921
- async commitInternal(message, author, allowEmpty = false) {
922
- if (!allowEmpty && this.stagingBuffer.size === 0) {
923
- throw new GitError("Nothing to commit: staging buffer is empty");
874
+ buildFullPath(filePath) {
875
+ if (this.basePath) {
876
+ return `${this.basePath}/${filePath}`;
877
+ }
878
+ return filePath;
879
+ }
880
+ /**
881
+ * [EARS-B6] Fetches and caches the full repository tree.
882
+ * [EARS-C3] Throws READ_ERROR if the tree response is truncated.
883
+ */
884
+ async fetchTree() {
885
+ if (this.treeCache !== null) {
886
+ return this.treeCache;
924
887
  }
925
888
  try {
926
- const { data: refData } = await this.octokit.rest.git.getRef({
927
- owner: this.owner,
928
- repo: this.repo,
929
- ref: `heads/${this.activeRef}`
930
- });
931
- const currentSha = refData.object.sha;
932
- const { data: commitData } = await this.octokit.rest.git.getCommit({
933
- owner: this.owner,
934
- repo: this.repo,
935
- commit_sha: currentSha
936
- });
937
- const treeSha = commitData.tree.sha;
938
- const treeEntries = [];
939
- for (const [path2, content] of this.stagingBuffer) {
940
- if (content === null) {
941
- treeEntries.push({
942
- path: path2,
943
- mode: "100644",
944
- type: "blob",
945
- sha: null
946
- });
947
- } else {
948
- const { data: blobData } = await this.octokit.rest.git.createBlob({
949
- owner: this.owner,
950
- repo: this.repo,
951
- content: Buffer.from(content).toString("base64"),
952
- encoding: "base64"
953
- });
954
- treeEntries.push({
955
- path: path2,
956
- mode: "100644",
957
- type: "blob",
958
- sha: blobData.sha
959
- });
960
- }
961
- }
962
- const { data: treeData } = await this.octokit.rest.git.createTree({
889
+ const { data } = await this.octokit.rest.git.getTree({
963
890
  owner: this.owner,
964
891
  repo: this.repo,
965
- base_tree: treeSha,
966
- tree: treeEntries
892
+ tree_sha: this.ref,
893
+ recursive: "1"
967
894
  });
968
- const newTreeSha = treeData.sha;
969
- const commitParams = {
970
- owner: this.owner,
971
- repo: this.repo,
972
- message,
973
- tree: newTreeSha,
974
- parents: [currentSha]
975
- };
976
- if (author) {
977
- commitParams.author = {
978
- name: author.name,
979
- email: author.email,
980
- date: (/* @__PURE__ */ new Date()).toISOString()
981
- };
982
- }
983
- const { data: newCommitData } = await this.octokit.rest.git.createCommit(commitParams);
984
- const newCommitSha = newCommitData.sha;
985
- try {
986
- await this.octokit.rest.git.updateRef({
987
- owner: this.owner,
988
- repo: this.repo,
989
- ref: `heads/${this.activeRef}`,
990
- sha: newCommitSha
991
- });
992
- } catch (error) {
993
- if (isOctokitRequestError(error) && error.status === 422) {
994
- throw new GitError("non-fast-forward update rejected");
995
- }
996
- throw error;
895
+ if (data.truncated) {
896
+ throw new FileListerError(
897
+ "Repository tree is truncated; too many files to list via Trees API",
898
+ "READ_ERROR"
899
+ );
997
900
  }
998
- this.stagingBuffer.clear();
999
- return newCommitSha;
901
+ this.treeCache = data.tree;
902
+ return this.treeCache;
1000
903
  } catch (error) {
1001
- if (error instanceof GitError) throw error;
904
+ if (error instanceof FileListerError) throw error;
1002
905
  if (isOctokitRequestError(error)) {
906
+ if (error.status === 404) {
907
+ throw new FileListerError(
908
+ "Repository or ref not found",
909
+ "FILE_NOT_FOUND"
910
+ );
911
+ }
1003
912
  if (error.status === 401 || error.status === 403) {
1004
- throw new GitError(`authentication/permission error (${error.status}): commit`);
913
+ throw new FileListerError(
914
+ "Permission denied accessing repository tree",
915
+ "PERMISSION_DENIED"
916
+ );
1005
917
  }
1006
918
  if (error.status >= 500) {
1007
- throw new GitError(`GitHub server error (${error.status}): commit`);
919
+ throw new FileListerError(
920
+ `GitHub API server error (${error.status}) fetching tree`,
921
+ "READ_ERROR"
922
+ );
1008
923
  }
1009
- throw new GitError(`GitHub API error (${error.status}): commit`);
924
+ throw new FileListerError(
925
+ `Unexpected GitHub API response (${error.status}) fetching tree`,
926
+ "READ_ERROR"
927
+ );
1010
928
  }
1011
- const msg = error instanceof Error ? error.message : String(error);
1012
- throw new GitError(`network error: ${msg}`);
929
+ throw new FileListerError(
930
+ "Network error fetching repository tree",
931
+ "NETWORK_ERROR"
932
+ );
1013
933
  }
1014
934
  }
1015
935
  /**
1016
- * [EARS-C3] Commit staged changes via 6-step atomic transaction
1017
- * [EARS-C5] Throws if staging buffer is empty
936
+ * [EARS-B7] Reads file content via the Blobs API (fallback for >1MB files).
1018
937
  */
1019
- async commit(message, author) {
1020
- return this.commitInternal(message, author, false);
1021
- }
1022
- // ═══════════════════════════════════════════════════════════════
1023
- // CATEGORY B: NO-OPS (sensible defaults)
1024
- // ═══════════════════════════════════════════════════════════════
1025
- /** [EARS-D5] exec not supported in API mode */
1026
- async exec(_command, _args, _options) {
1027
- return { exitCode: 1, stdout: "", stderr: "exec() not supported in GitHub API mode" };
1028
- }
1029
- /** No-op: repos are created via GitHub API, not initialized locally */
1030
- async init() {
1031
- }
1032
- /** [EARS-D1] Return virtual path representing the repo */
1033
- async getRepoRoot() {
1034
- return `github://${this.owner}/${this.repo}`;
1035
- }
1036
- /** [EARS-D1] Return active ref (starts as defaultBranch) */
1037
- async getCurrentBranch() {
1038
- return this.activeRef;
1039
- }
1040
- /** No-op: git config doesn't apply to GitHub API */
1041
- async setConfig(_key, _value, _scope) {
1042
- }
1043
- /** [EARS-D1] Return true if staging buffer has entries */
1044
- async hasUncommittedChanges(_pathFilter) {
1045
- return this.stagingBuffer.size > 0;
1046
- }
1047
- /** No-op: GitHub API doesn't have rebase-in-progress concept */
1048
- async isRebaseInProgress() {
1049
- return false;
1050
- }
1051
- /** [EARS-D1] GitHub repos always have 'origin' conceptually */
1052
- async isRemoteConfigured(_remoteName) {
1053
- return true;
1054
- }
1055
- /** No-op: always 'origin' */
1056
- async getBranchRemote(_branchName) {
1057
- return "origin";
1058
- }
1059
- /** No-op: GitHub API handles merges atomically */
1060
- async getConflictedFiles() {
1061
- return [];
1062
- }
1063
- /** [EARS-D2] Update activeRef for subsequent operations */
1064
- async checkoutBranch(branchName) {
1065
- this.activeRef = branchName;
1066
- }
1067
- /** No-op: GitHub API doesn't have stash concept */
1068
- async stash(_message) {
1069
- return null;
1070
- }
1071
- /** No-op */
1072
- async stashPop() {
1073
- return false;
1074
- }
1075
- /** No-op */
1076
- async stashDrop(_stashHash) {
1077
- }
1078
- /** No-op: API always fresh */
1079
- async fetch(_remote) {
1080
- }
1081
- /** No-op: API mode */
1082
- async pull(_remote, _branchName) {
1083
- }
1084
- /** No-op: API mode */
1085
- async pullRebase(_remote, _branchName) {
1086
- }
1087
- /** [EARS-D4] No-op: commits via API are already remote */
1088
- async push(_remote, _branchName) {
1089
- }
1090
- /** [EARS-D4] No-op: commits via API are already remote */
1091
- async pushWithUpstream(_remote, _branchName) {
1092
- }
1093
- /** No-op: API mode */
1094
- async setUpstream(_branchName, _remote, _remoteBranch) {
1095
- }
1096
- /** No-op */
1097
- async rebaseAbort() {
1098
- }
1099
- /** [EARS-D1] Delegates to commitInternal, allowing empty staging buffer */
1100
- async commitAllowEmpty(message, author) {
1101
- return this.commitInternal(message, author, true);
1102
- }
1103
- // ═══════════════════════════════════════════════════════════════
1104
- // CATEGORY C: NOT SUPPORTED (throw GitError)
1105
- // ═══════════════════════════════════════════════════════════════
1106
- /** [EARS-D3] Not supported via GitHub API */
1107
- async rebase(_targetBranch) {
1108
- this.notSupported("rebase");
1109
- }
1110
- /** [EARS-D3] Not supported via GitHub API */
1111
- async rebaseContinue() {
1112
- this.notSupported("rebaseContinue");
1113
- }
1114
- /** [EARS-D3] Not supported via GitHub API */
1115
- async resetHard(_target) {
1116
- this.notSupported("resetHard");
1117
- }
1118
- /** [EARS-D3] Not supported via GitHub API */
1119
- async checkoutOrphanBranch(_branchName) {
1120
- this.notSupported("checkoutOrphanBranch");
1121
- }
1122
- /** [EARS-D3] Not supported via GitHub API */
1123
- async checkoutFilesFromBranch(_sourceBranch, _filePaths) {
1124
- this.notSupported("checkoutFilesFromBranch");
1125
- }
1126
- /** [EARS-D3] Not supported via GitHub API */
1127
- async getMergeBase(_branchA, _branchB) {
1128
- this.notSupported("getMergeBase");
938
+ async readViaBlobs(sha, filePath) {
939
+ try {
940
+ const { data } = await this.octokit.rest.git.getBlob({
941
+ owner: this.owner,
942
+ repo: this.repo,
943
+ file_sha: sha
944
+ });
945
+ return Buffer.from(data.content, "base64").toString("utf-8");
946
+ } catch (error) {
947
+ if (isOctokitRequestError(error)) {
948
+ if (error.status === 404) {
949
+ throw new FileListerError(
950
+ `File not found: ${filePath}`,
951
+ "FILE_NOT_FOUND",
952
+ filePath
953
+ );
954
+ }
955
+ if (error.status === 401 || error.status === 403) {
956
+ throw new FileListerError(
957
+ `Permission denied: ${filePath}`,
958
+ "PERMISSION_DENIED",
959
+ filePath
960
+ );
961
+ }
962
+ throw new FileListerError(
963
+ `GitHub API error (${error.status}): ${filePath}`,
964
+ "READ_ERROR",
965
+ filePath
966
+ );
967
+ }
968
+ throw new FileListerError(
969
+ `Network error reading blob for file: ${filePath}`,
970
+ "NETWORK_ERROR",
971
+ filePath
972
+ );
973
+ }
1129
974
  }
1130
975
  };
1131
976
 
1132
- // src/config_store/github/github_config_store.ts
1133
- var GitHubConfigStore = class {
977
+ // src/record_store/github/github_record_store.ts
978
+ var GitHubRecordStore = class {
1134
979
  owner;
1135
980
  repo;
1136
981
  ref;
1137
982
  basePath;
983
+ extension;
984
+ idEncoder;
1138
985
  octokit;
1139
- /** Cached blob SHA from the last loadConfig call, used for PUT updates */
1140
- cachedSha = null;
1141
- constructor(options, octokit) {
986
+ /** SHA cache keyed by full file path (basePath/encoded + extension) */
987
+ shaCache = /* @__PURE__ */ new Map();
988
+ /** IGitModule dependency for putMany() atomic commits. Optional — only needed for putMany(). */
989
+ gitModule;
990
+ constructor(options, octokit, gitModule) {
1142
991
  this.owner = options.owner;
1143
992
  this.repo = options.repo;
1144
993
  this.ref = options.ref ?? "gitgov-state";
1145
- this.basePath = options.basePath ?? ".gitgov";
994
+ this.basePath = options.basePath;
995
+ this.extension = options.extension ?? ".json";
996
+ this.idEncoder = options.idEncoder;
1146
997
  this.octokit = octokit;
998
+ this.gitModule = gitModule;
999
+ }
1000
+ async get(id) {
1001
+ this.validateId(id);
1002
+ const filePath = this.buildFilePath(id);
1003
+ try {
1004
+ const { data } = await this.octokit.rest.repos.getContent({
1005
+ owner: this.owner,
1006
+ repo: this.repo,
1007
+ path: filePath,
1008
+ ref: this.ref
1009
+ });
1010
+ if (Array.isArray(data) || data.type !== "file") {
1011
+ throw new GitHubApiError(`Not a file: ${filePath}`, "INVALID_RESPONSE");
1012
+ }
1013
+ if (data.content === null || data.content === void 0) {
1014
+ throw new GitHubApiError(
1015
+ `File content is null (file may exceed 1MB): ${filePath}`,
1016
+ "INVALID_RESPONSE"
1017
+ );
1018
+ }
1019
+ this.shaCache.set(filePath, data.sha);
1020
+ const decoded = Buffer.from(data.content, "base64").toString("utf-8");
1021
+ return JSON.parse(decoded);
1022
+ } catch (error) {
1023
+ if (error instanceof GitHubApiError) throw error;
1024
+ if (isOctokitRequestError(error) && error.status === 404) return null;
1025
+ throw mapOctokitError(error, `GET ${filePath}`);
1026
+ }
1027
+ }
1028
+ async put(id, value, opts) {
1029
+ this.validateId(id);
1030
+ const filePath = this.buildFilePath(id);
1031
+ const content = Buffer.from(JSON.stringify(value, null, 2)).toString("base64");
1032
+ const cachedSha = this.shaCache.get(filePath);
1033
+ try {
1034
+ const { data } = await this.octokit.rest.repos.createOrUpdateFileContents({
1035
+ owner: this.owner,
1036
+ repo: this.repo,
1037
+ path: filePath,
1038
+ message: opts?.commitMessage ?? `put ${id}`,
1039
+ content,
1040
+ branch: this.ref,
1041
+ ...cachedSha ? { sha: cachedSha } : {}
1042
+ });
1043
+ if (data.content?.sha) {
1044
+ this.shaCache.set(filePath, data.content.sha);
1045
+ }
1046
+ return { commitSha: data.commit.sha };
1047
+ } catch (error) {
1048
+ throw mapOctokitError(error, `PUT ${filePath}`);
1049
+ }
1050
+ }
1051
+ /**
1052
+ * [EARS-A11, EARS-A12, EARS-B8] Persists multiple records in a single atomic commit.
1053
+ * Uses GitHubGitModule staging buffer: add() with contentMap, then commit().
1054
+ * Empty entries array returns { commitSha: undefined } without API calls.
1055
+ * Requires gitModule dependency — throws if not injected.
1056
+ */
1057
+ async putMany(entries, opts) {
1058
+ if (entries.length === 0) {
1059
+ return {};
1060
+ }
1061
+ if (!this.gitModule) {
1062
+ throw new Error("putMany requires IGitModule dependency for atomic commits");
1063
+ }
1064
+ for (const { id } of entries) {
1065
+ this.validateId(id);
1066
+ }
1067
+ const contentMap = {};
1068
+ const filePaths = [];
1069
+ for (const { id, value } of entries) {
1070
+ const filePath = this.buildFilePath(id);
1071
+ contentMap[filePath] = JSON.stringify(value, null, 2);
1072
+ filePaths.push(filePath);
1073
+ }
1074
+ await this.gitModule.add(filePaths, { contentMap });
1075
+ const message = opts?.commitMessage ?? `putMany ${entries.length} records`;
1076
+ const commitSha = await this.gitModule.commit(message);
1077
+ return { commitSha };
1147
1078
  }
1148
- /**
1149
- * Load project configuration from GitHub Contents API.
1150
- *
1151
- * [EARS-A1] Returns GitGovConfig when valid JSON is found.
1152
- * [EARS-A2] Returns null on 404 (fail-safe).
1153
- * [EARS-A3] Returns null on invalid JSON (fail-safe).
1154
- * [EARS-B1] Fetches via Contents API with base64 decode.
1155
- * [EARS-B2] Caches SHA from response for subsequent saveConfig.
1156
- */
1157
- async loadConfig() {
1158
- const path2 = `${this.basePath}/config.json`;
1079
+ async delete(id, opts) {
1080
+ this.validateId(id);
1081
+ const filePath = this.buildFilePath(id);
1082
+ let sha = this.shaCache.get(filePath);
1083
+ if (sha === void 0) {
1084
+ try {
1085
+ const { data } = await this.octokit.rest.repos.getContent({
1086
+ owner: this.owner,
1087
+ repo: this.repo,
1088
+ path: filePath,
1089
+ ref: this.ref
1090
+ });
1091
+ if (Array.isArray(data) || data.type !== "file") {
1092
+ return {};
1093
+ }
1094
+ sha = data.sha;
1095
+ } catch (error) {
1096
+ if (isOctokitRequestError(error) && error.status === 404) {
1097
+ return {};
1098
+ }
1099
+ throw mapOctokitError(error, `GET ${filePath} (for delete)`);
1100
+ }
1101
+ }
1102
+ try {
1103
+ const { data } = await this.octokit.rest.repos.deleteFile({
1104
+ owner: this.owner,
1105
+ repo: this.repo,
1106
+ path: filePath,
1107
+ message: opts?.commitMessage ?? `delete ${id}`,
1108
+ sha,
1109
+ branch: this.ref
1110
+ });
1111
+ this.shaCache.delete(filePath);
1112
+ return { commitSha: data.commit.sha };
1113
+ } catch (error) {
1114
+ if (isOctokitRequestError(error) && error.status === 404) {
1115
+ this.shaCache.delete(filePath);
1116
+ return {};
1117
+ }
1118
+ throw mapOctokitError(error, `DELETE ${filePath}`);
1119
+ }
1120
+ }
1121
+ async list() {
1159
1122
  try {
1160
1123
  const { data } = await this.octokit.rest.repos.getContent({
1161
1124
  owner: this.owner,
1162
1125
  repo: this.repo,
1163
- path: path2,
1126
+ path: this.basePath,
1164
1127
  ref: this.ref
1165
1128
  });
1166
- if (Array.isArray(data) || data.type !== "file") {
1167
- return null;
1168
- }
1169
- if (!data.content) {
1170
- return null;
1171
- }
1172
- this.cachedSha = data.sha;
1173
- try {
1174
- const decoded = Buffer.from(data.content, "base64").toString("utf-8");
1175
- return JSON.parse(decoded);
1176
- } catch {
1177
- return null;
1129
+ if (!Array.isArray(data)) {
1130
+ return [];
1178
1131
  }
1132
+ const ids = data.filter((entry) => entry.name.endsWith(this.extension)).map((entry) => entry.name.slice(0, -this.extension.length));
1133
+ return this.idEncoder ? ids.map((encoded) => this.idEncoder.decode(encoded)) : ids;
1179
1134
  } catch (error) {
1180
1135
  if (isOctokitRequestError(error) && error.status === 404) {
1181
- return null;
1136
+ return [];
1182
1137
  }
1183
- throw mapOctokitError(error, `loadConfig ${this.owner}/${this.repo}/${path2}`);
1138
+ throw mapOctokitError(error, `GET ${this.basePath} (list)`);
1184
1139
  }
1185
1140
  }
1186
- /**
1187
- * Save project configuration to GitHub via Contents API PUT.
1188
- *
1189
- * [EARS-A4] Writes config via PUT to Contents API.
1190
- * [EARS-B3] Includes cached SHA for updates (optimistic concurrency).
1191
- * [EARS-B4] Omits SHA for initial creation.
1192
- * [EARS-C1] Throws PERMISSION_DENIED on 401/403.
1193
- * [EARS-C2] Throws CONFLICT on 409.
1194
- * [EARS-C3] Throws SERVER_ERROR on 5xx.
1195
- */
1196
- async saveConfig(config) {
1197
- const path2 = `${this.basePath}/config.json`;
1198
- const content = Buffer.from(JSON.stringify(config, null, 2)).toString("base64");
1141
+ async exists(id) {
1142
+ this.validateId(id);
1143
+ const filePath = this.buildFilePath(id);
1199
1144
  try {
1200
- const { data } = await this.octokit.rest.repos.createOrUpdateFileContents({
1145
+ await this.octokit.rest.repos.getContent({
1201
1146
  owner: this.owner,
1202
1147
  repo: this.repo,
1203
- path: path2,
1204
- message: "chore(config): update gitgov config.json",
1205
- content,
1206
- branch: this.ref,
1207
- ...this.cachedSha ? { sha: this.cachedSha } : {}
1148
+ path: filePath,
1149
+ ref: this.ref
1208
1150
  });
1209
- if (data.content?.sha) {
1210
- this.cachedSha = data.content.sha;
1211
- }
1212
- return { commitSha: data.commit.sha };
1151
+ return true;
1213
1152
  } catch (error) {
1214
- throw mapOctokitError(error, `saveConfig ${this.owner}/${this.repo}/${path2}`);
1153
+ if (isOctokitRequestError(error) && error.status === 404) {
1154
+ return false;
1155
+ }
1156
+ throw mapOctokitError(error, `GET ${filePath} (exists)`);
1157
+ }
1158
+ }
1159
+ // ─────────────────────────────────────────────────────────
1160
+ // Private helpers
1161
+ // ─────────────────────────────────────────────────────────
1162
+ validateId(id) {
1163
+ if (!id || typeof id !== "string") {
1164
+ throw new GitHubApiError("ID must be a non-empty string", "INVALID_ID");
1165
+ }
1166
+ if (id.includes("..") || /[\/\\]/.test(id)) {
1167
+ throw new GitHubApiError(
1168
+ `Invalid ID: "${id}". IDs cannot contain /, \\, or ..`,
1169
+ "INVALID_ID"
1170
+ );
1215
1171
  }
1216
1172
  }
1173
+ buildFilePath(id) {
1174
+ const encoded = this.idEncoder ? this.idEncoder.encode(id) : id;
1175
+ return `${this.basePath}/${encoded}${this.extension}`;
1176
+ }
1217
1177
  };
1218
1178
 
1219
- // src/sync_state/sync_state.types.ts
1220
- var SYNC_DIRECTORIES = [
1221
- "tasks",
1222
- "cycles",
1223
- "actors",
1224
- "agents",
1225
- "feedbacks",
1226
- "executions",
1227
- "changelogs",
1228
- "workflows"
1229
- ];
1230
- var SYNC_ROOT_FILES = [
1231
- "config.json"
1232
- ];
1233
- var SYNC_ALLOWED_EXTENSIONS = [".json"];
1234
- var SYNC_EXCLUDED_PATTERNS = [
1235
- /\.key$/,
1236
- // Private keys (e.g., keys/*.key)
1237
- /\.backup$/,
1238
- // Backup files from lint
1239
- /\.backup-\d+$/,
1240
- // Numbered backup files
1241
- /\.tmp$/,
1242
- // Temporary files
1243
- /\.bak$/
1244
- // Backup files
1245
- ];
1246
- var LOCAL_ONLY_FILES = [
1247
- "index.json",
1248
- // Generated index, rebuilt on each machine
1249
- ".session.json",
1250
- // Local session state for current user/agent
1251
- "gitgov"
1252
- // Local binary/script
1253
- ];
1254
-
1255
- // src/sync_state/sync_state.utils.ts
1256
- function shouldSyncFile(filePath) {
1257
- const fileName = path.basename(filePath);
1258
- const ext = path.extname(filePath);
1259
- if (!SYNC_ALLOWED_EXTENSIONS.includes(ext)) {
1260
- return false;
1261
- }
1262
- for (const pattern of SYNC_EXCLUDED_PATTERNS) {
1263
- if (pattern.test(fileName)) {
1264
- return false;
1265
- }
1179
+ // src/git/errors.ts
1180
+ var GitError = class _GitError extends Error {
1181
+ constructor(message) {
1182
+ super(message);
1183
+ this.name = "GitError";
1184
+ Object.setPrototypeOf(this, _GitError.prototype);
1266
1185
  }
1267
- if (LOCAL_ONLY_FILES.includes(fileName)) {
1268
- return false;
1186
+ };
1187
+ var BranchNotFoundError = class _BranchNotFoundError extends GitError {
1188
+ branchName;
1189
+ constructor(branchName) {
1190
+ super(`Branch not found: ${branchName}`);
1191
+ this.name = "BranchNotFoundError";
1192
+ this.branchName = branchName;
1193
+ Object.setPrototypeOf(this, _BranchNotFoundError.prototype);
1269
1194
  }
1270
- const normalizedPath = filePath.replace(/\\/g, "/");
1271
- const parts = normalizedPath.split("/");
1272
- const gitgovIndex = parts.findIndex((p) => p === ".gitgov");
1273
- let relativeParts;
1274
- if (gitgovIndex !== -1) {
1275
- relativeParts = parts.slice(gitgovIndex + 1);
1276
- } else {
1277
- const syncDirIndex = parts.findIndex(
1278
- (p) => SYNC_DIRECTORIES.includes(p)
1279
- );
1280
- if (syncDirIndex !== -1) {
1281
- relativeParts = parts.slice(syncDirIndex);
1282
- } else if (SYNC_ROOT_FILES.includes(fileName)) {
1283
- return true;
1284
- } else {
1285
- return false;
1286
- }
1195
+ };
1196
+ var FileNotFoundError = class _FileNotFoundError extends GitError {
1197
+ filePath;
1198
+ commitHash;
1199
+ constructor(filePath, commitHash) {
1200
+ super(`File not found: ${filePath} in commit ${commitHash}`);
1201
+ this.name = "FileNotFoundError";
1202
+ this.filePath = filePath;
1203
+ this.commitHash = commitHash;
1204
+ Object.setPrototypeOf(this, _FileNotFoundError.prototype);
1287
1205
  }
1288
- if (relativeParts.length === 1) {
1289
- return SYNC_ROOT_FILES.includes(relativeParts[0]);
1290
- } else if (relativeParts.length >= 2) {
1291
- const dirName = relativeParts[0];
1292
- return SYNC_DIRECTORIES.includes(dirName);
1206
+ };
1207
+ var BranchAlreadyExistsError = class _BranchAlreadyExistsError extends GitError {
1208
+ branchName;
1209
+ constructor(branchName) {
1210
+ super(`Branch already exists: ${branchName}`);
1211
+ this.name = "BranchAlreadyExistsError";
1212
+ this.branchName = branchName;
1213
+ Object.setPrototypeOf(this, _BranchAlreadyExistsError.prototype);
1293
1214
  }
1294
- return false;
1295
- }
1215
+ };
1296
1216
 
1297
- // src/sync_state/github_sync_state/github_sync_state.ts
1298
- var GithubSyncStateModule = class {
1299
- deps;
1300
- lastKnownSha = null;
1301
- constructor(deps) {
1302
- this.deps = deps;
1217
+ // src/git/github/github_git_module.ts
1218
+ var GitHubGitModule = class {
1219
+ owner;
1220
+ repo;
1221
+ defaultBranch;
1222
+ octokit;
1223
+ /** Staging buffer: path → content (null = delete) */
1224
+ stagingBuffer = /* @__PURE__ */ new Map();
1225
+ /** Active ref for operations (can be changed via checkoutBranch) */
1226
+ activeRef;
1227
+ constructor(options, octokit) {
1228
+ this.owner = options.owner;
1229
+ this.repo = options.repo;
1230
+ this.defaultBranch = options.defaultBranch ?? "gitgov-state";
1231
+ this.octokit = octokit;
1232
+ this.activeRef = this.defaultBranch;
1303
1233
  }
1304
- // ==================== Block A: Branch Management ====================
1234
+ // ═══════════════════════════════════════════════════════════════
1235
+ // PRIVATE HELPERS
1236
+ // ═══════════════════════════════════════════════════════════════
1237
+ /** Category C: Not supported via GitHub API */
1238
+ notSupported(method) {
1239
+ throw new GitError(
1240
+ `${method} is not supported via GitHub API`
1241
+ );
1242
+ }
1243
+ // ═══════════════════════════════════════════════════════════════
1244
+ // CATEGORY A: READ OPERATIONS (EARS-A1 to A6)
1245
+ // ═══════════════════════════════════════════════════════════════
1305
1246
  /**
1306
- * [EARS-GS-A3] Returns the configured state branch name.
1247
+ * [EARS-A1] Read file content via Contents API + base64 decode
1248
+ * [EARS-A2] Fallback to Blobs API for files >1MB
1307
1249
  */
1308
- async getStateBranchName() {
1309
- return "gitgov-state";
1250
+ async getFileContent(commitHash, filePath) {
1251
+ try {
1252
+ const { data } = await this.octokit.rest.repos.getContent({
1253
+ owner: this.owner,
1254
+ repo: this.repo,
1255
+ path: filePath,
1256
+ ref: commitHash
1257
+ });
1258
+ if (Array.isArray(data) || data.type !== "file") {
1259
+ throw new GitError(`Not a file: ${filePath}`);
1260
+ }
1261
+ if (data.content !== null && data.content !== void 0) {
1262
+ return Buffer.from(data.content, "base64").toString("utf-8");
1263
+ }
1264
+ const { data: blobData } = await this.octokit.rest.git.getBlob({
1265
+ owner: this.owner,
1266
+ repo: this.repo,
1267
+ file_sha: data.sha
1268
+ });
1269
+ return Buffer.from(blobData.content, "base64").toString("utf-8");
1270
+ } catch (error) {
1271
+ if (error instanceof GitError) throw error;
1272
+ if (isOctokitRequestError(error)) {
1273
+ if (error.status === 404) {
1274
+ throw new FileNotFoundError(filePath, commitHash);
1275
+ }
1276
+ if (error.status === 401 || error.status === 403) {
1277
+ throw new GitError(`authentication/permission error (${error.status}): getFileContent ${filePath}`);
1278
+ }
1279
+ if (error.status >= 500) {
1280
+ throw new GitError(`GitHub server error (${error.status}): getFileContent ${filePath}`);
1281
+ }
1282
+ throw new GitError(`GitHub API error (${error.status}): getFileContent ${filePath}`);
1283
+ }
1284
+ const msg = error instanceof Error ? error.message : String(error);
1285
+ throw new GitError(`network error: ${msg}`);
1286
+ }
1310
1287
  }
1311
1288
  /**
1312
- * [EARS-GS-A1] Creates gitgov-state branch if it does not exist.
1313
- * [EARS-GS-A2] Idempotent no-op if branch already exists.
1289
+ * [EARS-A3] Get commit SHA from branch via Refs API
1290
+ * [EARS-B4] Return SHA directly if already a 40-char hex
1314
1291
  */
1315
- async ensureStateBranch() {
1316
- const branchName = await this.getStateBranchName();
1292
+ async getCommitHash(ref = this.activeRef) {
1293
+ if (/^[0-9a-f]{40}$/i.test(ref)) {
1294
+ return ref;
1295
+ }
1317
1296
  try {
1318
- await this.deps.octokit.rest.repos.getBranch({
1319
- owner: this.deps.owner,
1320
- repo: this.deps.repo,
1321
- branch: branchName
1297
+ const { data } = await this.octokit.rest.git.getRef({
1298
+ owner: this.owner,
1299
+ repo: this.repo,
1300
+ ref: `heads/${ref}`
1322
1301
  });
1323
- return;
1302
+ return data.object.sha;
1324
1303
  } catch (error) {
1325
- if (!isOctokitRequestError(error) || error.status !== 404) {
1326
- throw error;
1304
+ if (isOctokitRequestError(error)) {
1305
+ if (error.status === 404) {
1306
+ throw new BranchNotFoundError(ref);
1307
+ }
1308
+ if (error.status === 401 || error.status === 403) {
1309
+ throw new GitError(`authentication/permission error (${error.status}): getCommitHash ${ref}`);
1310
+ }
1311
+ if (error.status >= 500) {
1312
+ throw new GitError(`GitHub server error (${error.status}): getCommitHash ${ref}`);
1313
+ }
1327
1314
  }
1315
+ const msg = error instanceof Error ? error.message : String(error);
1316
+ throw new GitError(`network error: ${msg}`);
1328
1317
  }
1329
- const { data: repoData } = await this.deps.octokit.rest.repos.get({
1330
- owner: this.deps.owner,
1331
- repo: this.deps.repo
1332
- });
1333
- const defaultBranch = repoData.default_branch;
1334
- const { data: refData } = await this.deps.octokit.rest.git.getRef({
1335
- owner: this.deps.owner,
1336
- repo: this.deps.repo,
1337
- ref: `heads/${defaultBranch}`
1338
- });
1339
- await this.deps.octokit.rest.git.createRef({
1340
- owner: this.deps.owner,
1341
- repo: this.deps.repo,
1342
- ref: `refs/heads/${branchName}`,
1343
- sha: refData.object.sha
1344
- });
1345
1318
  }
1346
- // ==================== Block B: Push State ====================
1347
1319
  /**
1348
- * [EARS-GS-B1..B5] Push local .gitgov/ state to gitgov-state branch via API.
1349
- *
1350
- * Uses the 6-step atomic commit pattern:
1351
- * getRef → getCommit → createBlob → createTree → createCommit → updateRef
1352
- *
1353
- * Optimistic concurrency: if remote ref advanced since our read, updateRef
1354
- * fails with 422 → return conflictDetected: true.
1320
+ * [EARS-A4] List changed files via Compare API
1355
1321
  */
1356
- async pushState(options) {
1357
- const branchName = await this.getStateBranchName();
1358
- const sourceBranch = options.sourceBranch ?? "main";
1322
+ async getChangedFiles(fromCommit, toCommit, pathFilter) {
1359
1323
  try {
1360
- const { data: stateRefData } = await this.deps.octokit.rest.git.getRef({
1361
- owner: this.deps.owner,
1362
- repo: this.deps.repo,
1363
- ref: `heads/${branchName}`
1364
- });
1365
- const currentSha = stateRefData.object.sha;
1366
- const { data: sourceTree } = await this.deps.octokit.rest.git.getTree({
1367
- owner: this.deps.owner,
1368
- repo: this.deps.repo,
1369
- tree_sha: sourceBranch,
1370
- recursive: "true"
1371
- });
1372
- const sourceFiles = (sourceTree.tree ?? []).filter(
1373
- (item) => item.type === "blob" && item.path?.startsWith(".gitgov/") && shouldSyncFile(item.path)
1374
- );
1375
- const { data: targetCommit } = await this.deps.octokit.rest.git.getCommit({
1376
- owner: this.deps.owner,
1377
- repo: this.deps.repo,
1378
- commit_sha: currentSha
1379
- });
1380
- const { data: targetTree } = await this.deps.octokit.rest.git.getTree({
1381
- owner: this.deps.owner,
1382
- repo: this.deps.repo,
1383
- tree_sha: targetCommit.tree.sha,
1384
- recursive: "true"
1324
+ const { data } = await this.octokit.rest.repos.compareCommits({
1325
+ owner: this.owner,
1326
+ repo: this.repo,
1327
+ base: fromCommit,
1328
+ head: toCommit
1385
1329
  });
1386
- const targetFileMap = /* @__PURE__ */ new Map();
1387
- for (const item of targetTree.tree ?? []) {
1388
- if (item.type === "blob" && item.path && item.sha) {
1389
- targetFileMap.set(item.path, item.sha);
1330
+ const statusMap = {
1331
+ added: "A",
1332
+ modified: "M",
1333
+ removed: "D",
1334
+ renamed: "M"
1335
+ };
1336
+ const files = (data.files ?? []).map((f) => ({
1337
+ status: statusMap[f.status] ?? "M",
1338
+ file: f.filename
1339
+ })).filter((f) => !pathFilter || f.file.startsWith(pathFilter));
1340
+ return files;
1341
+ } catch (error) {
1342
+ if (isOctokitRequestError(error)) {
1343
+ if (error.status === 401 || error.status === 403) {
1344
+ throw new GitError(`authentication/permission error (${error.status}): getChangedFiles ${fromCommit}...${toCommit}`);
1390
1345
  }
1391
- }
1392
- const delta = [];
1393
- const treeEntries = [];
1394
- for (const sourceFile of sourceFiles) {
1395
- if (!sourceFile.path || !sourceFile.sha) continue;
1396
- const statePath = sourceFile.path.replace(/^\.gitgov\//, "");
1397
- const targetSha = targetFileMap.get(statePath);
1398
- if (targetSha !== sourceFile.sha) {
1399
- delta.push({
1400
- status: targetSha ? "M" : "A",
1401
- file: statePath
1402
- });
1403
- treeEntries.push({
1404
- path: statePath,
1405
- mode: "100644",
1406
- type: "blob",
1407
- sha: sourceFile.sha
1408
- });
1346
+ if (error.status >= 500) {
1347
+ throw new GitError(`GitHub server error (${error.status}): getChangedFiles`);
1409
1348
  }
1410
- targetFileMap.delete(statePath);
1349
+ throw new GitError(`Failed to compare ${fromCommit}...${toCommit}: HTTP ${error.status}`);
1411
1350
  }
1412
- for (const [deletedPath] of targetFileMap) {
1413
- if (shouldSyncFile(deletedPath)) {
1414
- delta.push({ status: "D", file: deletedPath });
1415
- treeEntries.push({
1416
- path: deletedPath,
1417
- mode: "100644",
1418
- type: "blob",
1419
- sha: null
1420
- });
1351
+ const msg = error instanceof Error ? error.message : String(error);
1352
+ throw new GitError(`network error: ${msg}`);
1353
+ }
1354
+ }
1355
+ /**
1356
+ * [EARS-A5] Get commit history via Commits API
1357
+ */
1358
+ async getCommitHistory(branch, options) {
1359
+ try {
1360
+ const { data } = await this.octokit.rest.repos.listCommits({
1361
+ owner: this.owner,
1362
+ repo: this.repo,
1363
+ sha: branch,
1364
+ ...options?.maxCount !== void 0 && { per_page: options.maxCount },
1365
+ ...options?.pathFilter !== void 0 && { path: options.pathFilter }
1366
+ });
1367
+ return data.map((c) => ({
1368
+ hash: c.sha,
1369
+ message: c.commit.message,
1370
+ author: `${c.commit.author?.name ?? "unknown"} <${c.commit.author?.email ?? "unknown"}>`,
1371
+ date: c.commit.author?.date ?? ""
1372
+ }));
1373
+ } catch (error) {
1374
+ if (isOctokitRequestError(error)) {
1375
+ if (error.status === 401 || error.status === 403) {
1376
+ throw new GitError(`authentication/permission error (${error.status}): getCommitHistory ${branch}`);
1421
1377
  }
1378
+ if (error.status >= 500) {
1379
+ throw new GitError(`GitHub server error (${error.status}): getCommitHistory`);
1380
+ }
1381
+ throw new GitError(`Failed to get commit history: HTTP ${error.status}`);
1382
+ }
1383
+ const msg = error instanceof Error ? error.message : String(error);
1384
+ throw new GitError(`network error: ${msg}`);
1385
+ }
1386
+ }
1387
+ /**
1388
+ * [EARS-B3] Get commit history between two commits via Compare API
1389
+ */
1390
+ async getCommitHistoryRange(fromHash, toHash, options) {
1391
+ try {
1392
+ const { data } = await this.octokit.rest.repos.compareCommits({
1393
+ owner: this.owner,
1394
+ repo: this.repo,
1395
+ base: fromHash,
1396
+ head: toHash
1397
+ });
1398
+ let commits = data.commits.map((c) => ({
1399
+ hash: c.sha,
1400
+ message: c.commit.message,
1401
+ author: `${c.commit.author?.name ?? "unknown"} <${c.commit.author?.email ?? "unknown"}>`,
1402
+ date: c.commit.author?.date ?? ""
1403
+ }));
1404
+ if (options?.pathFilter) {
1405
+ const changedPaths = new Set((data.files ?? []).map((f) => f.filename));
1406
+ commits = commits.filter(
1407
+ () => Array.from(changedPaths).some((f) => f.startsWith(options.pathFilter))
1408
+ );
1422
1409
  }
1423
- if (delta.length === 0) {
1424
- return {
1425
- success: true,
1426
- filesSynced: 0,
1427
- sourceBranch,
1428
- commitHash: null,
1429
- commitMessage: null,
1430
- conflictDetected: false
1431
- };
1410
+ if (options?.maxCount) {
1411
+ commits = commits.slice(0, options.maxCount);
1432
1412
  }
1433
- if (options.dryRun) {
1434
- return {
1435
- success: true,
1436
- filesSynced: delta.length,
1437
- sourceBranch,
1438
- commitHash: null,
1439
- commitMessage: `[dry-run] gitgov sync: ${delta.length} files`,
1440
- conflictDetected: false
1441
- };
1413
+ return commits;
1414
+ } catch (error) {
1415
+ if (isOctokitRequestError(error)) {
1416
+ if (error.status === 401 || error.status === 403) {
1417
+ throw new GitError(`authentication/permission error (${error.status}): getCommitHistoryRange ${fromHash}...${toHash}`);
1418
+ }
1419
+ if (error.status >= 500) {
1420
+ throw new GitError(`GitHub server error (${error.status}): getCommitHistoryRange`);
1421
+ }
1422
+ throw new GitError(`Failed to get commit range: HTTP ${error.status}`);
1442
1423
  }
1443
- const { data: newTreeData } = await this.deps.octokit.rest.git.createTree({
1444
- owner: this.deps.owner,
1445
- repo: this.deps.repo,
1446
- base_tree: targetCommit.tree.sha,
1447
- tree: treeEntries
1448
- });
1449
- const commitMessage = `gitgov sync: ${delta.length} files from ${sourceBranch}`;
1450
- const { data: newCommitData } = await this.deps.octokit.rest.git.createCommit({
1451
- owner: this.deps.owner,
1452
- repo: this.deps.repo,
1453
- message: commitMessage,
1454
- tree: newTreeData.sha,
1455
- parents: [currentSha]
1424
+ const msg = error instanceof Error ? error.message : String(error);
1425
+ throw new GitError(`network error: ${msg}`);
1426
+ }
1427
+ }
1428
+ /**
1429
+ * [EARS-A6] Get commit message via Commits API
1430
+ */
1431
+ async getCommitMessage(commitHash) {
1432
+ try {
1433
+ const { data } = await this.octokit.rest.repos.getCommit({
1434
+ owner: this.owner,
1435
+ repo: this.repo,
1436
+ ref: commitHash
1456
1437
  });
1457
- try {
1458
- await this.deps.octokit.rest.git.updateRef({
1459
- owner: this.deps.owner,
1460
- repo: this.deps.repo,
1461
- ref: `heads/${branchName}`,
1462
- sha: newCommitData.sha
1463
- });
1464
- } catch (error) {
1465
- if (isOctokitRequestError(error) && (error.status === 422 || error.status === 409)) {
1466
- return {
1467
- success: false,
1468
- filesSynced: 0,
1469
- sourceBranch,
1470
- commitHash: null,
1471
- commitMessage: null,
1472
- conflictDetected: true,
1473
- conflictInfo: {
1474
- type: "rebase_conflict",
1475
- affectedFiles: delta.map((d) => d.file),
1476
- message: "Remote gitgov-state ref has advanced since last read. Pull and retry.",
1477
- resolutionSteps: [
1478
- "Call pullState() to fetch latest remote state",
1479
- "Retry pushState() with updated parent SHA"
1480
- ]
1481
- }
1482
- };
1438
+ return data.commit.message;
1439
+ } catch (error) {
1440
+ if (isOctokitRequestError(error)) {
1441
+ if (error.status === 404) {
1442
+ throw new GitError(`Commit not found: ${commitHash}`);
1443
+ }
1444
+ if (error.status === 401 || error.status === 403) {
1445
+ throw new GitError(`authentication/permission error (${error.status}): getCommitMessage ${commitHash}`);
1446
+ }
1447
+ if (error.status >= 500) {
1448
+ throw new GitError(`GitHub server error (${error.status}): getCommitMessage`);
1483
1449
  }
1484
- throw error;
1485
1450
  }
1486
- this.lastKnownSha = newCommitData.sha;
1487
- return {
1488
- success: true,
1489
- filesSynced: delta.length,
1490
- sourceBranch,
1491
- commitHash: newCommitData.sha,
1492
- commitMessage,
1493
- conflictDetected: false
1494
- };
1451
+ const msg = error instanceof Error ? error.message : String(error);
1452
+ throw new GitError(`network error: ${msg}`);
1453
+ }
1454
+ }
1455
+ // ═══════════════════════════════════════════════════════════════
1456
+ // CATEGORY A: BRANCH OPERATIONS (EARS-B1 to B2)
1457
+ // ═══════════════════════════════════════════════════════════════
1458
+ /**
1459
+ * [EARS-B1] Check if branch exists via Branches API
1460
+ */
1461
+ async branchExists(branchName) {
1462
+ try {
1463
+ await this.octokit.rest.repos.getBranch({
1464
+ owner: this.owner,
1465
+ repo: this.repo,
1466
+ branch: branchName
1467
+ });
1468
+ return true;
1495
1469
  } catch (error) {
1496
1470
  if (isOctokitRequestError(error)) {
1497
- return {
1498
- success: false,
1499
- filesSynced: 0,
1500
- sourceBranch,
1501
- commitHash: null,
1502
- commitMessage: null,
1503
- conflictDetected: false,
1504
- error: `GitHub API error (${error.status}): ${error.message}`
1505
- };
1471
+ if (error.status === 404) return false;
1472
+ if (error.status === 401 || error.status === 403) {
1473
+ throw new GitError(`authentication/permission error (${error.status}): branchExists ${branchName}`);
1474
+ }
1475
+ throw new GitError(`Failed to check branch: HTTP ${error.status}`);
1506
1476
  }
1507
1477
  const msg = error instanceof Error ? error.message : String(error);
1508
- return {
1509
- success: false,
1510
- filesSynced: 0,
1511
- sourceBranch,
1512
- commitHash: null,
1513
- commitMessage: null,
1514
- conflictDetected: false,
1515
- error: msg
1516
- };
1478
+ throw new GitError(`network error: ${msg}`);
1517
1479
  }
1518
1480
  }
1519
- // ==================== Block C: Pull State ====================
1520
1481
  /**
1521
- * [EARS-GS-C1..C4] Pull remote state from gitgov-state branch.
1522
- *
1523
- * Fetches tree + blobs, updates lastKnownSha, triggers re-indexing.
1482
+ * [EARS-B2] List remote branches via Branches API
1483
+ * remoteName is ignored — repo itself is the implicit remote
1524
1484
  */
1525
- async pullState(options) {
1526
- const branchName = await this.getStateBranchName();
1527
- let remoteSha;
1485
+ async listRemoteBranches(_remoteName) {
1528
1486
  try {
1529
- const { data: refData } = await this.deps.octokit.rest.git.getRef({
1530
- owner: this.deps.owner,
1531
- repo: this.deps.repo,
1532
- ref: `heads/${branchName}`
1487
+ const { data } = await this.octokit.rest.repos.listBranches({
1488
+ owner: this.owner,
1489
+ repo: this.repo
1533
1490
  });
1534
- remoteSha = refData.object.sha;
1491
+ return data.map((b) => b.name);
1535
1492
  } catch (error) {
1536
- if (isOctokitRequestError(error) && error.status === 404) {
1537
- return {
1538
- success: true,
1539
- hasChanges: false,
1540
- filesUpdated: 0,
1541
- reindexed: false,
1542
- conflictDetected: false
1543
- };
1493
+ if (isOctokitRequestError(error)) {
1494
+ if (error.status === 401 || error.status === 403) {
1495
+ throw new GitError(`authentication/permission error (${error.status}): listRemoteBranches`);
1496
+ }
1497
+ if (error.status >= 500) {
1498
+ throw new GitError(`GitHub server error (${error.status}): listRemoteBranches`);
1499
+ }
1500
+ throw new GitError(`Failed to list branches: HTTP ${error.status}`);
1544
1501
  }
1545
- throw error;
1502
+ const msg = error instanceof Error ? error.message : String(error);
1503
+ throw new GitError(`network error: ${msg}`);
1546
1504
  }
1547
- if (this.lastKnownSha === remoteSha && !options?.forceReindex) {
1548
- return {
1549
- success: true,
1550
- hasChanges: false,
1551
- filesUpdated: 0,
1552
- reindexed: false,
1553
- conflictDetected: false
1554
- };
1505
+ }
1506
+ // ═══════════════════════════════════════════════════════════════
1507
+ // CATEGORY A: WRITE OPERATIONS (EARS-C1 to C7)
1508
+ // ═══════════════════════════════════════════════════════════════
1509
+ /** [EARS-C1] Read file content and store in staging buffer */
1510
+ async add(filePaths, options) {
1511
+ for (const filePath of filePaths) {
1512
+ const content = options?.contentMap?.[filePath] ?? await this.getFileContent(this.activeRef, filePath);
1513
+ this.stagingBuffer.set(filePath, content);
1555
1514
  }
1556
- const { data: commitData } = await this.deps.octokit.rest.git.getCommit({
1557
- owner: this.deps.owner,
1558
- repo: this.deps.repo,
1559
- commit_sha: remoteSha
1560
- });
1561
- const { data: treeData } = await this.deps.octokit.rest.git.getTree({
1562
- owner: this.deps.owner,
1563
- repo: this.deps.repo,
1564
- tree_sha: commitData.tree.sha,
1565
- recursive: "true"
1566
- });
1567
- const syncableFiles = (treeData.tree ?? []).filter(
1568
- (item) => item.type === "blob" && item.path && shouldSyncFile(item.path)
1569
- );
1570
- const filesUpdated = syncableFiles.length;
1571
- this.lastKnownSha = remoteSha;
1572
- let reindexed = false;
1573
- if (filesUpdated > 0 || options?.forceReindex) {
1574
- try {
1575
- await this.deps.indexer.computeProjection();
1576
- reindexed = true;
1577
- } catch {
1578
- reindexed = false;
1579
- }
1515
+ }
1516
+ /** [EARS-C2] Mark files as deleted in staging buffer */
1517
+ async rm(filePaths) {
1518
+ for (const filePath of filePaths) {
1519
+ this.stagingBuffer.set(filePath, null);
1580
1520
  }
1581
- return {
1582
- success: true,
1583
- hasChanges: filesUpdated > 0,
1584
- filesUpdated,
1585
- reindexed,
1586
- conflictDetected: false
1587
- };
1588
1521
  }
1589
- // ==================== Block D: Change Detection ====================
1522
+ /** [EARS-C7] Return staged file paths from buffer */
1523
+ async getStagedFiles() {
1524
+ return Array.from(this.stagingBuffer.keys());
1525
+ }
1590
1526
  /**
1591
- * [EARS-GS-D1..D3] Calculate file delta between known state and current remote.
1527
+ * [EARS-C6] Create branch via Refs API POST
1592
1528
  */
1593
- async calculateStateDelta(_sourceBranch) {
1594
- const branchName = await this.getStateBranchName();
1595
- let currentSha;
1529
+ async createBranch(branchName, startPoint) {
1530
+ const sha = startPoint ? await this.getCommitHash(startPoint) : await this.getCommitHash(this.activeRef);
1596
1531
  try {
1597
- const { data: refData } = await this.deps.octokit.rest.git.getRef({
1598
- owner: this.deps.owner,
1599
- repo: this.deps.repo,
1600
- ref: `heads/${branchName}`
1532
+ await this.octokit.rest.git.createRef({
1533
+ owner: this.owner,
1534
+ repo: this.repo,
1535
+ ref: `refs/heads/${branchName}`,
1536
+ sha
1601
1537
  });
1602
- currentSha = refData.object.sha;
1603
1538
  } catch (error) {
1604
- if (isOctokitRequestError(error) && error.status === 404) {
1605
- return [];
1539
+ if (isOctokitRequestError(error)) {
1540
+ if (error.status === 422) {
1541
+ throw new BranchAlreadyExistsError(branchName);
1542
+ }
1543
+ if (error.status === 401 || error.status === 403) {
1544
+ throw new GitError(`authentication/permission error (${error.status}): createBranch ${branchName}`);
1545
+ }
1546
+ throw new GitError(`Failed to create branch ${branchName}: HTTP ${error.status}`);
1606
1547
  }
1607
- throw error;
1548
+ const msg = error instanceof Error ? error.message : String(error);
1549
+ throw new GitError(`network error: ${msg}`);
1608
1550
  }
1609
- if (this.lastKnownSha === currentSha) {
1610
- return [];
1551
+ }
1552
+ /**
1553
+ * Internal commit implementation shared by commit() and commitAllowEmpty().
1554
+ *
1555
+ * [EARS-C3] 6-step atomic transaction
1556
+ * [EARS-C4] Clears staging buffer after successful commit
1557
+ * [EARS-C5] Throws if staging buffer is empty (unless allowEmpty)
1558
+ */
1559
+ async commitInternal(message, author, allowEmpty = false) {
1560
+ if (!allowEmpty && this.stagingBuffer.size === 0) {
1561
+ throw new GitError("Nothing to commit: staging buffer is empty");
1611
1562
  }
1612
- if (this.lastKnownSha === null) {
1613
- const { data: commitData } = await this.deps.octokit.rest.git.getCommit({
1614
- owner: this.deps.owner,
1615
- repo: this.deps.repo,
1563
+ try {
1564
+ const { data: refData } = await this.octokit.rest.git.getRef({
1565
+ owner: this.owner,
1566
+ repo: this.repo,
1567
+ ref: `heads/${this.activeRef}`
1568
+ });
1569
+ const currentSha = refData.object.sha;
1570
+ const { data: commitData } = await this.octokit.rest.git.getCommit({
1571
+ owner: this.owner,
1572
+ repo: this.repo,
1616
1573
  commit_sha: currentSha
1617
1574
  });
1618
- const { data: treeData } = await this.deps.octokit.rest.git.getTree({
1619
- owner: this.deps.owner,
1620
- repo: this.deps.repo,
1621
- tree_sha: commitData.tree.sha,
1622
- recursive: "true"
1575
+ const treeSha = commitData.tree.sha;
1576
+ const treeEntries = [];
1577
+ for (const [path2, content] of this.stagingBuffer) {
1578
+ if (content === null) {
1579
+ treeEntries.push({
1580
+ path: path2,
1581
+ mode: "100644",
1582
+ type: "blob",
1583
+ sha: null
1584
+ });
1585
+ } else {
1586
+ const { data: blobData } = await this.octokit.rest.git.createBlob({
1587
+ owner: this.owner,
1588
+ repo: this.repo,
1589
+ content: Buffer.from(content).toString("base64"),
1590
+ encoding: "base64"
1591
+ });
1592
+ treeEntries.push({
1593
+ path: path2,
1594
+ mode: "100644",
1595
+ type: "blob",
1596
+ sha: blobData.sha
1597
+ });
1598
+ }
1599
+ }
1600
+ const { data: treeData } = await this.octokit.rest.git.createTree({
1601
+ owner: this.owner,
1602
+ repo: this.repo,
1603
+ base_tree: treeSha,
1604
+ tree: treeEntries
1623
1605
  });
1624
- return (treeData.tree ?? []).filter((item) => item.type === "blob" && item.path && shouldSyncFile(item.path)).map((item) => ({
1625
- status: "A",
1626
- file: item.path
1627
- }));
1606
+ const newTreeSha = treeData.sha;
1607
+ const commitParams = {
1608
+ owner: this.owner,
1609
+ repo: this.repo,
1610
+ message,
1611
+ tree: newTreeSha,
1612
+ parents: [currentSha]
1613
+ };
1614
+ if (author) {
1615
+ commitParams.author = {
1616
+ name: author.name,
1617
+ email: author.email,
1618
+ date: (/* @__PURE__ */ new Date()).toISOString()
1619
+ };
1620
+ }
1621
+ const { data: newCommitData } = await this.octokit.rest.git.createCommit(commitParams);
1622
+ const newCommitSha = newCommitData.sha;
1623
+ try {
1624
+ await this.octokit.rest.git.updateRef({
1625
+ owner: this.owner,
1626
+ repo: this.repo,
1627
+ ref: `heads/${this.activeRef}`,
1628
+ sha: newCommitSha
1629
+ });
1630
+ } catch (error) {
1631
+ if (isOctokitRequestError(error) && error.status === 422) {
1632
+ throw new GitError("non-fast-forward update rejected");
1633
+ }
1634
+ throw error;
1635
+ }
1636
+ this.stagingBuffer.clear();
1637
+ return newCommitSha;
1638
+ } catch (error) {
1639
+ if (error instanceof GitError) throw error;
1640
+ if (isOctokitRequestError(error)) {
1641
+ if (error.status === 401 || error.status === 403) {
1642
+ throw new GitError(`authentication/permission error (${error.status}): commit`);
1643
+ }
1644
+ if (error.status >= 500) {
1645
+ throw new GitError(`GitHub server error (${error.status}): commit`);
1646
+ }
1647
+ throw new GitError(`GitHub API error (${error.status}): commit`);
1648
+ }
1649
+ const msg = error instanceof Error ? error.message : String(error);
1650
+ throw new GitError(`network error: ${msg}`);
1628
1651
  }
1629
- const { data: comparison } = await this.deps.octokit.rest.repos.compareCommits({
1630
- owner: this.deps.owner,
1631
- repo: this.deps.repo,
1632
- base: this.lastKnownSha,
1633
- head: currentSha
1634
- });
1635
- return (comparison.files ?? []).filter((file) => shouldSyncFile(file.filename)).map((file) => ({
1636
- status: file.status === "added" ? "A" : file.status === "removed" ? "D" : "M",
1637
- file: file.filename
1638
- }));
1639
1652
  }
1640
1653
  /**
1641
- * Always empty no local pending changes in API mode.
1642
- * In API mode there is no local filesystem; all state is remote.
1654
+ * [EARS-C3] Commit staged changes via 6-step atomic transaction
1655
+ * [EARS-C5] Throws if staging buffer is empty
1643
1656
  */
1644
- async getPendingChanges() {
1645
- return [];
1657
+ async commit(message, author) {
1658
+ return this.commitInternal(message, author, false);
1646
1659
  }
1647
- // ==================== Block E: Conflict Handling ====================
1648
- /**
1649
- * Always false — no rebase in API mode.
1650
- */
1660
+ // ═══════════════════════════════════════════════════════════════
1661
+ // CATEGORY B: NO-OPS (sensible defaults)
1662
+ // ═══════════════════════════════════════════════════════════════
1663
+ /** [EARS-D5] exec not supported in API mode */
1664
+ async exec(_command, _args, _options) {
1665
+ return { exitCode: 1, stdout: "", stderr: "exec() not supported in GitHub API mode" };
1666
+ }
1667
+ /** No-op: repos are created via GitHub API, not initialized locally */
1668
+ async init() {
1669
+ }
1670
+ /** [EARS-D1] Return virtual path representing the repo */
1671
+ async getRepoRoot() {
1672
+ return `github://${this.owner}/${this.repo}`;
1673
+ }
1674
+ /** [EARS-D1] Return active ref (starts as defaultBranch) */
1675
+ async getCurrentBranch() {
1676
+ return this.activeRef;
1677
+ }
1678
+ /** No-op: git config doesn't apply to GitHub API */
1679
+ async setConfig(_key, _value, _scope) {
1680
+ }
1681
+ /** [EARS-D1] Return true if staging buffer has entries */
1682
+ async hasUncommittedChanges(_pathFilter) {
1683
+ return this.stagingBuffer.size > 0;
1684
+ }
1685
+ /** No-op: GitHub API doesn't have rebase-in-progress concept */
1651
1686
  async isRebaseInProgress() {
1652
1687
  return false;
1653
1688
  }
1654
- /**
1655
- * Always empty — no conflict markers in API mode.
1656
- */
1657
- async checkConflictMarkers(_filePaths) {
1689
+ /** [EARS-D1] GitHub repos always have 'origin' conceptually */
1690
+ async isRemoteConfigured(_remoteName) {
1691
+ return true;
1692
+ }
1693
+ /** No-op: always 'origin' */
1694
+ async getBranchRemote(_branchName) {
1695
+ return "origin";
1696
+ }
1697
+ /** No-op: GitHub API handles merges atomically */
1698
+ async getConflictedFiles() {
1658
1699
  return [];
1659
1700
  }
1660
- /**
1661
- * Empty diff — no git-level conflict markers in API mode.
1662
- */
1663
- async getConflictDiff(_filePaths) {
1664
- return {
1665
- files: [],
1666
- message: "No conflict markers in API mode. Conflicts are SHA-based.",
1667
- resolutionSteps: [
1668
- "Call pullState() to fetch latest remote state",
1669
- "Retry pushState() with updated records"
1670
- ]
1671
- };
1701
+ /** [EARS-D2] Update activeRef for subsequent operations */
1702
+ async checkoutBranch(branchName) {
1703
+ this.activeRef = branchName;
1704
+ }
1705
+ /** No-op: GitHub API doesn't have stash concept */
1706
+ async stash(_message) {
1707
+ return null;
1708
+ }
1709
+ /** No-op */
1710
+ async stashPop() {
1711
+ return false;
1712
+ }
1713
+ /** No-op */
1714
+ async stashDrop(_stashHash) {
1715
+ }
1716
+ /** No-op: API always fresh */
1717
+ async fetch(_remote) {
1718
+ }
1719
+ /** No-op: API mode */
1720
+ async pull(_remote, _branchName) {
1721
+ }
1722
+ /** No-op: API mode */
1723
+ async pullRebase(_remote, _branchName) {
1724
+ }
1725
+ /** [EARS-D4] No-op: commits via API are already remote */
1726
+ async push(_remote, _branchName) {
1727
+ }
1728
+ /** [EARS-D4] No-op: commits via API are already remote */
1729
+ async pushWithUpstream(_remote, _branchName) {
1730
+ }
1731
+ /** No-op: API mode */
1732
+ async setUpstream(_branchName, _remote, _remoteBranch) {
1733
+ }
1734
+ /** No-op */
1735
+ async rebaseAbort() {
1736
+ }
1737
+ /** [EARS-D1] Delegates to commitInternal, allowing empty staging buffer */
1738
+ async commitAllowEmpty(message, author) {
1739
+ return this.commitInternal(message, author, true);
1740
+ }
1741
+ // ═══════════════════════════════════════════════════════════════
1742
+ // CATEGORY C: NOT SUPPORTED (throw GitError)
1743
+ // ═══════════════════════════════════════════════════════════════
1744
+ /** [EARS-D3] Not supported via GitHub API */
1745
+ async rebase(_targetBranch) {
1746
+ this.notSupported("rebase");
1747
+ }
1748
+ /** [EARS-D3] Not supported via GitHub API */
1749
+ async rebaseContinue() {
1750
+ this.notSupported("rebaseContinue");
1751
+ }
1752
+ /** [EARS-D3] Not supported via GitHub API */
1753
+ async resetHard(_target) {
1754
+ this.notSupported("resetHard");
1672
1755
  }
1673
- /**
1674
- * [EARS-GS-E1..E2] Resolve conflict by pulling latest and retrying push.
1675
- */
1676
- async resolveConflict(options) {
1677
- const pullResult = await this.pullState({ forceReindex: false });
1678
- if (!pullResult.success) {
1679
- return {
1680
- success: false,
1681
- rebaseCommitHash: "",
1682
- resolutionCommitHash: "",
1683
- conflictsResolved: 0,
1684
- resolvedBy: options.actorId,
1685
- reason: options.reason,
1686
- error: `Pull failed during conflict resolution: ${pullResult.error}`
1687
- };
1688
- }
1689
- const pushResult = await this.pushState({
1690
- actorId: options.actorId
1691
- });
1692
- if (!pushResult.success || pushResult.conflictDetected) {
1693
- const errorMsg = pushResult.conflictDetected ? "Content conflict: same file modified by both sides. Manual resolution required." : pushResult.error ?? "Unknown push error";
1694
- return {
1695
- success: false,
1696
- rebaseCommitHash: "",
1697
- resolutionCommitHash: "",
1698
- conflictsResolved: 0,
1699
- resolvedBy: options.actorId,
1700
- reason: options.reason,
1701
- error: errorMsg
1702
- };
1703
- }
1704
- return {
1705
- success: true,
1706
- rebaseCommitHash: this.lastKnownSha ?? "",
1707
- resolutionCommitHash: pushResult.commitHash ?? "",
1708
- conflictsResolved: pushResult.filesSynced,
1709
- resolvedBy: options.actorId,
1710
- reason: options.reason
1711
- };
1756
+ /** [EARS-D3] Not supported via GitHub API */
1757
+ async checkoutOrphanBranch(_branchName) {
1758
+ this.notSupported("checkoutOrphanBranch");
1712
1759
  }
1713
- /**
1714
- * No integrity violations in API mode (no rebase commits).
1715
- */
1716
- async verifyResolutionIntegrity() {
1717
- return [];
1760
+ /** [EARS-D3] Not supported via GitHub API */
1761
+ async checkoutFilesFromBranch(_sourceBranch, _filePaths) {
1762
+ this.notSupported("checkoutFilesFromBranch");
1763
+ }
1764
+ /** [EARS-D3] Not supported via GitHub API */
1765
+ async getMergeBase(_branchA, _branchB) {
1766
+ this.notSupported("getMergeBase");
1767
+ }
1768
+ };
1769
+
1770
+ // src/config_store/github/github_config_store.ts
1771
+ var GitHubConfigStore = class {
1772
+ owner;
1773
+ repo;
1774
+ ref;
1775
+ basePath;
1776
+ octokit;
1777
+ /** Cached blob SHA from the last loadConfig call, used for PUT updates */
1778
+ cachedSha = null;
1779
+ constructor(options, octokit) {
1780
+ this.owner = options.owner;
1781
+ this.repo = options.repo;
1782
+ this.ref = options.ref ?? "gitgov-state";
1783
+ this.basePath = options.basePath ?? ".gitgov";
1784
+ this.octokit = octokit;
1718
1785
  }
1719
- // ==================== Block F: Audit ====================
1720
1786
  /**
1721
- * [EARS-GS-F1..F2] Audit the remote gitgov-state branch.
1787
+ * Load project configuration from GitHub Contents API.
1788
+ *
1789
+ * [EARS-A1] Returns GitGovConfig when valid JSON is found.
1790
+ * [EARS-A2] Returns null on 404 (fail-safe).
1791
+ * [EARS-A3] Returns null on invalid JSON (fail-safe).
1792
+ * [EARS-B1] Fetches via Contents API with base64 decode.
1793
+ * [EARS-B2] Caches SHA from response for subsequent saveConfig.
1722
1794
  */
1723
- async auditState(options) {
1724
- const branchName = await this.getStateBranchName();
1725
- const scope = options?.scope ?? "all";
1726
- let totalCommits = 0;
1795
+ async loadConfig() {
1796
+ const path2 = `${this.basePath}/config.json`;
1727
1797
  try {
1728
- const { data: commits } = await this.deps.octokit.rest.repos.listCommits({
1729
- owner: this.deps.owner,
1730
- repo: this.deps.repo,
1731
- sha: branchName,
1732
- per_page: 100
1798
+ const { data } = await this.octokit.rest.repos.getContent({
1799
+ owner: this.owner,
1800
+ repo: this.repo,
1801
+ path: path2,
1802
+ ref: this.ref
1733
1803
  });
1734
- totalCommits = commits.length;
1735
- } catch (error) {
1736
- if (isOctokitRequestError(error) && error.status === 404) {
1737
- return {
1738
- passed: true,
1739
- scope,
1740
- totalCommits: 0,
1741
- rebaseCommits: 0,
1742
- resolutionCommits: 0,
1743
- integrityViolations: [],
1744
- summary: "Branch gitgov-state does not exist. No audit needed."
1745
- };
1804
+ if (Array.isArray(data) || data.type !== "file") {
1805
+ return null;
1746
1806
  }
1747
- throw error;
1748
- }
1749
- const rebaseCommits = 0;
1750
- const resolutionCommits = 0;
1751
- const integrityViolations = [];
1752
- let lintReport;
1753
- if (options?.verifySignatures !== false || options?.verifyChecksums !== false) {
1807
+ if (!data.content) {
1808
+ return null;
1809
+ }
1810
+ this.cachedSha = data.sha;
1754
1811
  try {
1755
- const { data: refData } = await this.deps.octokit.rest.git.getRef({
1756
- owner: this.deps.owner,
1757
- repo: this.deps.repo,
1758
- ref: `heads/${branchName}`
1759
- });
1760
- const { data: commitData } = await this.deps.octokit.rest.git.getCommit({
1761
- owner: this.deps.owner,
1762
- repo: this.deps.repo,
1763
- commit_sha: refData.object.sha
1764
- });
1765
- const { data: treeData } = await this.deps.octokit.rest.git.getTree({
1766
- owner: this.deps.owner,
1767
- repo: this.deps.repo,
1768
- tree_sha: commitData.tree.sha,
1769
- recursive: "true"
1770
- });
1771
- const treeItems = (treeData.tree ?? []).filter((item) => item.type === "blob" && item.path && item.sha && shouldSyncFile(item.path));
1772
- const startTime = Date.now();
1773
- const allResults = [];
1774
- let filesChecked = 0;
1775
- for (const item of treeItems) {
1776
- try {
1777
- const { data: blobData } = await this.deps.octokit.rest.git.getBlob({
1778
- owner: this.deps.owner,
1779
- repo: this.deps.repo,
1780
- file_sha: item.sha
1781
- });
1782
- const content = Buffer.from(blobData.content, "base64").toString("utf-8");
1783
- const record = JSON.parse(content);
1784
- const entityType = pathToEntityType(item.path);
1785
- if (entityType) {
1786
- const results = this.deps.lint.lintRecord(record, {
1787
- recordId: item.path.split("/").pop()?.replace(".json", "") ?? item.path,
1788
- entityType,
1789
- filePath: item.path
1790
- });
1791
- allResults.push(...results);
1792
- }
1793
- filesChecked++;
1794
- } catch {
1795
- }
1796
- }
1797
- if (filesChecked > 0) {
1798
- lintReport = {
1799
- summary: {
1800
- filesChecked,
1801
- errors: allResults.filter((r) => r.level === "error").length,
1802
- warnings: allResults.filter((r) => r.level === "warning").length,
1803
- fixable: allResults.filter((r) => r.fixable).length,
1804
- executionTime: Date.now() - startTime
1805
- },
1806
- results: allResults,
1807
- metadata: {
1808
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1809
- options: {},
1810
- version: "1.0.0"
1811
- }
1812
- };
1813
- }
1812
+ const decoded = Buffer.from(data.content, "base64").toString("utf-8");
1813
+ return JSON.parse(decoded);
1814
1814
  } catch {
1815
+ return null;
1815
1816
  }
1817
+ } catch (error) {
1818
+ if (isOctokitRequestError(error) && error.status === 404) {
1819
+ return null;
1820
+ }
1821
+ throw mapOctokitError(error, `loadConfig ${this.owner}/${this.repo}/${path2}`);
1816
1822
  }
1817
- const lintPassed = !lintReport || lintReport.summary.errors === 0;
1818
- const passed = integrityViolations.length === 0 && lintPassed;
1819
- const lintErrors = lintReport?.summary.errors ?? 0;
1820
- let summary;
1821
- if (passed) {
1822
- summary = `Audit passed. ${totalCommits} commits analyzed, 0 violations.`;
1823
- } else if (integrityViolations.length > 0 && lintErrors > 0) {
1824
- summary = `Audit failed. ${integrityViolations.length} integrity violations, ${lintErrors} lint errors.`;
1825
- } else if (lintErrors > 0) {
1826
- summary = `Audit failed. ${lintErrors} lint errors found.`;
1827
- } else {
1828
- summary = `Audit failed. ${integrityViolations.length} integrity violations found.`;
1829
- }
1830
- const report = {
1831
- passed,
1832
- scope,
1833
- totalCommits,
1834
- rebaseCommits,
1835
- resolutionCommits,
1836
- integrityViolations,
1837
- summary
1838
- };
1839
- if (lintReport) {
1840
- report.lintReport = lintReport;
1823
+ }
1824
+ /**
1825
+ * Save project configuration to GitHub via Contents API PUT.
1826
+ *
1827
+ * [EARS-A4] Writes config via PUT to Contents API.
1828
+ * [EARS-B3] Includes cached SHA for updates (optimistic concurrency).
1829
+ * [EARS-B4] Omits SHA for initial creation.
1830
+ * [EARS-C1] Throws PERMISSION_DENIED on 401/403.
1831
+ * [EARS-C2] Throws CONFLICT on 409.
1832
+ * [EARS-C3] Throws SERVER_ERROR on 5xx.
1833
+ */
1834
+ async saveConfig(config) {
1835
+ const path2 = `${this.basePath}/config.json`;
1836
+ const content = Buffer.from(JSON.stringify(config, null, 2)).toString("base64");
1837
+ try {
1838
+ const { data } = await this.octokit.rest.repos.createOrUpdateFileContents({
1839
+ owner: this.owner,
1840
+ repo: this.repo,
1841
+ path: path2,
1842
+ message: "chore(config): update gitgov config.json",
1843
+ content,
1844
+ branch: this.ref,
1845
+ ...this.cachedSha ? { sha: this.cachedSha } : {}
1846
+ });
1847
+ if (data.content?.sha) {
1848
+ this.cachedSha = data.content.sha;
1849
+ }
1850
+ return { commitSha: data.commit.sha };
1851
+ } catch (error) {
1852
+ throw mapOctokitError(error, `saveConfig ${this.owner}/${this.repo}/${path2}`);
1841
1853
  }
1842
- return report;
1843
1854
  }
1844
1855
  };
1845
- function pathToEntityType(filePath) {
1846
- const dirMap = {
1847
- tasks: "task",
1848
- cycles: "cycle",
1849
- actors: "actor",
1850
- agents: "agent",
1851
- feedbacks: "feedback",
1852
- executions: "execution",
1853
- changelogs: "changelog"
1854
- };
1855
- const firstSegment = filePath.split("/")[0] ?? "";
1856
- return dirMap[firstSegment];
1857
- }
1858
1856
 
1859
1857
  // src/github.ts
1860
1858
  var GitHubApiError = class _GitHubApiError extends Error {