@context-engine-bridge/context-engine-mcp-bridge 0.0.74 → 0.0.75
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/package.json +1 -1
- package/src/connectCli.js +38 -9
- package/src/uploader.js +332 -7
- package/test/uploader.test.mjs +75 -1
package/package.json
CHANGED
package/src/connectCli.js
CHANGED
|
@@ -2,7 +2,7 @@ import process from "node:process";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import { saveAuthEntry } from "./authConfig.js";
|
|
5
|
-
import { indexWorkspace, loadGitignore, isCodeFile } from "./uploader.js";
|
|
5
|
+
import { indexWorkspace, loadGitignore, isCodeFile, collectGitState } from "./uploader.js";
|
|
6
6
|
|
|
7
7
|
const SAAS_ENDPOINTS = {
|
|
8
8
|
uploadEndpoint: "https://dev.context-engine.ai/upload",
|
|
@@ -167,6 +167,8 @@ function startWatcher(workspace, initialSessionId, authEntry, intervalMs) {
|
|
|
167
167
|
let isRunning = false;
|
|
168
168
|
let pendingSync = false;
|
|
169
169
|
let sessionId = initialSessionId;
|
|
170
|
+
let pendingHistoryOnly = false;
|
|
171
|
+
let lastKnownHead = "";
|
|
170
172
|
|
|
171
173
|
async function refreshSessionIfNeeded() {
|
|
172
174
|
// If the auth entry has an expiry and we're within 5 minutes of it,
|
|
@@ -233,7 +235,7 @@ function startWatcher(workspace, initialSessionId, authEntry, intervalMs) {
|
|
|
233
235
|
|
|
234
236
|
function detectChanges() {
|
|
235
237
|
const currentFiles = scanDirectory(workspace);
|
|
236
|
-
let
|
|
238
|
+
let codeChanged = false;
|
|
237
239
|
|
|
238
240
|
const currentPaths = new Set(currentFiles);
|
|
239
241
|
|
|
@@ -242,24 +244,35 @@ function startWatcher(workspace, initialSessionId, authEntry, intervalMs) {
|
|
|
242
244
|
const oldHash = fileHashes.get(filePath);
|
|
243
245
|
|
|
244
246
|
if (newHash !== oldHash) {
|
|
245
|
-
|
|
247
|
+
codeChanged = true;
|
|
246
248
|
fileHashes.set(filePath, newHash);
|
|
247
249
|
}
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
for (const oldPath of fileHashes.keys()) {
|
|
251
253
|
if (!currentPaths.has(oldPath)) {
|
|
252
|
-
|
|
254
|
+
codeChanged = true;
|
|
253
255
|
fileHashes.delete(oldPath);
|
|
254
256
|
}
|
|
255
257
|
}
|
|
256
258
|
|
|
257
|
-
|
|
259
|
+
let historyChanged = false;
|
|
260
|
+
try {
|
|
261
|
+
const gitState = collectGitState(workspace);
|
|
262
|
+
const currentHead = gitState && gitState.head ? gitState.head : "";
|
|
263
|
+
if (currentHead && currentHead !== lastKnownHead) {
|
|
264
|
+
historyChanged = true;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { codeChanged, historyChanged };
|
|
258
270
|
}
|
|
259
271
|
|
|
260
|
-
async function doSync() {
|
|
272
|
+
async function doSync(historyOnly = false) {
|
|
261
273
|
if (isRunning) {
|
|
262
274
|
pendingSync = true;
|
|
275
|
+
pendingHistoryOnly = pendingHistoryOnly || historyOnly;
|
|
263
276
|
return;
|
|
264
277
|
}
|
|
265
278
|
|
|
@@ -279,9 +292,15 @@ function startWatcher(workspace, initialSessionId, authEntry, intervalMs) {
|
|
|
279
292
|
log: console.error,
|
|
280
293
|
orgId: authEntry?.org_id,
|
|
281
294
|
orgSlug: authEntry?.org_slug,
|
|
295
|
+
historyOnly,
|
|
282
296
|
}
|
|
283
297
|
);
|
|
284
298
|
if (result.success) {
|
|
299
|
+
try {
|
|
300
|
+
const gitState = collectGitState(workspace);
|
|
301
|
+
lastKnownHead = gitState && gitState.head ? gitState.head : lastKnownHead;
|
|
302
|
+
} catch {
|
|
303
|
+
}
|
|
285
304
|
console.error(`[ctxce] [${now}] Sync complete.`);
|
|
286
305
|
} else {
|
|
287
306
|
console.error(`[ctxce] [${now}] Sync failed: ${result.error}`);
|
|
@@ -293,18 +312,28 @@ function startWatcher(workspace, initialSessionId, authEntry, intervalMs) {
|
|
|
293
312
|
isRunning = false;
|
|
294
313
|
|
|
295
314
|
if (pendingSync) {
|
|
315
|
+
const nextHistoryOnly = pendingHistoryOnly;
|
|
296
316
|
pendingSync = false;
|
|
297
|
-
|
|
317
|
+
pendingHistoryOnly = false;
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
doSync(nextHistoryOnly);
|
|
320
|
+
}, DEFAULT_DEBOUNCE_MS);
|
|
298
321
|
}
|
|
299
322
|
}
|
|
300
323
|
|
|
301
324
|
scanDirectory(workspace).forEach(f => {
|
|
302
325
|
fileHashes.set(f, getFileHash(f));
|
|
303
326
|
});
|
|
327
|
+
try {
|
|
328
|
+
const gitState = collectGitState(workspace);
|
|
329
|
+
lastKnownHead = gitState && gitState.head ? gitState.head : "";
|
|
330
|
+
} catch {
|
|
331
|
+
}
|
|
304
332
|
|
|
305
333
|
const intervalId = setInterval(() => {
|
|
306
|
-
|
|
307
|
-
|
|
334
|
+
const changeState = detectChanges();
|
|
335
|
+
if (changeState.codeChanged || changeState.historyChanged) {
|
|
336
|
+
doSync(changeState.historyChanged && !changeState.codeChanged);
|
|
308
337
|
}
|
|
309
338
|
}, intervalMs);
|
|
310
339
|
|
package/src/uploader.js
CHANGED
|
@@ -26,6 +26,7 @@ const EXTENSIONLESS_FILES = new Set([
|
|
|
26
26
|
|
|
27
27
|
const DEFAULT_IGNORES = [
|
|
28
28
|
".git", "node_modules", "__pycache__", ".venv", "venv", ".env",
|
|
29
|
+
".context-engine", ".context-engine-uploader", ".codebase", "dev-workspace",
|
|
29
30
|
"dist", "build", ".next", ".nuxt", "coverage", ".nyc_output",
|
|
30
31
|
"*.pyc", "*.pyo", "*.so", "*.dylib", "*.dll", "*.exe",
|
|
31
32
|
"*.jpg", "*.jpeg", "*.png", "*.gif", "*.ico", "*.svg", "*.webp",
|
|
@@ -60,6 +61,17 @@ function getCollectionName(repoName) {
|
|
|
60
61
|
return `${sanitized}-${hash}`;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
export function findGitRoot(startPath) {
|
|
65
|
+
let current = path.resolve(startPath);
|
|
66
|
+
while (current !== path.dirname(current)) {
|
|
67
|
+
if (fs.existsSync(path.join(current, ".git"))) {
|
|
68
|
+
return current;
|
|
69
|
+
}
|
|
70
|
+
current = path.dirname(current);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
export function loadGitignore(workspacePath) {
|
|
64
76
|
const ig = ignore();
|
|
65
77
|
ig.add(DEFAULT_IGNORES);
|
|
@@ -161,6 +173,241 @@ function computeLogicalRepoId(workspacePath) {
|
|
|
161
173
|
return computeLogicalRepoIdentity(workspacePath).id;
|
|
162
174
|
}
|
|
163
175
|
|
|
176
|
+
function redactEmails(text) {
|
|
177
|
+
if (!text) return text;
|
|
178
|
+
return text.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<redacted>");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function collectGitHistory(workspacePath, options = {}) {
|
|
182
|
+
let maxCommits = 0;
|
|
183
|
+
if (typeof options.maxCommits === "number" && Number.isFinite(options.maxCommits)) {
|
|
184
|
+
maxCommits = options.maxCommits;
|
|
185
|
+
} else {
|
|
186
|
+
const rawMax = (process.env.REMOTE_UPLOAD_GIT_MAX_COMMITS || "").trim();
|
|
187
|
+
const parsed = rawMax ? parseInt(rawMax, 10) : 0;
|
|
188
|
+
maxCommits = Number.isFinite(parsed) ? parsed : 0;
|
|
189
|
+
}
|
|
190
|
+
const since = options.since || (process.env.REMOTE_UPLOAD_GIT_SINCE || "").trim();
|
|
191
|
+
const forceFullEnv = (process.env.REMOTE_UPLOAD_GIT_FORCE || "").toLowerCase();
|
|
192
|
+
const forceFull = ["1", "true", "yes", "on"].includes(forceFullEnv);
|
|
193
|
+
|
|
194
|
+
const root = findGitRoot(workspacePath);
|
|
195
|
+
if (!root) return null;
|
|
196
|
+
|
|
197
|
+
const gitCacheDir =
|
|
198
|
+
options.gitCacheDir != null ? options.gitCacheDir : path.join(workspacePath, ".context-engine");
|
|
199
|
+
const gitCachePath = path.join(gitCacheDir, "git_history_cache.json");
|
|
200
|
+
const gitOpts = { cwd: root, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] };
|
|
201
|
+
const shaRe = /^[0-9a-f]{4,40}$/;
|
|
202
|
+
|
|
203
|
+
let currentHead = "";
|
|
204
|
+
try {
|
|
205
|
+
currentHead = execFileSync("git", ["rev-parse", "HEAD"], gitOpts).trim();
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let cache = {};
|
|
210
|
+
if (!forceFull && fs.existsSync(gitCachePath)) {
|
|
211
|
+
try {
|
|
212
|
+
cache = JSON.parse(fs.readFileSync(gitCachePath, "utf8"));
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (
|
|
218
|
+
!forceFull &&
|
|
219
|
+
currentHead &&
|
|
220
|
+
cache.last_head === currentHead &&
|
|
221
|
+
cache.max_commits === maxCommits &&
|
|
222
|
+
(cache.since || "") === since
|
|
223
|
+
) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let baseHead = "";
|
|
228
|
+
const prevHead = String(cache.last_head || "").trim();
|
|
229
|
+
if (!forceFull && currentHead && prevHead && shaRe.test(prevHead) && prevHead !== currentHead) {
|
|
230
|
+
baseHead = prevHead;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let snapshotMode = forceFull;
|
|
234
|
+
if (!snapshotMode && currentHead && baseHead) {
|
|
235
|
+
try {
|
|
236
|
+
execFileSync("git", ["merge-base", "--is-ancestor", baseHead, currentHead], gitOpts);
|
|
237
|
+
} catch {
|
|
238
|
+
snapshotMode = true;
|
|
239
|
+
baseHead = "";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const revListArgs = ["rev-list", "--no-merges"];
|
|
244
|
+
if (since) revListArgs.push(`--since=${since}`);
|
|
245
|
+
if (baseHead && currentHead) {
|
|
246
|
+
revListArgs.push(`${baseHead}..${currentHead}`);
|
|
247
|
+
} else {
|
|
248
|
+
revListArgs.push("HEAD");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let commits = [];
|
|
252
|
+
try {
|
|
253
|
+
const result = execFileSync("git", revListArgs, gitOpts);
|
|
254
|
+
commits = result.split("\n").filter((line) => line.trim());
|
|
255
|
+
if (maxCommits > 0 && commits.length > maxCommits) {
|
|
256
|
+
commits = commits.slice(0, maxCommits);
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (commits.length === 0) return null;
|
|
263
|
+
|
|
264
|
+
const records = [];
|
|
265
|
+
const fmt = "%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%b";
|
|
266
|
+
for (const sha of commits) {
|
|
267
|
+
if (!shaRe.test(sha)) continue;
|
|
268
|
+
try {
|
|
269
|
+
const showResult = execFileSync("git", ["show", "-s", `--format=${fmt}`, sha], gitOpts);
|
|
270
|
+
const parts = showResult.trim().split("\x1f");
|
|
271
|
+
const [cSha, authorName, , authoredDate, subject, body] = [...parts, "", "", "", "", "", ""].slice(0, 6);
|
|
272
|
+
|
|
273
|
+
let files = [];
|
|
274
|
+
try {
|
|
275
|
+
const filesResult = execFileSync(
|
|
276
|
+
"git",
|
|
277
|
+
["diff-tree", "--no-commit-id", "--name-only", "-r", sha],
|
|
278
|
+
gitOpts
|
|
279
|
+
);
|
|
280
|
+
files = filesResult.split("\n").filter((entry) => entry.trim());
|
|
281
|
+
} catch {
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let diffText = "";
|
|
285
|
+
try {
|
|
286
|
+
const diffResult = execFileSync(
|
|
287
|
+
"git",
|
|
288
|
+
["show", "--stat", "--patch", "--unified=3", sha],
|
|
289
|
+
{ ...gitOpts, maxBuffer: 10 * 1024 * 1024 }
|
|
290
|
+
);
|
|
291
|
+
const maxChars = parseInt(process.env.COMMIT_SUMMARY_DIFF_CHARS || "6000", 10);
|
|
292
|
+
diffText = diffResult.substring(0, maxChars);
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let message = redactEmails((subject + (body ? `\n${body}` : "")).trim());
|
|
297
|
+
if (message.length > 2000) message = `${message.substring(0, 2000)}…`;
|
|
298
|
+
|
|
299
|
+
records.push({
|
|
300
|
+
commit_id: cSha || sha,
|
|
301
|
+
author_name: authorName,
|
|
302
|
+
authored_date: authoredDate,
|
|
303
|
+
message,
|
|
304
|
+
files,
|
|
305
|
+
diff: diffText,
|
|
306
|
+
});
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (records.length === 0) return null;
|
|
312
|
+
|
|
313
|
+
const repoName = path.basename(root);
|
|
314
|
+
const manifest = {
|
|
315
|
+
version: 1,
|
|
316
|
+
repo_name: repoName,
|
|
317
|
+
generated_at: new Date().toISOString(),
|
|
318
|
+
head: currentHead,
|
|
319
|
+
prev_head: prevHead,
|
|
320
|
+
base_head: baseHead,
|
|
321
|
+
mode: snapshotMode ? "snapshot" : "delta",
|
|
322
|
+
max_commits: maxCommits,
|
|
323
|
+
since,
|
|
324
|
+
commits: records,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
fs.mkdirSync(path.dirname(gitCachePath), { recursive: true });
|
|
329
|
+
fs.writeFileSync(
|
|
330
|
+
gitCachePath,
|
|
331
|
+
JSON.stringify(
|
|
332
|
+
{
|
|
333
|
+
last_head: currentHead || commits[0] || "",
|
|
334
|
+
max_commits: maxCommits,
|
|
335
|
+
since,
|
|
336
|
+
updated_at: new Date().toISOString(),
|
|
337
|
+
},
|
|
338
|
+
null,
|
|
339
|
+
2
|
|
340
|
+
)
|
|
341
|
+
);
|
|
342
|
+
} catch {
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return manifest;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function collectGitState(workspacePath, options = {}) {
|
|
349
|
+
const maxCommits =
|
|
350
|
+
typeof options.maxCommits === "number" && Number.isFinite(options.maxCommits)
|
|
351
|
+
? options.maxCommits
|
|
352
|
+
: (() => {
|
|
353
|
+
const rawMax = (process.env.REMOTE_UPLOAD_GIT_MAX_COMMITS || "").trim();
|
|
354
|
+
const parsed = rawMax ? parseInt(rawMax, 10) : 0;
|
|
355
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
356
|
+
})();
|
|
357
|
+
const since = options.since || (process.env.REMOTE_UPLOAD_GIT_SINCE || "").trim();
|
|
358
|
+
const root = findGitRoot(workspacePath);
|
|
359
|
+
if (!root) return null;
|
|
360
|
+
|
|
361
|
+
const gitCacheDir =
|
|
362
|
+
options.gitCacheDir != null ? options.gitCacheDir : path.join(workspacePath, ".context-engine");
|
|
363
|
+
const gitCachePath = path.join(gitCacheDir, "git_history_cache.json");
|
|
364
|
+
const gitOpts = { cwd: root, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] };
|
|
365
|
+
|
|
366
|
+
let currentHead = "";
|
|
367
|
+
try {
|
|
368
|
+
currentHead = execFileSync("git", ["rev-parse", "HEAD"], gitOpts).trim();
|
|
369
|
+
} catch {
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let cache = {};
|
|
373
|
+
try {
|
|
374
|
+
if (fs.existsSync(gitCachePath)) {
|
|
375
|
+
cache = JSON.parse(fs.readFileSync(gitCachePath, "utf8"));
|
|
376
|
+
}
|
|
377
|
+
} catch {
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const prevHead = String(cache.last_head || "").trim();
|
|
381
|
+
let baseHead = "";
|
|
382
|
+
if (currentHead && prevHead && prevHead !== currentHead) {
|
|
383
|
+
baseHead = prevHead;
|
|
384
|
+
try {
|
|
385
|
+
execFileSync("git", ["merge-base", "--is-ancestor", prevHead, currentHead], gitOpts);
|
|
386
|
+
} catch {
|
|
387
|
+
baseHead = "";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const repoName = path.basename(root);
|
|
392
|
+
const logicalRepoIdentity = computeLogicalRepoIdentity(workspacePath);
|
|
393
|
+
const logicalRepoId = logicalRepoIdentity.id;
|
|
394
|
+
return {
|
|
395
|
+
version: 1,
|
|
396
|
+
repo_name: repoName,
|
|
397
|
+
workspace_path: workspacePath,
|
|
398
|
+
logical_repo_id: logicalRepoId,
|
|
399
|
+
logical_repo_source: logicalRepoIdentity.source,
|
|
400
|
+
repo_fingerprint: `logical:${logicalRepoId}`,
|
|
401
|
+
head: currentHead,
|
|
402
|
+
prev_head: prevHead,
|
|
403
|
+
base_head: baseHead,
|
|
404
|
+
mode: baseHead ? "delta" : "snapshot",
|
|
405
|
+
max_commits: maxCommits,
|
|
406
|
+
since,
|
|
407
|
+
history_complete: !since && maxCommits <= 0,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
164
411
|
function scanWorkspace(workspacePath, ig) {
|
|
165
412
|
const files = [];
|
|
166
413
|
|
|
@@ -204,13 +451,21 @@ function scanWorkspace(workspacePath, ig) {
|
|
|
204
451
|
}
|
|
205
452
|
|
|
206
453
|
export async function createBundle(workspacePath, options = {}) {
|
|
207
|
-
const { log = console.error } = options;
|
|
454
|
+
const { log = console.error, gitHistory = null, gitState = null, allowEmpty = false } = options;
|
|
208
455
|
|
|
209
456
|
const ig = loadGitignore(workspacePath);
|
|
210
457
|
const files = scanWorkspace(workspacePath, ig);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
458
|
+
const resolvedGitHistory =
|
|
459
|
+
gitHistory === null ? collectGitHistory(workspacePath) : gitHistory;
|
|
460
|
+
const resolvedGitState =
|
|
461
|
+
gitState === null ? collectGitState(workspacePath) : gitState;
|
|
462
|
+
|
|
463
|
+
if (files.length === 0 && !allowEmpty) {
|
|
464
|
+
if (resolvedGitHistory) {
|
|
465
|
+
log("[uploader] No code files found in workspace; git history metadata is available");
|
|
466
|
+
} else {
|
|
467
|
+
log("[uploader] No code files found in workspace");
|
|
468
|
+
}
|
|
214
469
|
return null;
|
|
215
470
|
}
|
|
216
471
|
|
|
@@ -222,7 +477,7 @@ export async function createBundle(workspacePath, options = {}) {
|
|
|
222
477
|
|
|
223
478
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-"));
|
|
224
479
|
const bundleDir = path.join(tmpDir, bundleId);
|
|
225
|
-
const metadataDir = path.join(bundleDir, "
|
|
480
|
+
const metadataDir = path.join(bundleDir, "metadata");
|
|
226
481
|
const filesDir = path.join(bundleDir, "files");
|
|
227
482
|
|
|
228
483
|
fs.mkdirSync(metadataDir, { recursive: true });
|
|
@@ -277,6 +532,20 @@ export async function createBundle(workspacePath, options = {}) {
|
|
|
277
532
|
file_hashes: fileHashes,
|
|
278
533
|
}, null, 2));
|
|
279
534
|
|
|
535
|
+
if (resolvedGitHistory) {
|
|
536
|
+
fs.writeFileSync(
|
|
537
|
+
path.join(metadataDir, "git_history.json"),
|
|
538
|
+
JSON.stringify(resolvedGitHistory, null, 2)
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (resolvedGitState) {
|
|
543
|
+
fs.writeFileSync(
|
|
544
|
+
path.join(metadataDir, "git_state.json"),
|
|
545
|
+
JSON.stringify(resolvedGitState, null, 2)
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
280
549
|
const bundlePath = path.join(tmpDir, `${bundleId}.tar.gz`);
|
|
281
550
|
|
|
282
551
|
try {
|
|
@@ -310,6 +579,49 @@ export async function createBundle(workspacePath, options = {}) {
|
|
|
310
579
|
}
|
|
311
580
|
}
|
|
312
581
|
|
|
582
|
+
export async function uploadGitHistoryOnly(workspacePath, uploadEndpoint, sessionId, options = {}) {
|
|
583
|
+
const { log = console.error, orgId, orgSlug, quietNoop = false } = options;
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const gitHistory = collectGitHistory(workspacePath);
|
|
587
|
+
if (!gitHistory) {
|
|
588
|
+
if (!quietNoop) {
|
|
589
|
+
log("[uploader] No git history changes to upload");
|
|
590
|
+
}
|
|
591
|
+
return { success: true, noop: true };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const gitState = collectGitState(workspacePath);
|
|
595
|
+
const bundle = await createBundle(workspacePath, {
|
|
596
|
+
log,
|
|
597
|
+
gitHistory,
|
|
598
|
+
gitState,
|
|
599
|
+
allowEmpty: true,
|
|
600
|
+
});
|
|
601
|
+
if (!bundle) {
|
|
602
|
+
return { success: false, error: "failed_to_create_history_bundle" };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
log(`[uploader] Created git history bundle: ${fs.statSync(bundle.bundlePath).size} bytes`);
|
|
607
|
+
const result = await uploadBundle(
|
|
608
|
+
bundle.bundlePath,
|
|
609
|
+
bundle.manifest,
|
|
610
|
+
uploadEndpoint,
|
|
611
|
+
sessionId,
|
|
612
|
+
{ log, orgId, orgSlug }
|
|
613
|
+
);
|
|
614
|
+
return result;
|
|
615
|
+
} finally {
|
|
616
|
+
bundle.cleanup();
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
620
|
+
log(`[uploader] Git history upload failed: ${message}`);
|
|
621
|
+
return { success: false, error: message };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
313
625
|
export async function uploadBundle(bundlePath, manifest, uploadEndpoint, sessionId, options = {}) {
|
|
314
626
|
const { log = console.error, orgId, orgSlug } = options;
|
|
315
627
|
|
|
@@ -416,13 +728,26 @@ export async function indexWorkspace(workspacePath, uploadEndpoint, sessionId, o
|
|
|
416
728
|
}
|
|
417
729
|
|
|
418
730
|
async function _indexWorkspaceInner(workspacePath, uploadEndpoint, sessionId, options = {}) {
|
|
419
|
-
const { log = console.error, orgId, orgSlug } = options;
|
|
731
|
+
const { log = console.error, orgId, orgSlug, historyOnly = false } = options;
|
|
420
732
|
|
|
421
733
|
log(`[uploader] Scanning workspace: ${workspacePath}`);
|
|
422
734
|
|
|
735
|
+
if (historyOnly) {
|
|
736
|
+
return await uploadGitHistoryOnly(workspacePath, uploadEndpoint, sessionId, {
|
|
737
|
+
log,
|
|
738
|
+
orgId,
|
|
739
|
+
orgSlug,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
423
743
|
const bundle = await createBundle(workspacePath, { log });
|
|
424
744
|
if (!bundle) {
|
|
425
|
-
return
|
|
745
|
+
return await uploadGitHistoryOnly(workspacePath, uploadEndpoint, sessionId, {
|
|
746
|
+
log,
|
|
747
|
+
orgId,
|
|
748
|
+
orgSlug,
|
|
749
|
+
quietNoop: true,
|
|
750
|
+
});
|
|
426
751
|
}
|
|
427
752
|
|
|
428
753
|
try {
|
package/test/uploader.test.mjs
CHANGED
|
@@ -5,7 +5,11 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
computeLogicalRepoIdentity,
|
|
10
|
+
createBundle,
|
|
11
|
+
uploadBundle,
|
|
12
|
+
} from "../src/uploader.js";
|
|
9
13
|
|
|
10
14
|
test("computeLogicalRepoIdentity prefers normalized remote origin", async () => {
|
|
11
15
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-bridge-git-"));
|
|
@@ -82,3 +86,73 @@ test("uploadBundle streams an exact multipart file payload", async () => {
|
|
|
82
86
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
83
87
|
}
|
|
84
88
|
});
|
|
89
|
+
|
|
90
|
+
test("createBundle writes metadata/, git_history.json, and git_state.json", async () => {
|
|
91
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-bridge-bundle-"));
|
|
92
|
+
try {
|
|
93
|
+
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
|
|
94
|
+
execFileSync("git", ["config", "user.email", "bridge@example.com"], { cwd: tmpDir, stdio: "ignore" });
|
|
95
|
+
execFileSync("git", ["config", "user.name", "Bridge Test"], { cwd: tmpDir, stdio: "ignore" });
|
|
96
|
+
execFileSync("git", ["remote", "add", "origin", "git@github.com:Context-Engine-AI/Bridge-Test.git"], {
|
|
97
|
+
cwd: tmpDir,
|
|
98
|
+
stdio: "ignore",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
fs.writeFileSync(path.join(tmpDir, "index.js"), "export const value = 1;\n");
|
|
102
|
+
execFileSync("git", ["add", "index.js"], { cwd: tmpDir, stdio: "ignore" });
|
|
103
|
+
execFileSync("git", ["commit", "-m", "initial commit"], { cwd: tmpDir, stdio: "ignore" });
|
|
104
|
+
|
|
105
|
+
const bundle = await createBundle(tmpDir, { log: () => {} });
|
|
106
|
+
assert.ok(bundle);
|
|
107
|
+
|
|
108
|
+
const entries = execFileSync("tar", ["-tzf", bundle.bundlePath], { encoding: "utf8" })
|
|
109
|
+
.split("\n")
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
|
|
112
|
+
assert.ok(entries.some((entry) => entry.endsWith("/manifest.json")));
|
|
113
|
+
assert.ok(entries.some((entry) => entry.endsWith("/metadata/operations.json")));
|
|
114
|
+
assert.ok(entries.some((entry) => entry.endsWith("/metadata/hashes.json")));
|
|
115
|
+
assert.ok(entries.some((entry) => entry.endsWith("/metadata/git_history.json")));
|
|
116
|
+
assert.ok(entries.some((entry) => entry.endsWith("/metadata/git_state.json")));
|
|
117
|
+
assert.ok(!entries.some((entry) => entry.includes("/.metadata/")));
|
|
118
|
+
|
|
119
|
+
const historyEntry = entries.find((entry) => entry.endsWith("/metadata/git_history.json"));
|
|
120
|
+
const stateEntry = entries.find((entry) => entry.endsWith("/metadata/git_state.json"));
|
|
121
|
+
const historyJson = JSON.parse(
|
|
122
|
+
execFileSync("tar", ["-xOzf", bundle.bundlePath, historyEntry], { encoding: "utf8" })
|
|
123
|
+
);
|
|
124
|
+
const stateJson = JSON.parse(
|
|
125
|
+
execFileSync("tar", ["-xOzf", bundle.bundlePath, stateEntry], { encoding: "utf8" })
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
assert.equal(historyJson.repo_name, path.basename(tmpDir));
|
|
129
|
+
assert.equal(stateJson.logical_repo_source, "remote_origin");
|
|
130
|
+
assert.match(stateJson.logical_repo_id, /^git:[0-9a-f]{16}$/);
|
|
131
|
+
} finally {
|
|
132
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("createBundle can build a history-only bundle for repos with no code files", async () => {
|
|
137
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-bridge-history-only-"));
|
|
138
|
+
try {
|
|
139
|
+
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
|
|
140
|
+
execFileSync("git", ["config", "user.email", "bridge@example.com"], { cwd: tmpDir, stdio: "ignore" });
|
|
141
|
+
execFileSync("git", ["config", "user.name", "Bridge Test"], { cwd: tmpDir, stdio: "ignore" });
|
|
142
|
+
fs.writeFileSync(path.join(tmpDir, "notes.txt"), "history only\n");
|
|
143
|
+
execFileSync("git", ["add", "notes.txt"], { cwd: tmpDir, stdio: "ignore" });
|
|
144
|
+
execFileSync("git", ["commit", "-m", "history only"], { cwd: tmpDir, stdio: "ignore" });
|
|
145
|
+
|
|
146
|
+
const bundle = await createBundle(tmpDir, { log: () => {}, allowEmpty: true });
|
|
147
|
+
assert.ok(bundle);
|
|
148
|
+
assert.equal(bundle.manifest.total_files, 0);
|
|
149
|
+
|
|
150
|
+
const entries = execFileSync("tar", ["-tzf", bundle.bundlePath], { encoding: "utf8" })
|
|
151
|
+
.split("\n")
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
assert.ok(entries.some((entry) => entry.endsWith("/metadata/git_history.json")));
|
|
154
|
+
assert.ok(entries.some((entry) => entry.endsWith("/metadata/operations.json")));
|
|
155
|
+
} finally {
|
|
156
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|