@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.
- package/dist/src/{agent_runner-DijNVjaF.d.ts → agent_runner-D3G5zzGv.d.ts} +2 -2
- package/dist/src/fs.d.ts +20 -8
- package/dist/src/fs.js +537 -825
- package/dist/src/fs.js.map +1 -1
- package/dist/src/github.d.ts +115 -115
- package/dist/src/github.js +1594 -1596
- package/dist/src/github.js.map +1 -1
- package/dist/src/index.d.ts +617 -1212
- package/dist/src/index.js +550 -1176
- package/dist/src/index.js.map +1 -1
- package/dist/src/memory.d.ts +1 -1
- package/dist/src/prisma.d.ts +3 -1
- package/dist/src/prisma.js +7 -2
- package/dist/src/prisma.js.map +1 -1
- package/dist/src/{record_projection.types-D9NkQbL_.d.ts → record_projection.types-B2OZbgoW.d.ts} +127 -166
- package/dist/src/{sync_state-C2a2RuBQ.d.ts → sync_state-GmqG3pLj.d.ts} +7 -3
- package/package.json +1 -1
package/dist/src/github.js
CHANGED
|
@@ -1,1860 +1,1858 @@
|
|
|
1
|
-
import picomatch from 'picomatch';
|
|
2
1
|
import path from 'path';
|
|
2
|
+
import picomatch from 'picomatch';
|
|
3
3
|
|
|
4
|
-
// src/
|
|
4
|
+
// src/sync_state/sync_state.utils.ts
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
var
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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/
|
|
17
|
-
var
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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-
|
|
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
|
|
42
|
-
|
|
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-
|
|
61
|
-
* [EARS-
|
|
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
|
|
64
|
-
const
|
|
101
|
+
async ensureStateBranch() {
|
|
102
|
+
const branchName = await this.getStateBranchName();
|
|
65
103
|
try {
|
|
66
|
-
|
|
67
|
-
owner: this.owner,
|
|
68
|
-
repo: this.repo,
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
return true;
|
|
109
|
+
return;
|
|
76
110
|
} catch (error) {
|
|
77
|
-
if (isOctokitRequestError(error)) {
|
|
78
|
-
|
|
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-
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
|
112
|
-
const
|
|
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.
|
|
115
|
-
owner: this.owner,
|
|
116
|
-
repo: this.repo,
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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-
|
|
244
|
-
*
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
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.
|
|
252
|
-
owner: this.owner,
|
|
253
|
-
repo: this.repo,
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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-
|
|
377
|
+
* [EARS-GS-D1..D3] Calculate file delta between known state and current remote.
|
|
299
378
|
*/
|
|
300
|
-
async
|
|
379
|
+
async calculateStateDelta(_sourceBranch) {
|
|
380
|
+
const branchName = await this.getStateBranchName();
|
|
381
|
+
let currentSha;
|
|
301
382
|
try {
|
|
302
|
-
const { data } = await this.octokit.rest.git.
|
|
303
|
-
owner: this.owner,
|
|
304
|
-
repo: this.repo,
|
|
305
|
-
|
|
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
|
-
|
|
388
|
+
currentSha = refData.object.sha;
|
|
308
389
|
} catch (error) {
|
|
309
|
-
if (isOctokitRequestError(error)) {
|
|
310
|
-
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
406
|
-
this.
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
*
|
|
415
|
-
*
|
|
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
|
|
420
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
*
|
|
610
|
-
* [EARS-A2] Fallback to Blobs API for files >1MB
|
|
500
|
+
* No integrity violations in API mode (no rebase commits).
|
|
611
501
|
*/
|
|
612
|
-
async
|
|
613
|
-
|
|
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-
|
|
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
|
|
655
|
-
|
|
656
|
-
|
|
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.
|
|
660
|
-
owner: this.owner,
|
|
661
|
-
repo: this.repo,
|
|
662
|
-
|
|
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
|
-
|
|
520
|
+
totalCommits = commits.length;
|
|
665
521
|
} catch (error) {
|
|
666
|
-
if (isOctokitRequestError(error)) {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
678
|
-
throw new GitError(`network error: ${msg}`);
|
|
533
|
+
throw error;
|
|
679
534
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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 (
|
|
709
|
-
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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-
|
|
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
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
814
|
-
|
|
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-
|
|
698
|
+
* [EARS-A2] Checks if a file exists via Contents API.
|
|
699
|
+
* [EARS-B4] Returns false for 404 responses.
|
|
822
700
|
*/
|
|
823
|
-
async
|
|
701
|
+
async exists(filePath) {
|
|
702
|
+
const fullPath = this.buildFullPath(filePath);
|
|
824
703
|
try {
|
|
825
|
-
await this.octokit.rest.repos.
|
|
704
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
826
705
|
owner: this.owner,
|
|
827
706
|
repo: this.repo,
|
|
828
|
-
|
|
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
|
|
718
|
+
throw new FileListerError(
|
|
719
|
+
`Permission denied: ${filePath}`,
|
|
720
|
+
"PERMISSION_DENIED",
|
|
721
|
+
filePath
|
|
722
|
+
);
|
|
836
723
|
}
|
|
837
|
-
|
|
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
|
-
|
|
840
|
-
|
|
737
|
+
throw new FileListerError(
|
|
738
|
+
`Network error checking file: ${filePath}`,
|
|
739
|
+
"NETWORK_ERROR",
|
|
740
|
+
filePath
|
|
741
|
+
);
|
|
841
742
|
}
|
|
842
743
|
}
|
|
843
744
|
/**
|
|
844
|
-
* [EARS-
|
|
845
|
-
*
|
|
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
|
|
749
|
+
async read(filePath) {
|
|
750
|
+
const fullPath = this.buildFullPath(filePath);
|
|
848
751
|
try {
|
|
849
|
-
const { data } = await this.octokit.rest.repos.
|
|
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
|
-
|
|
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
|
|
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
|
|
787
|
+
throw new FileListerError(
|
|
788
|
+
`GitHub API server error (${error.status}): ${filePath}`,
|
|
789
|
+
"READ_ERROR",
|
|
790
|
+
filePath
|
|
791
|
+
);
|
|
861
792
|
}
|
|
862
|
-
throw new
|
|
793
|
+
throw new FileListerError(
|
|
794
|
+
`Unexpected GitHub API response (${error.status}): ${filePath}`,
|
|
795
|
+
"READ_ERROR",
|
|
796
|
+
filePath
|
|
797
|
+
);
|
|
863
798
|
}
|
|
864
|
-
|
|
865
|
-
|
|
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-
|
|
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
|
|
892
|
-
const
|
|
810
|
+
async stat(filePath) {
|
|
811
|
+
const fullPath = this.buildFullPath(filePath);
|
|
893
812
|
try {
|
|
894
|
-
await this.octokit.rest.
|
|
813
|
+
const { data } = await this.octokit.rest.repos.getContent({
|
|
895
814
|
owner: this.owner,
|
|
896
815
|
repo: this.repo,
|
|
897
|
-
|
|
898
|
-
|
|
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 ===
|
|
903
|
-
throw new
|
|
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
|
|
842
|
+
throw new FileListerError(
|
|
843
|
+
`Permission denied: ${filePath}`,
|
|
844
|
+
"PERMISSION_DENIED",
|
|
845
|
+
filePath
|
|
846
|
+
);
|
|
907
847
|
}
|
|
908
|
-
|
|
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
|
-
|
|
911
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
922
|
-
if (
|
|
923
|
-
|
|
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
|
|
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
|
-
|
|
966
|
-
|
|
892
|
+
tree_sha: this.ref,
|
|
893
|
+
recursive: "1"
|
|
967
894
|
});
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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.
|
|
999
|
-
return
|
|
901
|
+
this.treeCache = data.tree;
|
|
902
|
+
return this.treeCache;
|
|
1000
903
|
} catch (error) {
|
|
1001
|
-
if (error instanceof
|
|
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
|
|
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
|
|
919
|
+
throw new FileListerError(
|
|
920
|
+
`GitHub API server error (${error.status}) fetching tree`,
|
|
921
|
+
"READ_ERROR"
|
|
922
|
+
);
|
|
1008
923
|
}
|
|
1009
|
-
throw new
|
|
924
|
+
throw new FileListerError(
|
|
925
|
+
`Unexpected GitHub API response (${error.status}) fetching tree`,
|
|
926
|
+
"READ_ERROR"
|
|
927
|
+
);
|
|
1010
928
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
929
|
+
throw new FileListerError(
|
|
930
|
+
"Network error fetching repository tree",
|
|
931
|
+
"NETWORK_ERROR"
|
|
932
|
+
);
|
|
1013
933
|
}
|
|
1014
934
|
}
|
|
1015
935
|
/**
|
|
1016
|
-
* [EARS-
|
|
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
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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/
|
|
1133
|
-
var
|
|
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
|
-
/**
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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:
|
|
1126
|
+
path: this.basePath,
|
|
1164
1127
|
ref: this.ref
|
|
1165
1128
|
});
|
|
1166
|
-
if (Array.isArray(data)
|
|
1167
|
-
return
|
|
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
|
|
1136
|
+
return [];
|
|
1182
1137
|
}
|
|
1183
|
-
throw mapOctokitError(error, `
|
|
1138
|
+
throw mapOctokitError(error, `GET ${this.basePath} (list)`);
|
|
1184
1139
|
}
|
|
1185
1140
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1145
|
+
await this.octokit.rest.repos.getContent({
|
|
1201
1146
|
owner: this.owner,
|
|
1202
1147
|
repo: this.repo,
|
|
1203
|
-
path:
|
|
1204
|
-
|
|
1205
|
-
content,
|
|
1206
|
-
branch: this.ref,
|
|
1207
|
-
...this.cachedSha ? { sha: this.cachedSha } : {}
|
|
1148
|
+
path: filePath,
|
|
1149
|
+
ref: this.ref
|
|
1208
1150
|
});
|
|
1209
|
-
|
|
1210
|
-
this.cachedSha = data.content.sha;
|
|
1211
|
-
}
|
|
1212
|
-
return { commitSha: data.commit.sha };
|
|
1151
|
+
return true;
|
|
1213
1152
|
} catch (error) {
|
|
1214
|
-
|
|
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/
|
|
1220
|
-
var
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1295
|
-
}
|
|
1215
|
+
};
|
|
1296
1216
|
|
|
1297
|
-
// src/
|
|
1298
|
-
var
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
-
//
|
|
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-
|
|
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
|
|
1309
|
-
|
|
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-
|
|
1313
|
-
* [EARS-
|
|
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
|
|
1316
|
-
|
|
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.
|
|
1319
|
-
owner: this.
|
|
1320
|
-
repo: this.
|
|
1321
|
-
|
|
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 (
|
|
1326
|
-
|
|
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-
|
|
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
|
|
1357
|
-
const branchName = await this.getStateBranchName();
|
|
1358
|
-
const sourceBranch = options.sourceBranch ?? "main";
|
|
1322
|
+
async getChangedFiles(fromCommit, toCommit, pathFilter) {
|
|
1359
1323
|
try {
|
|
1360
|
-
const { data
|
|
1361
|
-
owner: this.
|
|
1362
|
-
repo: this.
|
|
1363
|
-
|
|
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
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1349
|
+
throw new GitError(`Failed to compare ${fromCommit}...${toCommit}: HTTP ${error.status}`);
|
|
1411
1350
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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 (
|
|
1424
|
-
|
|
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
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
1526
|
-
const branchName = await this.getStateBranchName();
|
|
1527
|
-
let remoteSha;
|
|
1485
|
+
async listRemoteBranches(_remoteName) {
|
|
1528
1486
|
try {
|
|
1529
|
-
const { data
|
|
1530
|
-
owner: this.
|
|
1531
|
-
repo: this.
|
|
1532
|
-
ref: `heads/${branchName}`
|
|
1487
|
+
const { data } = await this.octokit.rest.repos.listBranches({
|
|
1488
|
+
owner: this.owner,
|
|
1489
|
+
repo: this.repo
|
|
1533
1490
|
});
|
|
1534
|
-
|
|
1491
|
+
return data.map((b) => b.name);
|
|
1535
1492
|
} catch (error) {
|
|
1536
|
-
if (isOctokitRequestError(error)
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
1502
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1503
|
+
throw new GitError(`network error: ${msg}`);
|
|
1546
1504
|
}
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1522
|
+
/** [EARS-C7] Return staged file paths from buffer */
|
|
1523
|
+
async getStagedFiles() {
|
|
1524
|
+
return Array.from(this.stagingBuffer.keys());
|
|
1525
|
+
}
|
|
1590
1526
|
/**
|
|
1591
|
-
* [EARS-
|
|
1527
|
+
* [EARS-C6] Create branch via Refs API POST
|
|
1592
1528
|
*/
|
|
1593
|
-
async
|
|
1594
|
-
const
|
|
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
|
-
|
|
1598
|
-
owner: this.
|
|
1599
|
-
repo: this.
|
|
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)
|
|
1605
|
-
|
|
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
|
-
|
|
1548
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1549
|
+
throw new GitError(`network error: ${msg}`);
|
|
1608
1550
|
}
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
1613
|
-
const { data:
|
|
1614
|
-
owner: this.
|
|
1615
|
-
repo: this.
|
|
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
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
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
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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
|
-
*
|
|
1642
|
-
*
|
|
1654
|
+
* [EARS-C3] Commit staged changes via 6-step atomic transaction
|
|
1655
|
+
* [EARS-C5] Throws if staging buffer is empty
|
|
1643
1656
|
*/
|
|
1644
|
-
async
|
|
1645
|
-
return
|
|
1657
|
+
async commit(message, author) {
|
|
1658
|
+
return this.commitInternal(message, author, false);
|
|
1646
1659
|
}
|
|
1647
|
-
//
|
|
1648
|
-
|
|
1649
|
-
|
|
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
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
*
|
|
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
|
|
1724
|
-
const
|
|
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
|
|
1729
|
-
owner: this.
|
|
1730
|
-
repo: this.
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
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
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
|
1756
|
-
|
|
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
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
}
|
|
1830
|
-
const
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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 {
|