@context-engine-bridge/context-engine-mcp-bridge 0.0.73 → 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/.claude/settings.local.json +11 -0
- package/package.json +1 -1
- package/src/authCli.js +11 -2
- package/src/cli.js +10 -5
- package/src/connectCli.js +38 -9
- package/src/oauthHandler.js +1 -1
- package/src/uploader.js +437 -33
- package/test/uploader.test.mjs +158 -0
package/package.json
CHANGED
package/src/authCli.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import { loadAuthEntry, saveAuthEntry, deleteAuthEntry, loadAnyAuthEntry } from "./authConfig.js";
|
|
3
3
|
|
|
4
|
+
const SAAS_ENDPOINTS = {
|
|
5
|
+
uploadEndpoint: "https://dev.context-engine.ai/upload",
|
|
6
|
+
authBackendUrl: "https://dev.context-engine.ai",
|
|
7
|
+
};
|
|
8
|
+
|
|
4
9
|
function parseAuthArgs(args) {
|
|
5
10
|
let backendUrl = process.env.CTXCE_AUTH_BACKEND_URL || "";
|
|
6
11
|
let token = process.env.CTXCE_AUTH_TOKEN || "";
|
|
@@ -49,8 +54,12 @@ function getBackendUrl(backendUrl) {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
function getDefaultUploadBackend() {
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
return (
|
|
58
|
+
process.env.CTXCE_AUTH_BACKEND_URL
|
|
59
|
+
|| process.env.CTXCE_UPLOAD_ENDPOINT
|
|
60
|
+
|| process.env.UPLOAD_ENDPOINT
|
|
61
|
+
|| SAAS_ENDPOINTS.authBackendUrl
|
|
62
|
+
).trim();
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
function requireBackendUrl(backendUrl) {
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,11 @@ import { runMcpServer, runHttpMcpServer } from "./mcpServer.js";
|
|
|
7
7
|
import { runAuthCommand } from "./authCli.js";
|
|
8
8
|
import { runConnectCommand } from "./connectCli.js";
|
|
9
9
|
|
|
10
|
+
const SAAS_ENDPOINTS = {
|
|
11
|
+
mcpIndexerUrl: "https://dev.context-engine.ai/indexer/mcp",
|
|
12
|
+
mcpMemoryUrl: "https://dev.context-engine.ai/memory/mcp",
|
|
13
|
+
};
|
|
14
|
+
|
|
10
15
|
export async function runCli() {
|
|
11
16
|
const argv = process.argv.slice(2);
|
|
12
17
|
const cmd = argv[0];
|
|
@@ -27,8 +32,8 @@ export async function runCli() {
|
|
|
27
32
|
if (cmd === "mcp-http-serve") {
|
|
28
33
|
const args = argv.slice(1);
|
|
29
34
|
let workspace = process.cwd();
|
|
30
|
-
let indexerUrl = process.env.CTXCE_INDEXER_URL ||
|
|
31
|
-
let memoryUrl = process.env.CTXCE_MEMORY_URL ||
|
|
35
|
+
let indexerUrl = process.env.CTXCE_INDEXER_URL || SAAS_ENDPOINTS.mcpIndexerUrl;
|
|
36
|
+
let memoryUrl = process.env.CTXCE_MEMORY_URL || SAAS_ENDPOINTS.mcpMemoryUrl;
|
|
32
37
|
let port = Number.parseInt(process.env.CTXCE_HTTP_PORT || "30810", 10) || 30810;
|
|
33
38
|
let collection = null;
|
|
34
39
|
|
|
@@ -86,12 +91,12 @@ export async function runCli() {
|
|
|
86
91
|
// Minimal flag parsing for PoC: allow passing workspace/root and indexer URL.
|
|
87
92
|
// Supported flags:
|
|
88
93
|
// --workspace / --path : workspace root (default: cwd)
|
|
89
|
-
// --indexer-url : override MCP indexer URL (default env CTXCE_INDEXER_URL or
|
|
94
|
+
// --indexer-url : override MCP indexer URL (default env CTXCE_INDEXER_URL or SaaS endpoint)
|
|
90
95
|
// --collection : collection name to use for MCP calls
|
|
91
96
|
const args = argv.slice(1);
|
|
92
97
|
let workspace = process.cwd();
|
|
93
|
-
let indexerUrl = process.env.CTXCE_INDEXER_URL ||
|
|
94
|
-
let memoryUrl = process.env.CTXCE_MEMORY_URL ||
|
|
98
|
+
let indexerUrl = process.env.CTXCE_INDEXER_URL || SAAS_ENDPOINTS.mcpIndexerUrl;
|
|
99
|
+
let memoryUrl = process.env.CTXCE_MEMORY_URL || SAAS_ENDPOINTS.mcpMemoryUrl;
|
|
95
100
|
let collection = null;
|
|
96
101
|
|
|
97
102
|
for (let i = 0; i < args.length; i += 1) {
|
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/oauthHandler.js
CHANGED
|
@@ -198,7 +198,7 @@ export function getLoginPage(redirectUri, clientId, state, codeChallenge, codeCh
|
|
|
198
198
|
<form id="loginForm">
|
|
199
199
|
<div class="form-group">
|
|
200
200
|
<label>Backend URL</label>
|
|
201
|
-
<input type="url" id="backendUrl" placeholder="
|
|
201
|
+
<input type="url" id="backendUrl" placeholder="https://dev.context-engine.ai" required>
|
|
202
202
|
</div>
|
|
203
203
|
<div class="form-group">
|
|
204
204
|
<label>Username (optional)</label>
|
package/src/uploader.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
5
6
|
import { create as tarCreate } from "tar";
|
|
6
7
|
import ignore from "ignore";
|
|
7
8
|
|
|
@@ -25,6 +26,7 @@ const EXTENSIONLESS_FILES = new Set([
|
|
|
25
26
|
|
|
26
27
|
const DEFAULT_IGNORES = [
|
|
27
28
|
".git", "node_modules", "__pycache__", ".venv", "venv", ".env",
|
|
29
|
+
".context-engine", ".context-engine-uploader", ".codebase", "dev-workspace",
|
|
28
30
|
"dist", "build", ".next", ".nuxt", "coverage", ".nyc_output",
|
|
29
31
|
"*.pyc", "*.pyo", "*.so", "*.dylib", "*.dll", "*.exe",
|
|
30
32
|
"*.jpg", "*.jpeg", "*.png", "*.gif", "*.ico", "*.svg", "*.webp",
|
|
@@ -36,6 +38,40 @@ const DEFAULT_IGNORES = [
|
|
|
36
38
|
|
|
37
39
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
38
40
|
|
|
41
|
+
function sanitizeRepoName(repoName) {
|
|
42
|
+
return String(repoName || "")
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.trim()
|
|
45
|
+
.replace(/[^a-z0-9_.-]+/g, "-")
|
|
46
|
+
.replace(/-+/g, "-")
|
|
47
|
+
.replace(/^-|-$/g, "") || "workspace";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractRepoNameFromPath(workspacePath) {
|
|
51
|
+
try {
|
|
52
|
+
return path.basename(path.resolve(workspacePath));
|
|
53
|
+
} catch {
|
|
54
|
+
return "workspace";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getCollectionName(repoName) {
|
|
59
|
+
const sanitized = sanitizeRepoName(repoName);
|
|
60
|
+
const hash = createHash("sha256").update(String(repoName || "")).digest("hex").slice(0, 8);
|
|
61
|
+
return `${sanitized}-${hash}`;
|
|
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
|
+
|
|
39
75
|
export function loadGitignore(workspacePath) {
|
|
40
76
|
const ig = ignore();
|
|
41
77
|
ig.add(DEFAULT_IGNORES);
|
|
@@ -68,9 +104,308 @@ function computeFileHash(filePath) {
|
|
|
68
104
|
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
69
105
|
}
|
|
70
106
|
|
|
107
|
+
function normalizeGitRemoteUrl(rawRemote) {
|
|
108
|
+
const value = String(rawRemote || "").trim();
|
|
109
|
+
if (!value) return "";
|
|
110
|
+
|
|
111
|
+
if (!value.includes("://")) {
|
|
112
|
+
const match = value.match(/^(?:([^@/\s]+)@)?([^:/\s]+):(.+)$/);
|
|
113
|
+
if (match) {
|
|
114
|
+
const host = String(match[2] || "").trim().toLowerCase();
|
|
115
|
+
let repoPath = String(match[3] || "").trim().replace(/^\/+|\/+$/g, "");
|
|
116
|
+
repoPath = repoPath.replace(/\.git$/i, "");
|
|
117
|
+
if (host && repoPath) return `${host}/${repoPath}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const parsed = new URL(value);
|
|
123
|
+
const host = String(parsed.hostname || "").trim().toLowerCase();
|
|
124
|
+
let repoPath = String(parsed.pathname || "").trim().replace(/^\/+|\/+$/g, "");
|
|
125
|
+
repoPath = repoPath.replace(/\.git$/i, "");
|
|
126
|
+
const port = parsed.port ? `:${parsed.port}` : "";
|
|
127
|
+
if (host && repoPath) return `${host}${port}/${repoPath}`;
|
|
128
|
+
} catch {
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function computeLogicalRepoIdentity(workspacePath) {
|
|
135
|
+
const resolved = path.resolve(workspacePath);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
139
|
+
cwd: resolved,
|
|
140
|
+
encoding: "utf8",
|
|
141
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
142
|
+
}).trim();
|
|
143
|
+
const normalizedRemote = normalizeGitRemoteUrl(remote);
|
|
144
|
+
if (normalizedRemote) {
|
|
145
|
+
const hash = createHash("sha1").update(normalizedRemote).digest("hex").slice(0, 16);
|
|
146
|
+
return { id: `git:${hash}`, source: "remote_origin" };
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
let commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
153
|
+
cwd: resolved,
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
156
|
+
}).trim();
|
|
157
|
+
if (commonDir) {
|
|
158
|
+
if (!path.isAbsolute(commonDir)) {
|
|
159
|
+
commonDir = path.resolve(resolved, commonDir);
|
|
160
|
+
}
|
|
161
|
+
const hash = createHash("sha1").update(commonDir).digest("hex").slice(0, 16);
|
|
162
|
+
return { id: `git:${hash}`, source: "git_common_dir" };
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const normalized = resolved.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
168
|
+
const hash = createHash("sha1").update(normalized).digest("hex").slice(0, 16);
|
|
169
|
+
return { id: `fs:${hash}`, source: "filesystem_path" };
|
|
170
|
+
}
|
|
171
|
+
|
|
71
172
|
function computeLogicalRepoId(workspacePath) {
|
|
72
|
-
|
|
73
|
-
|
|
173
|
+
return computeLogicalRepoIdentity(workspacePath).id;
|
|
174
|
+
}
|
|
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
|
+
};
|
|
74
409
|
}
|
|
75
410
|
|
|
76
411
|
function scanWorkspace(workspacePath, ig) {
|
|
@@ -116,13 +451,21 @@ function scanWorkspace(workspacePath, ig) {
|
|
|
116
451
|
}
|
|
117
452
|
|
|
118
453
|
export async function createBundle(workspacePath, options = {}) {
|
|
119
|
-
const { log = console.error } = options;
|
|
454
|
+
const { log = console.error, gitHistory = null, gitState = null, allowEmpty = false } = options;
|
|
120
455
|
|
|
121
456
|
const ig = loadGitignore(workspacePath);
|
|
122
457
|
const files = scanWorkspace(workspacePath, ig);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
126
469
|
return null;
|
|
127
470
|
}
|
|
128
471
|
|
|
@@ -130,10 +473,11 @@ export async function createBundle(workspacePath, options = {}) {
|
|
|
130
473
|
|
|
131
474
|
const bundleId = createHash("sha256").update(Date.now().toString() + Math.random().toString()).digest("hex").slice(0, 16);
|
|
132
475
|
const createdAt = new Date().toISOString();
|
|
476
|
+
const repoName = extractRepoNameFromPath(workspacePath);
|
|
133
477
|
|
|
134
478
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-"));
|
|
135
479
|
const bundleDir = path.join(tmpDir, bundleId);
|
|
136
|
-
const metadataDir = path.join(bundleDir, "
|
|
480
|
+
const metadataDir = path.join(bundleDir, "metadata");
|
|
137
481
|
const filesDir = path.join(bundleDir, "files");
|
|
138
482
|
|
|
139
483
|
fs.mkdirSync(metadataDir, { recursive: true });
|
|
@@ -164,7 +508,7 @@ export async function createBundle(workspacePath, options = {}) {
|
|
|
164
508
|
version: "1.0",
|
|
165
509
|
bundle_id: bundleId,
|
|
166
510
|
workspace_path: workspacePath,
|
|
167
|
-
collection_name:
|
|
511
|
+
collection_name: getCollectionName(repoName),
|
|
168
512
|
created_at: createdAt,
|
|
169
513
|
sequence_number: null,
|
|
170
514
|
parent_sequence: null,
|
|
@@ -188,6 +532,20 @@ export async function createBundle(workspacePath, options = {}) {
|
|
|
188
532
|
file_hashes: fileHashes,
|
|
189
533
|
}, null, 2));
|
|
190
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
|
+
|
|
191
549
|
const bundlePath = path.join(tmpDir, `${bundleId}.tar.gz`);
|
|
192
550
|
|
|
193
551
|
try {
|
|
@@ -221,6 +579,49 @@ export async function createBundle(workspacePath, options = {}) {
|
|
|
221
579
|
}
|
|
222
580
|
}
|
|
223
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
|
+
|
|
224
625
|
export async function uploadBundle(bundlePath, manifest, uploadEndpoint, sessionId, options = {}) {
|
|
225
626
|
const { log = console.error, orgId, orgSlug } = options;
|
|
226
627
|
|
|
@@ -228,13 +629,16 @@ export async function uploadBundle(bundlePath, manifest, uploadEndpoint, session
|
|
|
228
629
|
const boundary = `----ctxce${Date.now()}${Math.random().toString(36).slice(2)}`;
|
|
229
630
|
|
|
230
631
|
// Build form fields (small metadata -- kept in memory)
|
|
231
|
-
const logicalRepoId =
|
|
632
|
+
const { id: logicalRepoId, source: logicalRepoSource } = computeLogicalRepoIdentity(
|
|
633
|
+
manifest.workspace_path
|
|
634
|
+
);
|
|
232
635
|
const fields = {
|
|
233
636
|
workspace_path: manifest.workspace_path,
|
|
234
637
|
collection_name: manifest.collection_name || logicalRepoId,
|
|
235
638
|
force: "true",
|
|
236
639
|
source_path: manifest.workspace_path,
|
|
237
640
|
logical_repo_id: logicalRepoId,
|
|
641
|
+
logical_repo_source: logicalRepoSource,
|
|
238
642
|
session: sessionId,
|
|
239
643
|
};
|
|
240
644
|
if (orgId) fields.org_id = orgId;
|
|
@@ -263,29 +667,16 @@ export async function uploadBundle(bundlePath, manifest, uploadEndpoint, session
|
|
|
263
667
|
// This prevents OOM for large repositories (hundreds of MB bundles).
|
|
264
668
|
const totalLength = filePreamble.length + bundleSize + fileEpilogue.length + fieldsBuffer.length;
|
|
265
669
|
|
|
266
|
-
const { Readable } = await import(
|
|
267
|
-
const bodyStream =
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const CHUNK = 256 * 1024; // 256KB chunks
|
|
273
|
-
const fd = fs.openSync(bundlePath, 'r');
|
|
274
|
-
try {
|
|
275
|
-
const buf = Buffer.allocUnsafe(CHUNK);
|
|
276
|
-
let bytesRead;
|
|
277
|
-
while ((bytesRead = fs.readSync(fd, buf, 0, CHUNK)) > 0) {
|
|
278
|
-
this.push(bytesRead === CHUNK ? buf : buf.subarray(0, bytesRead));
|
|
279
|
-
}
|
|
280
|
-
} finally {
|
|
281
|
-
fs.closeSync(fd);
|
|
282
|
-
}
|
|
283
|
-
// Push epilogue + fields + close
|
|
284
|
-
this.push(fileEpilogue);
|
|
285
|
-
this.push(fieldsBuffer);
|
|
286
|
-
this.push(null); // EOF
|
|
670
|
+
const { Readable } = await import("node:stream");
|
|
671
|
+
const bodyStream = Readable.from((async function* buildMultipartStream() {
|
|
672
|
+
yield filePreamble;
|
|
673
|
+
const fileStream = fs.createReadStream(bundlePath, { highWaterMark: 256 * 1024 });
|
|
674
|
+
for await (const chunk of fileStream) {
|
|
675
|
+
yield chunk;
|
|
287
676
|
}
|
|
288
|
-
|
|
677
|
+
yield fileEpilogue;
|
|
678
|
+
yield fieldsBuffer;
|
|
679
|
+
})());
|
|
289
680
|
|
|
290
681
|
const url = `${uploadEndpoint}/api/v1/delta/upload`;
|
|
291
682
|
log(`[uploader] Uploading to ${url} (${(bundleSize / 1024).toFixed(0)}KB bundle, streaming)...`);
|
|
@@ -337,13 +728,26 @@ export async function indexWorkspace(workspacePath, uploadEndpoint, sessionId, o
|
|
|
337
728
|
}
|
|
338
729
|
|
|
339
730
|
async function _indexWorkspaceInner(workspacePath, uploadEndpoint, sessionId, options = {}) {
|
|
340
|
-
const { log = console.error, orgId, orgSlug } = options;
|
|
731
|
+
const { log = console.error, orgId, orgSlug, historyOnly = false } = options;
|
|
341
732
|
|
|
342
733
|
log(`[uploader] Scanning workspace: ${workspacePath}`);
|
|
343
734
|
|
|
735
|
+
if (historyOnly) {
|
|
736
|
+
return await uploadGitHistoryOnly(workspacePath, uploadEndpoint, sessionId, {
|
|
737
|
+
log,
|
|
738
|
+
orgId,
|
|
739
|
+
orgSlug,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
344
743
|
const bundle = await createBundle(workspacePath, { log });
|
|
345
744
|
if (!bundle) {
|
|
346
|
-
return
|
|
745
|
+
return await uploadGitHistoryOnly(workspacePath, uploadEndpoint, sessionId, {
|
|
746
|
+
log,
|
|
747
|
+
orgId,
|
|
748
|
+
orgSlug,
|
|
749
|
+
quietNoop: true,
|
|
750
|
+
});
|
|
347
751
|
}
|
|
348
752
|
|
|
349
753
|
try {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
computeLogicalRepoIdentity,
|
|
10
|
+
createBundle,
|
|
11
|
+
uploadBundle,
|
|
12
|
+
} from "../src/uploader.js";
|
|
13
|
+
|
|
14
|
+
test("computeLogicalRepoIdentity prefers normalized remote origin", async () => {
|
|
15
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-bridge-git-"));
|
|
16
|
+
try {
|
|
17
|
+
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
|
|
18
|
+
execFileSync("git", ["remote", "add", "origin", "git@github.com:Context-Engine-AI/Context-Engine.git"], {
|
|
19
|
+
cwd: tmpDir,
|
|
20
|
+
stdio: "ignore",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const identity = computeLogicalRepoIdentity(tmpDir);
|
|
24
|
+
assert.equal(identity.source, "remote_origin");
|
|
25
|
+
assert.match(identity.id, /^git:[0-9a-f]{16}$/);
|
|
26
|
+
} finally {
|
|
27
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("uploadBundle streams an exact multipart file payload", async () => {
|
|
32
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-bridge-upload-"));
|
|
33
|
+
const workspacePath = path.join(tmpDir, "workspace");
|
|
34
|
+
fs.mkdirSync(workspacePath, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const bundlePath = path.join(tmpDir, "bundle.tar.gz");
|
|
37
|
+
const bundleData = Buffer.from(Array.from({ length: 4096 }, (_, i) => i % 251));
|
|
38
|
+
fs.writeFileSync(bundlePath, bundleData);
|
|
39
|
+
|
|
40
|
+
let capturedBody = null;
|
|
41
|
+
let boundary = "";
|
|
42
|
+
const originalFetch = global.fetch;
|
|
43
|
+
global.fetch = async (_url, options) => {
|
|
44
|
+
boundary = String(options.headers["Content-Type"] || "").split("boundary=")[1] || "";
|
|
45
|
+
const chunks = [];
|
|
46
|
+
for await (const chunk of options.body) {
|
|
47
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
48
|
+
}
|
|
49
|
+
capturedBody = Buffer.concat(chunks);
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
async json() {
|
|
53
|
+
return { success: true };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const result = await uploadBundle(
|
|
60
|
+
bundlePath,
|
|
61
|
+
{
|
|
62
|
+
workspace_path: workspacePath,
|
|
63
|
+
collection_name: "demo-collection",
|
|
64
|
+
},
|
|
65
|
+
"http://example.invalid",
|
|
66
|
+
"session-token",
|
|
67
|
+
{ log: () => {} }
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
assert.equal(result.success, true);
|
|
71
|
+
assert.ok(capturedBody);
|
|
72
|
+
assert.ok(boundary);
|
|
73
|
+
|
|
74
|
+
const fileHeader = Buffer.from('Content-Disposition: form-data; name="bundle"; filename="bundle.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n');
|
|
75
|
+
const headerIndex = capturedBody.indexOf(fileHeader);
|
|
76
|
+
assert.ok(headerIndex >= 0, "bundle header not found in multipart body");
|
|
77
|
+
|
|
78
|
+
const payloadStart = headerIndex + fileHeader.length;
|
|
79
|
+
const payloadEnd = capturedBody.indexOf(Buffer.from(`\r\n--${boundary}\r\n`), payloadStart);
|
|
80
|
+
assert.ok(payloadEnd > payloadStart, "bundle payload terminator not found");
|
|
81
|
+
|
|
82
|
+
const uploadedPayload = capturedBody.subarray(payloadStart, payloadEnd);
|
|
83
|
+
assert.deepEqual(uploadedPayload, bundleData);
|
|
84
|
+
} finally {
|
|
85
|
+
global.fetch = originalFetch;
|
|
86
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
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
|
+
});
|