@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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "enableAllProjectMcpServers": true,
3
+ "enabledMcpjsonServers": [
4
+ "context-engine"
5
+ ],
6
+ "permissions": {
7
+ "allow": [
8
+ "mcp__context-engine__repo_search"
9
+ ]
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.73",
3
+ "version": "0.0.75",
4
4
  "description": "Context Engine MCP bridge (http/stdio proxy combining indexer + memory servers)",
5
5
  "bin": {
6
6
  "ctxce": "bin/ctxce.js",
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
- // Default to upload service when nothing else is configured
53
- return (process.env.CTXCE_UPLOAD_ENDPOINT || process.env.UPLOAD_ENDPOINT || "http://localhost:8004").trim();
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 || "http://localhost:8003/mcp";
31
- let memoryUrl = process.env.CTXCE_MEMORY_URL || null;
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 http://localhost:8003/mcp)
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 || "http://localhost:8003/mcp";
94
- let memoryUrl = process.env.CTXCE_MEMORY_URL || null;
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 hasChanges = false;
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
- hasChanges = true;
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
- hasChanges = true;
254
+ codeChanged = true;
253
255
  fileHashes.delete(oldPath);
254
256
  }
255
257
  }
256
258
 
257
- return hasChanges;
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
- setTimeout(doSync, DEFAULT_DEBOUNCE_MS);
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
- if (detectChanges()) {
307
- doSync();
334
+ const changeState = detectChanges();
335
+ if (changeState.codeChanged || changeState.historyChanged) {
336
+ doSync(changeState.historyChanged && !changeState.codeChanged);
308
337
  }
309
338
  }, intervalMs);
310
339
 
@@ -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="http://localhost:8004" required>
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
- const normalized = workspacePath.replace(/\\/g, "/").replace(/\/+$/, "");
73
- return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
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
- if (files.length === 0) {
125
- log("[uploader] No code files found in workspace");
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, ".metadata");
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: computeLogicalRepoId(workspacePath),
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 = computeLogicalRepoId(manifest.workspace_path);
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('node:stream');
267
- const bodyStream = new Readable({
268
- read() {
269
- // Push preamble
270
- this.push(filePreamble);
271
- // Push file data in chunks via sync read to keep it simple
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 { success: false, error: "No code files found" };
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
+ });