@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.74",
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/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
 
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
- if (files.length === 0) {
213
- 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
+ }
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, ".metadata");
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 { success: false, error: "No code files found" };
745
+ return await uploadGitHistoryOnly(workspacePath, uploadEndpoint, sessionId, {
746
+ log,
747
+ orgId,
748
+ orgSlug,
749
+ quietNoop: true,
750
+ });
426
751
  }
427
752
 
428
753
  try {
@@ -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 { computeLogicalRepoIdentity, uploadBundle } from "../src/uploader.js";
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
+ });