create-interview-cockpit 0.25.0 → 0.26.1

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.
@@ -164,6 +164,7 @@ function makeCodeReferenceFileEntry(
164
164
  }
165
165
 
166
166
  interface GitDiffContextPayload {
167
+ rootId?: string;
167
168
  baseRef?: string;
168
169
  headRef?: string;
169
170
  mode?: string;
@@ -177,82 +178,56 @@ async function registerGitDiffContext(
177
178
  fileRegistry: Map<string, ReferenceFileEntry>,
178
179
  ctx: GitDiffContextPayload,
179
180
  ): Promise<string> {
180
- if (!GIT_DIFF_DIR) return "";
181
- const base = (ctx.baseRef || "").trim();
182
- if (!base || !isValidRef(base)) return "";
183
- const mode = parseDiffMode(ctx.mode);
184
- const headRaw = (ctx.headRef || "").trim();
185
- const head = mode === "working-tree" ? WORKING_TREE_SENTINEL : headRaw;
186
- if (mode !== "working-tree" && (!head || !isValidRef(head))) return "";
187
-
188
- const selected = Array.isArray(ctx.selectedFiles)
189
- ? ctx.selectedFiles.filter(
190
- (p) => typeof p === "string" && p && !p.includes("\0"),
191
- )
192
- : [];
193
- if (selected.length === 0) return "";
194
-
195
- let changedFiles: GitDiffFileEntry[];
196
- try {
197
- changedFiles = await getChangedFiles(base, head, mode);
198
- } catch {
199
- return "";
200
- }
201
- const byPath = new Map(changedFiles.map((f) => [f.path, f]));
202
- const validSelected = selected.filter((p) => byPath.has(p));
203
- if (validSelected.length === 0) return "";
204
-
205
- const headLabel = mode === "working-tree" ? "working tree" : head;
206
- const rangeLabel =
207
- mode === "working-tree"
208
- ? `${base} → working tree`
209
- : `${base} ${mode === "two-dot" ? ".." : "..."} ${headLabel}`;
181
+ const scoped = await getScopedGitDiffContext(ctx);
182
+ if (!scoped) return "";
183
+ const { root, base, head, mode, entries, rangeLabel, headLabel } = scoped;
210
184
 
211
- let manifest = `\n\n--- Available Git Diff Context (${rangeLabel}) ---
212
- You can pull lazy git diff context with the readFile tool. Three views are available per file:
213
- • gitdiff:patch:<path> — unified diff hunks (recommended starting point)
214
- • gitdiff:before:<path> — full file contents at the base ref
215
- • gitdiff:after:<path> — full file contents at the head ref (or working tree)
216
- Only read what you need; each file can be expensive.
185
+ let manifest = `\n\n--- Available Git Diff Context (${root.name}: ${rangeLabel}) ---
186
+ You can inspect the selected changed files with the Git Diff tools or pull lazy views with readFile. Three views are available per file:
187
+ • gitdiff:patch:<root>::<path> — unified diff hunks (recommended starting point)
188
+ • gitdiff:before:<root>::<path> — full file contents at the base ref
189
+ • gitdiff:after:<root>::<path> — full file contents at the head ref (or working tree)
190
+ Use listGitDiffFiles to see selected diff files, readGitDiffFile to get patch/before/after, searchGitDiff to find specific changes, and summarizeGitDiff for totals. Only read what you need; each file can be expensive.
217
191
 
218
192
  `;
219
- for (const filePath of validSelected) {
220
- const entry = byPath.get(filePath)!;
193
+ for (const entry of entries) {
221
194
  const sign = entry.binary
222
195
  ? "binary"
223
196
  : `+${entry.additions}/-${entry.deletions}`;
224
197
  const renamed = entry.oldPath ? ` (renamed from ${entry.oldPath})` : "";
225
- manifest += `• [${entry.status}] ${entry.path} ${sign}${renamed}\n`;
226
- manifest += ` patch id: "gitdiff:patch:${entry.path}"\n`;
198
+ manifest += `• [${entry.status}] ${entry.displayPath} ${sign}${renamed}\n`;
199
+ manifest += ` patch id: "gitdiff:patch:${entry.id}"\n`;
227
200
  if (entry.status !== "A" && entry.status !== "?") {
228
- manifest += ` before id: "gitdiff:before:${entry.path}"\n`;
201
+ manifest += ` before id: "gitdiff:before:${entry.id}"\n`;
229
202
  }
230
203
  if (entry.status !== "D") {
231
- manifest += ` after id: "gitdiff:after:${entry.path}"\n`;
204
+ manifest += ` after id: "gitdiff:after:${entry.id}"\n`;
232
205
  }
233
206
 
234
- const safePath = filePath; // already validated to be in diff
207
+ const safePath = entry.path; // already validated to be in diff
235
208
  fileRegistry.set(
236
- `gitdiff:patch:${safePath}`,
237
- makeCodeReferenceFileEntry(`[git diff] ${safePath}`, () =>
238
- getDiffPatch(base, head, mode, safePath),
209
+ `gitdiff:patch:${entry.id}`,
210
+ makeCodeReferenceFileEntry(`[git diff:${root.name}] ${safePath}`, () =>
211
+ getDiffPatch(root, base, head, mode, safePath),
239
212
  ),
240
213
  );
241
214
  if (entry.status !== "A" && entry.status !== "?") {
242
215
  const refPath = entry.oldPath ?? safePath;
243
216
  fileRegistry.set(
244
- `gitdiff:before:${safePath}`,
245
- makeCodeReferenceFileEntry(`[git before ${base}] ${safePath}`, () =>
246
- getFileAtRef(base, refPath),
217
+ `gitdiff:before:${entry.id}`,
218
+ makeCodeReferenceFileEntry(
219
+ `[git before ${base}:${root.name}] ${safePath}`,
220
+ () => getFileAtRef(root, base, refPath),
247
221
  ),
248
222
  );
249
223
  }
250
224
  if (entry.status !== "D") {
251
225
  const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
252
226
  fileRegistry.set(
253
- `gitdiff:after:${safePath}`,
254
- makeCodeReferenceFileEntry(`[git after ${headLabel}] ${safePath}`, () =>
255
- getFileAtRef(ref, safePath),
227
+ `gitdiff:after:${entry.id}`,
228
+ makeCodeReferenceFileEntry(
229
+ `[git after ${headLabel}:${root.name}] ${safePath}`,
230
+ () => getFileAtRef(root, ref, safePath),
256
231
  ),
257
232
  );
258
233
  }
@@ -344,7 +319,124 @@ function createReadFileTool(fileRegistry: Map<string, ReferenceFileEntry>) {
344
319
  }
345
320
 
346
321
  const PORT = process.env.PORT || 3001;
347
- const CODE_CONTEXT_DIR = process.env.CODE_CONTEXT_DIR || "";
322
+ const CODE_CONTEXT_ROOT_SEPARATOR = "::";
323
+
324
+ interface CodeContextRootConfig {
325
+ id: string;
326
+ name: string;
327
+ dir: string;
328
+ }
329
+
330
+ interface CodeContextFileRef {
331
+ id: string;
332
+ root: CodeContextRootConfig;
333
+ relPath: string;
334
+ absolutePath: string;
335
+ }
336
+
337
+ function splitCodeContextRootEnv(value: string | undefined): string[] {
338
+ if (!value) return [];
339
+ return value
340
+ .split(/[\n,;]/)
341
+ .map((entry) => entry.trim())
342
+ .filter(Boolean);
343
+ }
344
+
345
+ function parseCodeContextRootSpec(
346
+ raw: string,
347
+ ): { name?: string; dir: string } | null {
348
+ const value = raw.trim();
349
+ if (!value) return null;
350
+ const eq = value.indexOf("=");
351
+ if (eq > 0) {
352
+ const maybeName = value.slice(0, eq).trim();
353
+ const maybeDir = value.slice(eq + 1).trim();
354
+ if (maybeName && maybeDir && !/[\\/]/.test(maybeName)) {
355
+ return { name: maybeName, dir: path.resolve(maybeDir) };
356
+ }
357
+ }
358
+ return { dir: path.resolve(value) };
359
+ }
360
+
361
+ function slugForCodeContextRoot(value: string): string {
362
+ return (
363
+ value
364
+ .toLowerCase()
365
+ .replace(/[^a-z0-9._-]+/g, "-")
366
+ .replace(/^-+|-+$/g, "") || "root"
367
+ );
368
+ }
369
+
370
+ function uniqueCodeContextRootName(
371
+ baseName: string,
372
+ usedNames: Map<string, number>,
373
+ ): string {
374
+ const clean = baseName.trim() || "Code Context";
375
+ const key = clean.toLowerCase();
376
+ const count = usedNames.get(key) ?? 0;
377
+ usedNames.set(key, count + 1);
378
+ return count === 0 ? clean : `${clean} (${count + 1})`;
379
+ }
380
+
381
+ function uniqueCodeContextRootId(baseId: string, usedIds: Set<string>): string {
382
+ let candidate = baseId;
383
+ let suffix = 2;
384
+ while (usedIds.has(candidate)) {
385
+ candidate = `${baseId}-${suffix}`;
386
+ suffix += 1;
387
+ }
388
+ usedIds.add(candidate);
389
+ return candidate;
390
+ }
391
+
392
+ function resolveCodeContextRoots(): CodeContextRootConfig[] {
393
+ const specs = [
394
+ ...(process.env.CODE_CONTEXT_DIR
395
+ ? [
396
+ process.env.CODE_CONTEXT_NAME
397
+ ? `${process.env.CODE_CONTEXT_NAME}=${process.env.CODE_CONTEXT_DIR}`
398
+ : process.env.CODE_CONTEXT_DIR,
399
+ ]
400
+ : []),
401
+ ...splitCodeContextRootEnv(process.env.CODE_CONTEXT_DIRS),
402
+ ];
403
+ const seenDirs = new Set<string>();
404
+ const usedNames = new Map<string, number>();
405
+ const usedIds = new Set<string>();
406
+ const roots: CodeContextRootConfig[] = [];
407
+
408
+ for (const spec of specs) {
409
+ const parsed = parseCodeContextRootSpec(spec);
410
+ if (!parsed) continue;
411
+ const dir = path.resolve(parsed.dir);
412
+ if (seenDirs.has(dir)) continue;
413
+ seenDirs.add(dir);
414
+ const fallbackName = path.basename(dir) || "Code Context";
415
+ const name = uniqueCodeContextRootName(
416
+ parsed.name || fallbackName,
417
+ usedNames,
418
+ );
419
+ const id = uniqueCodeContextRootId(slugForCodeContextRoot(name), usedIds);
420
+ roots.push({ id, name, dir });
421
+ }
422
+
423
+ return roots;
424
+ }
425
+
426
+ const CODE_CONTEXT_ROOTS = resolveCodeContextRoots();
427
+ const CODE_CONTEXT_ROOT_BY_ID = new Map(
428
+ CODE_CONTEXT_ROOTS.map((root) => [root.id, root]),
429
+ );
430
+ // Backward-compatible primary root used by git-diff config when GIT_DIFF_DIR is unset.
431
+ const CODE_CONTEXT_DIR = CODE_CONTEXT_ROOTS[0]?.dir ?? "";
432
+
433
+ function makeCodeContextFileId(rootId: string, relPath: string): string {
434
+ return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${relPath}`;
435
+ }
436
+
437
+ function getCodeContextPrimaryRoot(): CodeContextRootConfig | null {
438
+ return CODE_CONTEXT_ROOTS[0] ?? null;
439
+ }
348
440
 
349
441
  const MAX_CODE_TOOL_LIST_RESULTS = 400;
350
442
  const MAX_CODE_TOOL_SEARCH_RESULTS = 80;
@@ -362,38 +454,106 @@ function normalizeCodeContextRelPath(value: string): string | null {
362
454
  return normalized;
363
455
  }
364
456
 
365
- function resolveCodeContextFilePath(relPath: string): string | null {
366
- if (!CODE_CONTEXT_DIR) return null;
367
- const normalized = normalizeCodeContextRelPath(relPath);
457
+ function getCodeContextFileRef(
458
+ fileIdOrPath: string,
459
+ ): CodeContextFileRef | null {
460
+ if (CODE_CONTEXT_ROOTS.length === 0) return null;
461
+ const raw = fileIdOrPath.replaceAll("\\", "/").trim();
462
+ if (!raw || raw.includes("\0")) return null;
463
+
464
+ const sepIdx = raw.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
465
+ const root =
466
+ sepIdx > 0
467
+ ? CODE_CONTEXT_ROOT_BY_ID.get(raw.slice(0, sepIdx))
468
+ : getCodeContextPrimaryRoot();
469
+ const relRaw =
470
+ sepIdx > 0 ? raw.slice(sepIdx + CODE_CONTEXT_ROOT_SEPARATOR.length) : raw;
471
+ if (!root) return null;
472
+
473
+ const normalized = normalizeCodeContextRelPath(relRaw);
368
474
  if (!normalized || !isCodeContextPathAllowed(normalized)) return null;
369
475
 
370
- const root = path.resolve(CODE_CONTEXT_DIR);
371
- const resolved = path.resolve(root, normalized);
372
- const relativeToRoot = path.relative(root, resolved);
476
+ const rootDir = path.resolve(root.dir);
477
+ const resolved = path.resolve(rootDir, normalized);
478
+ const relativeToRoot = path.relative(rootDir, resolved);
373
479
  if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
374
480
  return null;
375
481
  }
376
- return resolved;
482
+ return {
483
+ id: makeCodeContextFileId(root.id, normalized),
484
+ root,
485
+ relPath: normalized,
486
+ absolutePath: resolved,
487
+ };
488
+ }
489
+
490
+ function resolveCodeContextFilePath(fileIdOrPath: string): string | null {
491
+ return getCodeContextFileRef(fileIdOrPath)?.absolutePath ?? null;
492
+ }
493
+
494
+ function codeContextRelPath(fileIdOrPath: string): string {
495
+ return getCodeContextFileRef(fileIdOrPath)?.relPath ?? fileIdOrPath;
496
+ }
497
+
498
+ function codeContextDisplayPath(fileIdOrPath: string): string {
499
+ const ref = getCodeContextFileRef(fileIdOrPath);
500
+ if (!ref) return fileIdOrPath;
501
+ return `${ref.root.name}/${ref.relPath}`;
502
+ }
503
+
504
+ function codeContextFilterText(fileIdOrPath: string): string {
505
+ const ref = getCodeContextFileRef(fileIdOrPath);
506
+ if (!ref) return fileIdOrPath;
507
+ return `${ref.id} ${ref.root.name} ${ref.relPath}`;
508
+ }
509
+
510
+ async function getVisibleFilesForCodeContextRoot(
511
+ root: CodeContextRootConfig,
512
+ ): Promise<Set<string>> {
513
+ try {
514
+ return new Set(await walkDir(root.dir));
515
+ } catch {
516
+ return new Set();
517
+ }
518
+ }
519
+
520
+ async function getCodeContextRootsTree() {
521
+ const roots = [];
522
+ for (const root of CODE_CONTEXT_ROOTS) {
523
+ const files = [...(await getVisibleFilesForCodeContextRoot(root))].sort(
524
+ (a, b) => a.localeCompare(b),
525
+ );
526
+ roots.push({ id: root.id, name: root.name, files });
527
+ }
528
+ return { roots };
377
529
  }
378
530
 
379
531
  async function getScopedCodeContextFiles(
380
532
  selectedFiles: unknown,
381
533
  ): Promise<string[]> {
382
- if (!CODE_CONTEXT_DIR || !Array.isArray(selectedFiles)) return [];
534
+ if (CODE_CONTEXT_ROOTS.length === 0 || !Array.isArray(selectedFiles)) {
535
+ return [];
536
+ }
383
537
 
384
538
  const selected = selectedFiles
385
539
  .filter((file): file is string => typeof file === "string")
386
- .map((file) => normalizeCodeContextRelPath(file))
387
- .filter((file): file is string => Boolean(file));
540
+ .map((file) => getCodeContextFileRef(file))
541
+ .filter((file): file is CodeContextFileRef => Boolean(file));
388
542
 
389
543
  if (selected.length === 0) return [];
390
544
 
391
- try {
392
- const visibleFiles = new Set(await walkDir(CODE_CONTEXT_DIR));
393
- return uniqueStrings(selected).filter((file) => visibleFiles.has(file));
394
- } catch {
395
- return [];
545
+ const visibleByRoot = new Map<string, Set<string>>();
546
+ for (const root of CODE_CONTEXT_ROOTS.filter((root) =>
547
+ selected.some((file) => file.root.id === root.id),
548
+ )) {
549
+ visibleByRoot.set(root.id, await getVisibleFilesForCodeContextRoot(root));
396
550
  }
551
+
552
+ return uniqueStrings(
553
+ selected
554
+ .filter((file) => visibleByRoot.get(file.root.id)?.has(file.relPath))
555
+ .map((file) => file.id),
556
+ );
397
557
  }
398
558
 
399
559
  function escapeRegExp(value: string): string {
@@ -420,21 +580,31 @@ function trimToolLine(line: string): string {
420
580
  function codePathMatchesFilter(relPath: string, filter?: string): boolean {
421
581
  const normalizedFilter = filter?.trim().replaceAll("\\", "/").toLowerCase();
422
582
  if (!normalizedFilter) return true;
423
- return relPath.toLowerCase().includes(normalizedFilter);
583
+ return codeContextFilterText(relPath)
584
+ .toLowerCase()
585
+ .includes(normalizedFilter);
424
586
  }
425
587
 
426
588
  function formatCodeFileTree(paths: string[], limit: number): string {
427
589
  const lines: string[] = [];
428
- const seenDirs = new Set<string>();
429
- for (const relPath of paths.slice(0, limit)) {
590
+ const seen = new Set<string>();
591
+ for (const fileId of paths.slice(0, limit)) {
592
+ const ref = getCodeContextFileRef(fileId);
593
+ const rootLabel = ref?.root.name ?? "Code Context";
594
+ const relPath = ref?.relPath ?? fileId;
595
+ const rootKey = `root:${rootLabel}`;
596
+ if (!seen.has(rootKey)) {
597
+ seen.add(rootKey);
598
+ lines.push(`${rootLabel}/`);
599
+ }
430
600
  const parts = relPath.split("/");
431
601
  for (let i = 0; i < parts.length - 1; i++) {
432
- const dirPath = parts.slice(0, i + 1).join("/");
433
- if (seenDirs.has(dirPath)) continue;
434
- seenDirs.add(dirPath);
435
- lines.push(`${" ".repeat(i)}${parts[i]}/`);
602
+ const dirPath = `${rootLabel}:${parts.slice(0, i + 1).join("/")}`;
603
+ if (seen.has(dirPath)) continue;
604
+ seen.add(dirPath);
605
+ lines.push(`${" ".repeat(i + 1)}${parts[i]}/`);
436
606
  }
437
- lines.push(`${" ".repeat(Math.max(0, parts.length - 1))}${parts.at(-1)}`);
607
+ lines.push(`${" ".repeat(Math.max(1, parts.length))}${parts.at(-1)}`);
438
608
  }
439
609
  return lines.join("\n");
440
610
  }
@@ -502,6 +672,7 @@ async function searchCodeInFiles(
502
672
  );
503
673
  const matches: Array<{
504
674
  path: string;
675
+ displayPath: string;
505
676
  line: number;
506
677
  lineText: string;
507
678
  before: Array<{ line: number; text: string }>;
@@ -532,6 +703,7 @@ async function searchCodeInFiles(
532
703
  const afterEnd = Math.min(lines.length, i + contextLines + 1);
533
704
  matches.push({
534
705
  path: relPath,
706
+ displayPath: codeContextDisplayPath(relPath),
535
707
  line: i + 1,
536
708
  lineText: trimToolLine(lines[i]),
537
709
  before: lines.slice(beforeStart, i).map((text, offset) => ({
@@ -567,7 +739,7 @@ async function searchCodeInFiles(
567
739
  }
568
740
 
569
741
  function classifyConfigFile(relPath: string): string | null {
570
- const lower = relPath.toLowerCase();
742
+ const lower = codeContextRelPath(relPath).toLowerCase();
571
743
  const base = lower.split("/").at(-1) || lower;
572
744
  if (/^\.github\/workflows\/.+\.ya?ml$/.test(lower)) {
573
745
  return "github-actions-workflow";
@@ -606,7 +778,8 @@ function classifyConfigFile(relPath: string): string | null {
606
778
  }
607
779
 
608
780
  function summarizeConfigStructure(relPath: string, content: string) {
609
- const lower = relPath.toLowerCase();
781
+ const relativePath = codeContextRelPath(relPath);
782
+ const lower = relativePath.toLowerCase();
610
783
  if (lower.endsWith(".json")) {
611
784
  try {
612
785
  const parsed = JSON.parse(content);
@@ -628,7 +801,7 @@ function summarizeConfigStructure(relPath: string, content: string) {
628
801
  }
629
802
  }
630
803
 
631
- if (/\.ya?ml$/i.test(relPath)) {
804
+ if (/\.ya?ml$/i.test(relativePath)) {
632
805
  const topLevelKeys = uniqueStrings(
633
806
  content
634
807
  .split(/\r?\n/)
@@ -638,7 +811,7 @@ function summarizeConfigStructure(relPath: string, content: string) {
638
811
  return { topLevelKeys };
639
812
  }
640
813
 
641
- if (/\.(tf|tfvars|hcl)$/i.test(relPath)) {
814
+ if (/\.(tf|tfvars|hcl)$/i.test(relativePath)) {
642
815
  const blocks = uniqueStrings(
643
816
  content
644
817
  .split(/\r?\n/)
@@ -669,7 +842,8 @@ function extractConfigHighlights(content: string) {
669
842
  }
670
843
 
671
844
  function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
672
- if (!CODE_CONTEXT_DIR || scopedCodeFiles.length === 0) return {};
845
+ if (CODE_CONTEXT_ROOTS.length === 0 || scopedCodeFiles.length === 0)
846
+ return {};
673
847
 
674
848
  const files = uniqueStrings(scopedCodeFiles).sort((a, b) =>
675
849
  a.localeCompare(b),
@@ -679,7 +853,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
679
853
  return {
680
854
  listCodeFiles: tool({
681
855
  description:
682
- "List code-context files that are available to the model. This is read-only and restricted to files the user selected in Code Context, under CODE_CONTEXT_DIR, after ignore rules are applied.",
856
+ "List code-context files that are available to the model. This is read-only and restricted to files the user selected in Code Context, under configured code roots, after ignore rules are applied.",
683
857
  inputSchema: z.object({
684
858
  query: z
685
859
  .string()
@@ -719,6 +893,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
719
893
  totalMatched: filtered.length,
720
894
  truncated: filtered.length > listed.length,
721
895
  files: listed,
896
+ displayFiles: listed.map(codeContextDisplayPath),
722
897
  tree: formatCodeFileTree(listed, cappedLimit),
723
898
  };
724
899
  },
@@ -756,17 +931,20 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
756
931
  }),
757
932
  readCodeFile: tool({
758
933
  description:
759
- "Read one available code-context file by relative path, optionally with a 1-based line range. Restricted to selected, non-ignored files.",
934
+ "Read one available code-context file by canonical path, optionally with a 1-based line range. Restricted to selected, non-ignored files.",
760
935
  inputSchema: z.object({
761
936
  path: z
762
937
  .string()
763
- .describe("Relative path from the code context file list."),
938
+ .describe(
939
+ "Canonical path from listCodeFiles, e.g. root::src/file.ts. Legacy relative paths resolve against the first root.",
940
+ ),
764
941
  startLine: z.number().int().min(1).optional(),
765
942
  endLine: z.number().int().min(1).optional(),
766
943
  }),
767
944
  execute: async ({ path: requestedPath, startLine, endLine }) => {
768
- const relPath = normalizeCodeContextRelPath(requestedPath || "");
769
- if (!relPath || !fileSet.has(relPath)) {
945
+ const ref = getCodeContextFileRef(requestedPath || "");
946
+ const fileId = ref?.id;
947
+ if (!ref || !fileId || !fileSet.has(fileId)) {
770
948
  return {
771
949
  type: "error",
772
950
  error:
@@ -775,7 +953,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
775
953
  }
776
954
 
777
955
  try {
778
- const read = await readCodeTextWithinLimit(relPath);
956
+ const read = await readCodeTextWithinLimit(fileId);
779
957
  const lines = read.content.split(/\r?\n/);
780
958
  const totalLines = lines.length;
781
959
  const first = clampNumber(startLine, 1, totalLines || 1, 1);
@@ -790,7 +968,8 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
790
968
  .map((line, index) => `${first + index}: ${line}`)
791
969
  .join("\n");
792
970
  return {
793
- path: relPath,
971
+ path: fileId,
972
+ displayPath: codeContextDisplayPath(fileId),
794
973
  startLine: first,
795
974
  endLine: last,
796
975
  totalLines,
@@ -865,6 +1044,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
865
1044
  const read = await readCodeTextWithinLimit(file);
866
1045
  inspected.push({
867
1046
  path: file,
1047
+ displayPath: codeContextDisplayPath(file),
868
1048
  kind,
869
1049
  truncated: read.truncated,
870
1050
  structure: summarizeConfigStructure(file, read.content),
@@ -873,6 +1053,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
873
1053
  } catch (err: any) {
874
1054
  inspected.push({
875
1055
  path: file,
1056
+ displayPath: codeContextDisplayPath(file),
876
1057
  kind,
877
1058
  error: err?.message || "Could not inspect file",
878
1059
  });
@@ -934,8 +1115,12 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
934
1115
  if (command === "pwd") {
935
1116
  return {
936
1117
  command: "pwd",
937
- output: CODE_CONTEXT_DIR,
938
- note: "Read-only tools are still restricted to selected, non-ignored code-context files.",
1118
+ roots: CODE_CONTEXT_ROOTS.map((root) => ({
1119
+ id: root.id,
1120
+ name: root.name,
1121
+ path: root.dir,
1122
+ })),
1123
+ note: "Read-only tools are still restricted to selected, non-ignored code-context files across these roots.",
939
1124
  };
940
1125
  }
941
1126
  if (command === "find") {
@@ -949,6 +1134,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
949
1134
  return {
950
1135
  command: "find",
951
1136
  files: filtered,
1137
+ displayFiles: filtered.map(codeContextDisplayPath),
952
1138
  truncated: filtered.length >= limit,
953
1139
  };
954
1140
  }
@@ -965,28 +1151,47 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
965
1151
 
966
1152
  if (command === "git-ls-files") {
967
1153
  try {
968
- const result = await runGit(["ls-files", "-z"], {
969
- cwd: CODE_CONTEXT_DIR,
970
- allowFail: true,
971
- timeoutMs: 5000,
972
- maxBuffer: 1024 * 1024,
973
- });
974
- if (result.code !== 0) {
975
- return {
976
- type: "error",
977
- error: result.stderr.trim() || "git ls-files failed",
978
- };
1154
+ const matchingFiles: string[] = [];
1155
+ const roots = CODE_CONTEXT_ROOTS.filter((root) =>
1156
+ files.some(
1157
+ (file) => getCodeContextFileRef(file)?.root.id === root.id,
1158
+ ),
1159
+ );
1160
+ const errors: string[] = [];
1161
+
1162
+ for (const root of roots) {
1163
+ const result = await runGit(["ls-files", "-z"], {
1164
+ cwd: root.dir,
1165
+ allowFail: true,
1166
+ timeoutMs: 5000,
1167
+ maxBuffer: 1024 * 1024,
1168
+ });
1169
+ if (result.code !== 0) {
1170
+ errors.push(
1171
+ `${root.name}: ${result.stderr.trim() || "git ls-files failed"}`,
1172
+ );
1173
+ continue;
1174
+ }
1175
+ const tracked = new Set(
1176
+ result.stdout.split("\0").filter(Boolean),
1177
+ );
1178
+ for (const file of files) {
1179
+ const ref = getCodeContextFileRef(file);
1180
+ if (ref?.root.id !== root.id) continue;
1181
+ if (
1182
+ tracked.has(ref.relPath) &&
1183
+ codePathMatchesFilter(file, pathFilter)
1184
+ ) {
1185
+ matchingFiles.push(file);
1186
+ }
1187
+ }
979
1188
  }
980
- const tracked = new Set(result.stdout.split("\0").filter(Boolean));
981
- const filtered = files
982
- .filter(
983
- (file) =>
984
- tracked.has(file) && codePathMatchesFilter(file, pathFilter),
985
- )
986
- .slice(0, limit);
1189
+ const filtered = matchingFiles.slice(0, limit);
987
1190
  return {
988
1191
  command: "git ls-files",
989
1192
  files: filtered,
1193
+ displayFiles: filtered.map(codeContextDisplayPath),
1194
+ errors,
990
1195
  truncated: filtered.length >= limit,
991
1196
  };
992
1197
  } catch (err: any) {
@@ -997,8 +1202,9 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
997
1202
  }
998
1203
  }
999
1204
 
1000
- const relPath = normalizeCodeContextRelPath(requestedPath || "");
1001
- if (!relPath || !fileSet.has(relPath)) {
1205
+ const fileRef = getCodeContextFileRef(requestedPath || "");
1206
+ const fileId = fileRef?.id;
1207
+ if (!fileRef || !fileId || !fileSet.has(fileId)) {
1002
1208
  return {
1003
1209
  type: "error",
1004
1210
  error:
@@ -1015,10 +1221,10 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
1015
1221
  "--decorate",
1016
1222
  `--max-count=${Math.min(limit, 50)}`,
1017
1223
  "--",
1018
- relPath,
1224
+ fileRef.relPath,
1019
1225
  ],
1020
1226
  {
1021
- cwd: CODE_CONTEXT_DIR,
1227
+ cwd: fileRef.root.dir,
1022
1228
  allowFail: true,
1023
1229
  timeoutMs: 5000,
1024
1230
  maxBuffer: 512 * 1024,
@@ -1026,7 +1232,8 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
1026
1232
  );
1027
1233
  return {
1028
1234
  command: "git log",
1029
- path: relPath,
1235
+ path: fileId,
1236
+ displayPath: codeContextDisplayPath(fileId),
1030
1237
  output:
1031
1238
  result.stdout.trim() ||
1032
1239
  result.stderr.trim() ||
@@ -1044,16 +1251,17 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
1044
1251
  const last = Math.max(first, endLine ?? first);
1045
1252
  args.push("-L", `${first},${last}`);
1046
1253
  }
1047
- args.push("--", relPath);
1254
+ args.push("--", fileRef.relPath);
1048
1255
  const result = await runGit(args, {
1049
- cwd: CODE_CONTEXT_DIR,
1256
+ cwd: fileRef.root.dir,
1050
1257
  allowFail: true,
1051
1258
  timeoutMs: 5000,
1052
1259
  maxBuffer: 512 * 1024,
1053
1260
  });
1054
1261
  return {
1055
1262
  command: "git blame",
1056
- path: relPath,
1263
+ path: fileId,
1264
+ displayPath: codeContextDisplayPath(fileId),
1057
1265
  output:
1058
1266
  result.stdout.trim() ||
1059
1267
  result.stderr.trim() ||
@@ -1067,6 +1275,280 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
1067
1275
  };
1068
1276
  }
1069
1277
 
1278
+ function createGitDiffTools(ctx: GitDiffContextPayload): ToolSet {
1279
+ if (
1280
+ GIT_DIFF_ROOTS.length === 0 ||
1281
+ !Array.isArray(ctx.selectedFiles) ||
1282
+ ctx.selectedFiles.length === 0
1283
+ ) {
1284
+ return {};
1285
+ }
1286
+
1287
+ async function loadScoped() {
1288
+ const scoped = await getScopedGitDiffContext(ctx);
1289
+ if (!scoped) {
1290
+ throw new Error(
1291
+ "No selected git diff files are available for this root/range.",
1292
+ );
1293
+ }
1294
+ return scoped;
1295
+ }
1296
+
1297
+ function formatEntry(entry: ScopedGitDiffEntry) {
1298
+ return {
1299
+ path: entry.id,
1300
+ displayPath: entry.displayPath,
1301
+ relativePath: entry.path,
1302
+ oldPath: entry.oldPath,
1303
+ status: entry.status,
1304
+ additions: entry.additions,
1305
+ deletions: entry.deletions,
1306
+ binary: entry.binary,
1307
+ };
1308
+ }
1309
+
1310
+ return {
1311
+ listGitDiffFiles: tool({
1312
+ description:
1313
+ "List selected git diff files for the configured diff root/range. Restricted to files the user selected in Git Diff context.",
1314
+ inputSchema: z.object({
1315
+ query: z
1316
+ .string()
1317
+ .optional()
1318
+ .describe("Optional case-insensitive path/root/status filter."),
1319
+ status: z
1320
+ .enum(["A", "M", "D", "R", "C", "T", "U", "?"])
1321
+ .optional()
1322
+ .describe("Optional git status filter."),
1323
+ limit: z.number().int().min(1).max(500).optional(),
1324
+ }),
1325
+ execute: async ({ query, status, limit }) => {
1326
+ try {
1327
+ const scoped = await loadScoped();
1328
+ const filter = query?.trim().toLowerCase();
1329
+ const cappedLimit = clampNumber(limit, 1, 500, 200);
1330
+ const matched = scoped.entries.filter((entry) => {
1331
+ if (status && entry.status !== status) return false;
1332
+ if (!filter) return true;
1333
+ return `${entry.id} ${entry.displayPath} ${entry.status}`
1334
+ .toLowerCase()
1335
+ .includes(filter);
1336
+ });
1337
+ return {
1338
+ root: {
1339
+ id: scoped.root.id,
1340
+ name: scoped.root.name,
1341
+ },
1342
+ range: scoped.rangeLabel,
1343
+ totalSelected: scoped.entries.length,
1344
+ totalMatched: matched.length,
1345
+ truncated: matched.length > cappedLimit,
1346
+ files: matched.slice(0, cappedLimit).map(formatEntry),
1347
+ };
1348
+ } catch (err: any) {
1349
+ return { type: "error", error: err?.message || "diff list failed" };
1350
+ }
1351
+ },
1352
+ }),
1353
+ readGitDiffFile: tool({
1354
+ description:
1355
+ "Read a selected git diff file as a patch, before blob, or after blob. Use patch for exact changed hunks; use before/after to compare full file versions.",
1356
+ inputSchema: z.object({
1357
+ path: z
1358
+ .string()
1359
+ .describe(
1360
+ "Canonical path from listGitDiffFiles, e.g. root::src/file.ts. Legacy relative paths resolve against the selected diff root.",
1361
+ ),
1362
+ view: z
1363
+ .enum(["patch", "before", "after"])
1364
+ .optional()
1365
+ .describe("Which diff view to read. Defaults to patch."),
1366
+ startLine: z.number().int().min(1).optional(),
1367
+ endLine: z.number().int().min(1).optional(),
1368
+ }),
1369
+ execute: async ({ path: requestedPath, view, startLine, endLine }) => {
1370
+ try {
1371
+ const scoped = await loadScoped();
1372
+ const entry = findScopedGitDiffEntry(scoped, requestedPath);
1373
+ if (!entry) {
1374
+ return {
1375
+ type: "error",
1376
+ error: "File is not in the selected git diff context.",
1377
+ };
1378
+ }
1379
+ const selectedView = view ?? "patch";
1380
+ const content = await readGitDiffView(scoped, entry, selectedView);
1381
+ const lines = content.split(/\r?\n/);
1382
+ const totalLines = lines.length;
1383
+ const first = clampNumber(startLine, 1, totalLines || 1, 1);
1384
+ const last = clampNumber(
1385
+ endLine,
1386
+ first,
1387
+ totalLines || first,
1388
+ totalLines || first,
1389
+ );
1390
+ const selectedLines = lines
1391
+ .slice(first - 1, last)
1392
+ .map((line, index) => `${first + index}: ${line}`)
1393
+ .join("\n");
1394
+ return {
1395
+ path: entry.id,
1396
+ displayPath: entry.displayPath,
1397
+ view: selectedView,
1398
+ status: entry.status,
1399
+ startLine: first,
1400
+ endLine: last,
1401
+ totalLines,
1402
+ content: selectedLines || "(empty)",
1403
+ };
1404
+ } catch (err: any) {
1405
+ return { type: "error", error: err?.message || "diff read failed" };
1406
+ }
1407
+ },
1408
+ }),
1409
+ searchGitDiff: tool({
1410
+ description:
1411
+ "Search selected git diff patches and/or before/after blobs for exact text or a regular expression. Useful for finding specific changed symbols, config keys, or hunks.",
1412
+ inputSchema: z.object({
1413
+ query: z.string().min(1),
1414
+ view: z
1415
+ .enum(["patch", "before", "after", "all"])
1416
+ .optional()
1417
+ .describe("Which view to search. Defaults to patch."),
1418
+ regex: z.boolean().optional(),
1419
+ caseSensitive: z.boolean().optional(),
1420
+ pathFilter: z.string().optional(),
1421
+ limit: z.number().int().min(1).max(200).optional(),
1422
+ }),
1423
+ execute: async ({
1424
+ query,
1425
+ view,
1426
+ regex,
1427
+ caseSensitive,
1428
+ pathFilter,
1429
+ limit,
1430
+ }) => {
1431
+ try {
1432
+ const scoped = await loadScoped();
1433
+ const cappedLimit = clampNumber(limit, 1, 200, 80);
1434
+ const selectedView = view ?? "patch";
1435
+ const flags = caseSensitive ? "" : "i";
1436
+ const pattern = new RegExp(
1437
+ regex ? query : escapeRegExp(query),
1438
+ flags,
1439
+ );
1440
+ const filter = pathFilter?.trim().toLowerCase();
1441
+ const views =
1442
+ selectedView === "all"
1443
+ ? (["patch", "before", "after"] as const)
1444
+ : ([selectedView] as const);
1445
+ const matches: Array<{
1446
+ path: string;
1447
+ displayPath: string;
1448
+ view: "patch" | "before" | "after";
1449
+ line: number;
1450
+ lineText: string;
1451
+ }> = [];
1452
+
1453
+ for (const entry of scoped.entries) {
1454
+ if (
1455
+ filter &&
1456
+ !`${entry.id} ${entry.displayPath}`.toLowerCase().includes(filter)
1457
+ ) {
1458
+ continue;
1459
+ }
1460
+ for (const currentView of views) {
1461
+ if (
1462
+ currentView === "before" &&
1463
+ (entry.status === "A" || entry.status === "?")
1464
+ ) {
1465
+ continue;
1466
+ }
1467
+ if (currentView === "after" && entry.status === "D") {
1468
+ continue;
1469
+ }
1470
+ const content = await readGitDiffView(scoped, entry, currentView);
1471
+ const lines = content.split(/\r?\n/);
1472
+ for (let i = 0; i < lines.length; i += 1) {
1473
+ pattern.lastIndex = 0;
1474
+ if (!pattern.test(lines[i])) continue;
1475
+ matches.push({
1476
+ path: entry.id,
1477
+ displayPath: entry.displayPath,
1478
+ view: currentView,
1479
+ line: i + 1,
1480
+ lineText: trimToolLine(lines[i]),
1481
+ });
1482
+ if (matches.length >= cappedLimit) {
1483
+ return {
1484
+ query,
1485
+ view: selectedView,
1486
+ totalMatches: matches.length,
1487
+ truncated: true,
1488
+ matches,
1489
+ };
1490
+ }
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ return {
1496
+ query,
1497
+ view: selectedView,
1498
+ totalMatches: matches.length,
1499
+ truncated: false,
1500
+ matches,
1501
+ };
1502
+ } catch (err: any) {
1503
+ return { type: "error", error: err?.message || "diff search failed" };
1504
+ }
1505
+ },
1506
+ }),
1507
+ summarizeGitDiff: tool({
1508
+ description:
1509
+ "Summarize the selected git diff context with totals by status and additions/deletions. Use this before deeper reads when multiple files are selected.",
1510
+ inputSchema: z.object({
1511
+ includeFiles: z.boolean().optional(),
1512
+ }),
1513
+ execute: async ({ includeFiles }) => {
1514
+ try {
1515
+ const scoped = await loadScoped();
1516
+ const byStatus: Record<string, number> = {};
1517
+ let additions = 0;
1518
+ let deletions = 0;
1519
+ let binaryFiles = 0;
1520
+ for (const entry of scoped.entries) {
1521
+ byStatus[entry.status] = (byStatus[entry.status] ?? 0) + 1;
1522
+ additions += entry.additions;
1523
+ deletions += entry.deletions;
1524
+ if (entry.binary) binaryFiles += 1;
1525
+ }
1526
+ return {
1527
+ root: {
1528
+ id: scoped.root.id,
1529
+ name: scoped.root.name,
1530
+ },
1531
+ range: scoped.rangeLabel,
1532
+ filesChanged: scoped.entries.length,
1533
+ additions,
1534
+ deletions,
1535
+ binaryFiles,
1536
+ byStatus,
1537
+ ...(includeFiles
1538
+ ? { files: scoped.entries.map(formatEntry).slice(0, 200) }
1539
+ : {}),
1540
+ };
1541
+ } catch (err: any) {
1542
+ return {
1543
+ type: "error",
1544
+ error: err?.message || "diff summary failed",
1545
+ };
1546
+ }
1547
+ },
1548
+ }),
1549
+ };
1550
+ }
1551
+
1070
1552
  // ─── AI Providers (Vercel AI SDK) ────────────────────────
1071
1553
  // Set the provider + model in .env. Supports: openai, google, anthropic
1072
1554
 
@@ -1720,6 +2202,7 @@ app.patch("/api/questions/:id", async (req, res) => {
1720
2202
  delete q.gitDiffContext;
1721
2203
  } else if (gdc && typeof gdc === "object") {
1722
2204
  q.gitDiffContext = {
2205
+ rootId: String(gdc.rootId || ""),
1723
2206
  baseRef: String(gdc.baseRef || ""),
1724
2207
  headRef: String(gdc.headRef || ""),
1725
2208
  mode:
@@ -2710,14 +3193,15 @@ app.post("/api/chat", async (req, res) => {
2710
3193
  await getScopedCodeContextFiles(codeContextFiles);
2711
3194
 
2712
3195
  // Code-context files from the project directory
2713
- if (scopedCodeContextFiles.length && CODE_CONTEXT_DIR) {
3196
+ if (scopedCodeContextFiles.length && CODE_CONTEXT_ROOTS.length > 0) {
2714
3197
  for (const filePath of scopedCodeContextFiles) {
2715
- const resolved = resolveCodeContextFilePath(filePath);
2716
- if (!resolved) continue;
3198
+ const ref = getCodeContextFileRef(filePath);
3199
+ if (!ref) continue;
2717
3200
  fileRegistry.set(
2718
3201
  `code:${filePath}`,
2719
- makeCodeReferenceFileEntry(`[code] ${filePath}`, () =>
2720
- fs.readFile(resolved, "utf-8"),
3202
+ makeCodeReferenceFileEntry(
3203
+ `[code:${ref.root.name}] ${ref.relPath}`,
3204
+ () => fs.readFile(ref.absolutePath, "utf-8"),
2721
3205
  ),
2722
3206
  );
2723
3207
  }
@@ -2762,18 +3246,18 @@ You also have read-only code tools for the selected Code Context files:
2762
3246
  - inspectCiConfig: summarize CI/CD, package, Docker, and Terraform config among selected files.
2763
3247
  - runReadOnlyCodeCommand: safe whitelisted equivalents for pwd/find/rg/git-ls-files/git-log/git-blame.
2764
3248
 
2765
- These tools cannot access files outside CODE_CONTEXT_DIR, files hidden by ignore rules, or files the user did not select. If something is missing, ask the user to select more code context or adjust ignore settings.
3249
+ These tools cannot access files outside the configured code-context roots, files hidden by ignore rules, or files the user did not select. File IDs are root-qualified like "root::relative/path". If something is missing, ask the user to select more code context or adjust ignore settings.
2766
3250
 
2767
3251
  --- Linking Code Files in Your Response ---
2768
3252
  When you mention or reference one of the **[code]** files above in your response text, format it as a clickable link so the user can open it directly:
2769
3253
 
2770
- [DisplayText](coderef://relative/path/to/file)
3254
+ [DisplayText](coderef://root::relative/path/to/file)
2771
3255
 
2772
3256
  Examples:
2773
- [EmployeesController](coderef://src/Controllers/EmployeesController.cs)
2774
- [SettleDeferredPaymentCaseRequest](coderef://src/Requests/SettleDeferredPaymentCaseRequest.cs)
3257
+ [EmployeesController](coderef://backend::src/Controllers/EmployeesController.cs)
3258
+ [SettleDeferredPaymentCaseRequest](coderef://api::src/Requests/SettleDeferredPaymentCaseRequest.cs)
2775
3259
 
2776
- Use this for class names, method names, or any mention of a specific file from the code context. The display text should be the class, file, or concept name — not the raw path.
3260
+ Use this for class names, method names, or any mention of a specific file from the code context. The href must use the exact code file id shown above without the "code:" prefix. The display text should be the class, file, or concept name — not the raw path.
2777
3261
  Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
2778
3262
  }
2779
3263
 
@@ -2896,6 +3380,7 @@ Examples (illustrative only — use real ids and names from the list above):
2896
3380
  }
2897
3381
  : {}),
2898
3382
  ...createCodeContextTools(scopedCodeContextFiles),
3383
+ ...createGitDiffTools(gitDiffContext || {}),
2899
3384
  },
2900
3385
  stopWhen: stepCountIs(selectedResponseProfile.maxSteps),
2901
3386
  });
@@ -3134,30 +3619,34 @@ async function walkDir(dir: string, prefix = ""): Promise<string[]> {
3134
3619
  }
3135
3620
 
3136
3621
  app.get("/api/code-context/tree", async (_req, res) => {
3137
- if (!CODE_CONTEXT_DIR) return res.json([]);
3622
+ if (CODE_CONTEXT_ROOTS.length === 0) return res.json({ roots: [] });
3138
3623
  try {
3139
- const files = await walkDir(CODE_CONTEXT_DIR);
3140
- res.json(files);
3624
+ res.json(await getCodeContextRootsTree());
3141
3625
  } catch {
3142
- res.json([]);
3626
+ res.json({ roots: [] });
3143
3627
  }
3144
3628
  });
3145
3629
 
3146
3630
  app.get("/api/code-context/file", async (req, res) => {
3147
- if (!CODE_CONTEXT_DIR)
3148
- return res.status(400).json({ error: "No code context directory" });
3631
+ if (CODE_CONTEXT_ROOTS.length === 0)
3632
+ return res.status(400).json({ error: "No code context roots" });
3149
3633
  const filePath = req.query.path as string;
3150
3634
  if (!filePath) return res.status(400).json({ error: "Path required" });
3151
3635
 
3152
- const normalized = normalizeCodeContextRelPath(filePath);
3153
- const resolved = normalized ? resolveCodeContextFilePath(normalized) : null;
3154
- if (!normalized || !resolved) {
3636
+ const ref = getCodeContextFileRef(filePath);
3637
+ if (!ref) {
3155
3638
  return res.status(403).json({ error: "Access denied" });
3156
3639
  }
3157
3640
 
3158
3641
  try {
3159
- const content = await fs.readFile(resolved, "utf-8");
3160
- res.json({ path: normalized, content });
3642
+ const content = await fs.readFile(ref.absolutePath, "utf-8");
3643
+ res.json({
3644
+ path: ref.id,
3645
+ rootId: ref.root.id,
3646
+ rootName: ref.root.name,
3647
+ relativePath: ref.relPath,
3648
+ content,
3649
+ });
3161
3650
  } catch {
3162
3651
  res.status(404).json({ error: "File not found" });
3163
3652
  }
@@ -3170,7 +3659,108 @@ app.get("/api/code-context/file", async (req, res) => {
3170
3659
  // or before/after blobs are pulled lazily via the readFile tool using ids of
3171
3660
  // the form gitdiff:patch:<path> / gitdiff:before:<path> / gitdiff:after:<path>.
3172
3661
 
3173
- const GIT_DIFF_DIR = process.env.GIT_DIFF_DIR || CODE_CONTEXT_DIR;
3662
+ function resolveGitDiffRoots(): CodeContextRootConfig[] {
3663
+ const specs = [
3664
+ ...(process.env.GIT_DIFF_DIR
3665
+ ? [
3666
+ process.env.GIT_DIFF_NAME
3667
+ ? `${process.env.GIT_DIFF_NAME}=${process.env.GIT_DIFF_DIR}`
3668
+ : process.env.GIT_DIFF_DIR,
3669
+ ]
3670
+ : []),
3671
+ ...splitCodeContextRootEnv(process.env.GIT_DIFF_DIRS),
3672
+ ];
3673
+
3674
+ if (specs.length === 0) {
3675
+ return CODE_CONTEXT_ROOTS.map((root) => ({ ...root }));
3676
+ }
3677
+
3678
+ const seenDirs = new Set<string>();
3679
+ const usedNames = new Map<string, number>();
3680
+ const usedIds = new Set<string>();
3681
+ const roots: CodeContextRootConfig[] = [];
3682
+
3683
+ for (const spec of specs) {
3684
+ const parsed = parseCodeContextRootSpec(spec);
3685
+ if (!parsed) continue;
3686
+ const dir = path.resolve(parsed.dir);
3687
+ if (seenDirs.has(dir)) continue;
3688
+ seenDirs.add(dir);
3689
+ const fallbackName = path.basename(dir) || "Git Diff";
3690
+ const name = uniqueCodeContextRootName(
3691
+ parsed.name || fallbackName,
3692
+ usedNames,
3693
+ );
3694
+ const id = uniqueCodeContextRootId(slugForCodeContextRoot(name), usedIds);
3695
+ roots.push({ id, name, dir });
3696
+ }
3697
+
3698
+ return roots;
3699
+ }
3700
+
3701
+ const GIT_DIFF_ROOTS = resolveGitDiffRoots();
3702
+ const GIT_DIFF_ROOT_BY_ID = new Map(
3703
+ GIT_DIFF_ROOTS.map((root) => [root.id, root]),
3704
+ );
3705
+ const GIT_DIFF_DIR = GIT_DIFF_ROOTS[0]?.dir ?? "";
3706
+
3707
+ function getGitDiffPrimaryRoot(): CodeContextRootConfig | null {
3708
+ return GIT_DIFF_ROOTS[0] ?? null;
3709
+ }
3710
+
3711
+ function getGitDiffRoot(rootId?: string | null): CodeContextRootConfig | null {
3712
+ const normalized = rootId?.trim();
3713
+ if (normalized) return GIT_DIFF_ROOT_BY_ID.get(normalized) ?? null;
3714
+ return getGitDiffPrimaryRoot();
3715
+ }
3716
+
3717
+ function makeGitDiffFileId(rootId: string, relPath: string): string {
3718
+ return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${relPath}`;
3719
+ }
3720
+
3721
+ interface GitDiffFileRef {
3722
+ id: string;
3723
+ root: CodeContextRootConfig;
3724
+ relPath: string;
3725
+ }
3726
+
3727
+ function getGitDiffFileRef(
3728
+ fileIdOrPath: string,
3729
+ fallbackRootId?: string,
3730
+ ): GitDiffFileRef | null {
3731
+ if (GIT_DIFF_ROOTS.length === 0) return null;
3732
+ const raw = fileIdOrPath
3733
+ .replace(/^gitdiff:(patch|before|after):/, "")
3734
+ .replaceAll("\\", "/")
3735
+ .trim();
3736
+ if (!raw || raw.includes("\0")) return null;
3737
+
3738
+ const sepIdx = raw.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
3739
+ const root =
3740
+ sepIdx > 0
3741
+ ? GIT_DIFF_ROOT_BY_ID.get(raw.slice(0, sepIdx))
3742
+ : getGitDiffRoot(fallbackRootId);
3743
+ const relRaw =
3744
+ sepIdx > 0 ? raw.slice(sepIdx + CODE_CONTEXT_ROOT_SEPARATOR.length) : raw;
3745
+ if (!root) return null;
3746
+
3747
+ const normalized = normalizeCodeContextRelPath(relRaw);
3748
+ if (!normalized || !isCodeContextPathAllowed(normalized)) return null;
3749
+
3750
+ return {
3751
+ id: makeGitDiffFileId(root.id, normalized),
3752
+ root,
3753
+ relPath: normalized,
3754
+ };
3755
+ }
3756
+
3757
+ function getGitDiffRootSummaries() {
3758
+ return GIT_DIFF_ROOTS.map((root) => ({
3759
+ id: root.id,
3760
+ name: root.name,
3761
+ path: root.dir,
3762
+ }));
3763
+ }
3174
3764
 
3175
3765
  // Hard caps so a runaway repo can never blow up the response or the model's context.
3176
3766
  const MAX_DIFF_FILES = 500;
@@ -3191,6 +3781,21 @@ interface GitDiffFileEntry {
3191
3781
  binary: boolean;
3192
3782
  }
3193
3783
 
3784
+ interface ScopedGitDiffEntry extends GitDiffFileEntry {
3785
+ id: string;
3786
+ displayPath: string;
3787
+ }
3788
+
3789
+ interface ScopedGitDiffContext {
3790
+ root: CodeContextRootConfig;
3791
+ base: string;
3792
+ head: string;
3793
+ headLabel: string;
3794
+ mode: GitDiffMode;
3795
+ rangeLabel: string;
3796
+ entries: ScopedGitDiffEntry[];
3797
+ }
3798
+
3194
3799
  function runGit(
3195
3800
  args: string[],
3196
3801
  opts: {
@@ -3277,13 +3882,16 @@ function isValidRef(ref: string): boolean {
3277
3882
  return REF_PATTERN.test(ref);
3278
3883
  }
3279
3884
 
3280
- async function resolveRef(ref: string): Promise<string | null> {
3885
+ async function resolveRef(
3886
+ root: CodeContextRootConfig,
3887
+ ref: string,
3888
+ ): Promise<string | null> {
3281
3889
  if (!isValidRef(ref)) return null;
3282
3890
  try {
3283
3891
  const { stdout } = await runGit(
3284
3892
  ["rev-parse", "--verify", `${ref}^{commit}`],
3285
3893
  {
3286
- cwd: GIT_DIFF_DIR,
3894
+ cwd: root.dir,
3287
3895
  },
3288
3896
  );
3289
3897
  return stdout.trim() || null;
@@ -3314,6 +3922,7 @@ function buildDiffRange(
3314
3922
  }
3315
3923
 
3316
3924
  async function getChangedFiles(
3925
+ root: CodeContextRootConfig,
3317
3926
  base: string,
3318
3927
  head: string,
3319
3928
  mode: GitDiffMode,
@@ -3323,10 +3932,10 @@ async function getChangedFiles(
3323
3932
 
3324
3933
  const nameStatus = await runGit(
3325
3934
  [...baseArgs, "--name-status", "-z", ...range],
3326
- { cwd: GIT_DIFF_DIR },
3935
+ { cwd: root.dir },
3327
3936
  );
3328
3937
  const numStat = await runGit([...baseArgs, "--numstat", "-z", ...range], {
3329
- cwd: GIT_DIFF_DIR,
3938
+ cwd: root.dir,
3330
3939
  });
3331
3940
 
3332
3941
  // numstat with -z uses NUL between records AND between additions/deletions/path,
@@ -3384,7 +3993,7 @@ async function getChangedFiles(
3384
3993
  try {
3385
3994
  const untracked = await runGit(
3386
3995
  ["ls-files", "--others", "--exclude-standard", "-z"],
3387
- { cwd: GIT_DIFF_DIR },
3996
+ { cwd: root.dir },
3388
3997
  );
3389
3998
  for (const filePath of untracked.stdout
3390
3999
  .split("\0")
@@ -3402,10 +4011,17 @@ async function getChangedFiles(
3402
4011
  }
3403
4012
  }
3404
4013
 
3405
- return entries.slice(0, MAX_DIFF_FILES);
4014
+ return entries
4015
+ .filter(
4016
+ (entry) =>
4017
+ isCodeContextPathAllowed(entry.path) &&
4018
+ (!entry.oldPath || isCodeContextPathAllowed(entry.oldPath)),
4019
+ )
4020
+ .slice(0, MAX_DIFF_FILES);
3406
4021
  }
3407
4022
 
3408
4023
  async function getDiffPatch(
4024
+ root: CodeContextRootConfig,
3409
4025
  base: string,
3410
4026
  head: string,
3411
4027
  mode: GitDiffMode,
@@ -3422,7 +4038,7 @@ async function getDiffPatch(
3422
4038
  "--",
3423
4039
  filePath,
3424
4040
  ],
3425
- { cwd: GIT_DIFF_DIR, maxBuffer: MAX_DIFF_FILE_BYTES * 4 },
4041
+ { cwd: root.dir, maxBuffer: MAX_DIFF_FILE_BYTES * 4 },
3426
4042
  );
3427
4043
  if (stdout.length > MAX_DIFF_FILE_BYTES) {
3428
4044
  return (
@@ -3433,10 +4049,16 @@ async function getDiffPatch(
3433
4049
  return stdout;
3434
4050
  }
3435
4051
 
3436
- async function getFileAtRef(ref: string, filePath: string): Promise<string> {
4052
+ async function getFileAtRef(
4053
+ root: CodeContextRootConfig,
4054
+ ref: string,
4055
+ filePath: string,
4056
+ ): Promise<string> {
3437
4057
  if (ref === WORKING_TREE_SENTINEL) {
3438
- const resolved = path.resolve(path.join(GIT_DIFF_DIR, filePath));
3439
- if (!resolved.startsWith(path.resolve(GIT_DIFF_DIR))) {
4058
+ const rootDir = path.resolve(root.dir);
4059
+ const resolved = path.resolve(path.join(rootDir, filePath));
4060
+ const relativeToRoot = path.relative(rootDir, resolved);
4061
+ if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
3440
4062
  throw new Error("Access denied");
3441
4063
  }
3442
4064
  const buf = await fs.readFile(resolved);
@@ -3449,7 +4071,7 @@ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
3449
4071
  return buf.toString("utf-8");
3450
4072
  }
3451
4073
  const { stdout } = await runGit(["show", `${ref}:${filePath}`], {
3452
- cwd: GIT_DIFF_DIR,
4074
+ cwd: root.dir,
3453
4075
  maxBuffer: MAX_DIFF_FILE_BYTES * 4,
3454
4076
  });
3455
4077
  if (stdout.length > MAX_DIFF_FILE_BYTES) {
@@ -3461,92 +4083,224 @@ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
3461
4083
  return stdout;
3462
4084
  }
3463
4085
 
3464
- app.get("/api/code-context/git/branches", async (_req, res) => {
3465
- if (!GIT_DIFF_DIR) return res.json({ enabled: false, branches: [] });
4086
+ async function getScopedGitDiffContext(
4087
+ ctx: GitDiffContextPayload,
4088
+ ): Promise<ScopedGitDiffContext | null> {
4089
+ if (GIT_DIFF_ROOTS.length === 0) return null;
4090
+ const selectedRaw = Array.isArray(ctx.selectedFiles)
4091
+ ? ctx.selectedFiles.filter((p): p is string => typeof p === "string")
4092
+ : [];
4093
+ const inferredRootId =
4094
+ ctx.rootId ||
4095
+ selectedRaw
4096
+ .map((p) => getGitDiffFileRef(p)?.root.id)
4097
+ .find((id): id is string => Boolean(id));
4098
+ const root = getGitDiffRoot(inferredRootId || null);
4099
+ if (!root) return null;
4100
+
4101
+ const base = (ctx.baseRef || "").trim();
4102
+ if (!base || !isValidRef(base)) return null;
4103
+ const mode = parseDiffMode(ctx.mode);
4104
+ const headRaw = (ctx.headRef || "").trim();
4105
+ const head = mode === "working-tree" ? WORKING_TREE_SENTINEL : headRaw;
4106
+ if (mode !== "working-tree" && (!head || !isValidRef(head))) return null;
4107
+
4108
+ const selectedRefs = selectedRaw
4109
+ .map((p) => getGitDiffFileRef(p, root.id))
4110
+ .filter((p): p is GitDiffFileRef => Boolean(p))
4111
+ .filter((p) => p.root.id === root.id);
4112
+ if (selectedRefs.length === 0) return null;
4113
+
4114
+ let changedFiles: GitDiffFileEntry[];
3466
4115
  try {
3467
- // Refresh remote-tracking refs first. This is intentionally non-interactive
3468
- // and time-capped so private/offline repos fall back to already-local refs.
3469
- await runGit(["fetch", "--all", "--prune", "--quiet"], {
3470
- cwd: GIT_DIFF_DIR,
3471
- allowFail: true,
3472
- timeoutMs: 8000,
3473
- });
4116
+ changedFiles = await getChangedFiles(root, base, head, mode);
4117
+ } catch {
4118
+ return null;
4119
+ }
4120
+ const byPath = new Map(changedFiles.map((f) => [f.path, f]));
4121
+ const selectedPaths = uniqueStrings(selectedRefs.map((ref) => ref.relPath));
4122
+ const entries = selectedPaths
4123
+ .map((relPath) => byPath.get(relPath))
4124
+ .filter((entry): entry is GitDiffFileEntry => Boolean(entry))
4125
+ .map((entry) => ({
4126
+ ...entry,
4127
+ id: makeGitDiffFileId(root.id, entry.path),
4128
+ displayPath: `${root.name}/${entry.path}`,
4129
+ }));
4130
+ if (entries.length === 0) return null;
3474
4131
 
3475
- const [head, branches, tags, remotes] = await Promise.all([
3476
- runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: GIT_DIFF_DIR }),
3477
- runGit(
3478
- [
3479
- "for-each-ref",
3480
- "--sort=-committerdate",
3481
- `--count=${MAX_BRANCHES}`,
3482
- "--format=%(refname:short)",
3483
- "refs/heads/",
3484
- "refs/remotes/",
3485
- ],
3486
- { cwd: GIT_DIFF_DIR },
3487
- ),
3488
- runGit(
3489
- [
3490
- "for-each-ref",
3491
- "--sort=-creatordate",
3492
- "--count=50",
3493
- "--format=%(refname:short)",
3494
- "refs/tags/",
3495
- ],
3496
- { cwd: GIT_DIFF_DIR, allowFail: true },
3497
- ),
3498
- runGit(["remote"], { cwd: GIT_DIFF_DIR, allowFail: true }),
3499
- ]);
3500
- const remoteNames = remotes.stdout
3501
- .split("\n")
3502
- .map((s) => s.trim())
3503
- .filter(Boolean);
3504
- const lsRemoteResults = await Promise.all(
3505
- remoteNames.slice(0, 5).map((remote) =>
3506
- runGit(["ls-remote", "--heads", remote], {
3507
- cwd: GIT_DIFF_DIR,
3508
- allowFail: true,
3509
- timeoutMs: 5000,
3510
- maxBuffer: 1024 * 1024,
3511
- }).then((result) =>
3512
- result.stdout
3513
- .split("\n")
3514
- .map((line) => line.trim().split("\t")[1] || "")
3515
- .filter((ref) => ref.startsWith("refs/heads/"))
3516
- .map((ref) => `${remote}/${ref.replace("refs/heads/", "")}`),
3517
- ),
4132
+ const headLabel = mode === "working-tree" ? "working tree" : head;
4133
+ const rangeLabel =
4134
+ mode === "working-tree"
4135
+ ? `${base} → working tree`
4136
+ : `${base} ${mode === "two-dot" ? ".." : "..."} ${headLabel}`;
4137
+
4138
+ return {
4139
+ root,
4140
+ base,
4141
+ head,
4142
+ headLabel,
4143
+ mode,
4144
+ rangeLabel,
4145
+ entries,
4146
+ };
4147
+ }
4148
+
4149
+ function findScopedGitDiffEntry(
4150
+ scoped: ScopedGitDiffContext,
4151
+ fileIdOrPath: string,
4152
+ ): ScopedGitDiffEntry | null {
4153
+ const ref = getGitDiffFileRef(fileIdOrPath, scoped.root.id);
4154
+ if (!ref || ref.root.id !== scoped.root.id) return null;
4155
+ return (
4156
+ scoped.entries.find(
4157
+ (entry) => entry.id === ref.id || entry.path === ref.relPath,
4158
+ ) ?? null
4159
+ );
4160
+ }
4161
+
4162
+ async function readGitDiffView(
4163
+ scoped: ScopedGitDiffContext,
4164
+ entry: ScopedGitDiffEntry,
4165
+ view: "patch" | "before" | "after",
4166
+ ): Promise<string> {
4167
+ if (view === "before") {
4168
+ if (entry.status === "A" || entry.status === "?") return "";
4169
+ return getFileAtRef(scoped.root, scoped.base, entry.oldPath ?? entry.path);
4170
+ }
4171
+ if (view === "after") {
4172
+ if (entry.status === "D") return "";
4173
+ const ref =
4174
+ scoped.mode === "working-tree" ? WORKING_TREE_SENTINEL : scoped.head;
4175
+ return getFileAtRef(scoped.root, ref, entry.path);
4176
+ }
4177
+ return getDiffPatch(
4178
+ scoped.root,
4179
+ scoped.base,
4180
+ scoped.head,
4181
+ scoped.mode,
4182
+ entry.path,
4183
+ );
4184
+ }
4185
+
4186
+ async function loadGitBranchesForRoot(root: CodeContextRootConfig) {
4187
+ // Refresh remote-tracking refs first. This is intentionally non-interactive
4188
+ // and time-capped so private/offline repos fall back to already-local refs.
4189
+ await runGit(["fetch", "--all", "--prune", "--quiet"], {
4190
+ cwd: root.dir,
4191
+ allowFail: true,
4192
+ timeoutMs: 8000,
4193
+ });
4194
+
4195
+ const [head, branches, tags, remotes] = await Promise.all([
4196
+ runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root.dir }),
4197
+ runGit(
4198
+ [
4199
+ "for-each-ref",
4200
+ "--sort=-committerdate",
4201
+ `--count=${MAX_BRANCHES}`,
4202
+ "--format=%(refname:short)",
4203
+ "refs/heads/",
4204
+ "refs/remotes/",
4205
+ ],
4206
+ { cwd: root.dir },
4207
+ ),
4208
+ runGit(
4209
+ [
4210
+ "for-each-ref",
4211
+ "--sort=-creatordate",
4212
+ "--count=50",
4213
+ "--format=%(refname:short)",
4214
+ "refs/tags/",
4215
+ ],
4216
+ { cwd: root.dir, allowFail: true },
4217
+ ),
4218
+ runGit(["remote"], { cwd: root.dir, allowFail: true }),
4219
+ ]);
4220
+ const remoteNames = remotes.stdout
4221
+ .split("\n")
4222
+ .map((s) => s.trim())
4223
+ .filter(Boolean);
4224
+ const lsRemoteResults = await Promise.all(
4225
+ remoteNames.slice(0, 5).map((remote) =>
4226
+ runGit(["ls-remote", "--heads", remote], {
4227
+ cwd: root.dir,
4228
+ allowFail: true,
4229
+ timeoutMs: 5000,
4230
+ maxBuffer: 1024 * 1024,
4231
+ }).then((result) =>
4232
+ result.stdout
4233
+ .split("\n")
4234
+ .map((line) => line.trim().split("\t")[1] || "")
4235
+ .filter((ref) => ref.startsWith("refs/heads/"))
4236
+ .map((ref) => `${remote}/${ref.replace("refs/heads/", "")}`),
3518
4237
  ),
3519
- );
3520
- const branchList = uniqueStrings([
3521
- ...branches.stdout
3522
- .split("\n")
3523
- .map((s) => s.trim())
3524
- .filter(Boolean),
3525
- ...lsRemoteResults.flat(),
3526
- ]).filter((b) => !b.endsWith("/HEAD"));
3527
- const tagList = tags.stdout
4238
+ ),
4239
+ );
4240
+ const branchList = uniqueStrings([
4241
+ ...branches.stdout
3528
4242
  .split("\n")
3529
4243
  .map((s) => s.trim())
3530
- .filter(Boolean);
3531
- const currentHead = head.stdout.trim();
4244
+ .filter(Boolean),
4245
+ ...lsRemoteResults.flat(),
4246
+ ]).filter((b) => !b.endsWith("/HEAD"));
4247
+ const tagList = tags.stdout
4248
+ .split("\n")
4249
+ .map((s) => s.trim())
4250
+ .filter(Boolean);
4251
+ const currentHead = head.stdout.trim();
4252
+
4253
+ return {
4254
+ enabled: true,
4255
+ rootId: root.id,
4256
+ rootName: root.name,
4257
+ head: currentHead,
4258
+ defaultBranch: chooseDefaultBaseBranch(currentHead, branchList),
4259
+ branches: branchList,
4260
+ tags: tagList,
4261
+ };
4262
+ }
4263
+
4264
+ app.get("/api/code-context/git/branches", async (req, res) => {
4265
+ const roots = getGitDiffRootSummaries();
4266
+ if (GIT_DIFF_ROOTS.length === 0) {
4267
+ return res.json({ enabled: false, branches: [], roots });
4268
+ }
4269
+ const root = getGitDiffRoot(
4270
+ String(req.query.root || req.query.rootId || "").trim(),
4271
+ );
4272
+ if (!root) {
4273
+ return res.status(404).json({
4274
+ enabled: false,
4275
+ branches: [],
4276
+ roots,
4277
+ error: "Git diff root not found",
4278
+ });
4279
+ }
4280
+ try {
3532
4281
  res.json({
3533
- enabled: true,
3534
- head: currentHead,
3535
- defaultBranch: chooseDefaultBaseBranch(currentHead, branchList),
3536
- branches: branchList,
3537
- tags: tagList,
4282
+ ...(await loadGitBranchesForRoot(root)),
4283
+ roots,
3538
4284
  });
3539
4285
  } catch (err: any) {
3540
4286
  res.status(500).json({
3541
4287
  enabled: false,
4288
+ rootId: root.id,
4289
+ rootName: root.name,
3542
4290
  branches: [],
4291
+ roots,
3543
4292
  error: err?.message || "git not available",
3544
4293
  });
3545
4294
  }
3546
4295
  });
3547
4296
 
3548
4297
  app.get("/api/code-context/git/diff-tree", async (req, res) => {
3549
- if (!GIT_DIFF_DIR) return res.status(400).json({ error: "No git directory" });
4298
+ if (GIT_DIFF_ROOTS.length === 0)
4299
+ return res.status(400).json({ error: "No git diff roots" });
4300
+ const root = getGitDiffRoot(
4301
+ String(req.query.root || req.query.rootId || "").trim(),
4302
+ );
4303
+ if (!root) return res.status(404).json({ error: "Git diff root not found" });
3550
4304
 
3551
4305
  const base = String(req.query.base || "").trim();
3552
4306
  const headRaw = String(req.query.head || "").trim();
@@ -3555,7 +4309,7 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
3555
4309
  if (!isValidRef(base))
3556
4310
  return res.status(400).json({ error: "Invalid base ref" });
3557
4311
 
3558
- const baseSha = await resolveRef(base);
4312
+ const baseSha = await resolveRef(root, base);
3559
4313
  if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
3560
4314
 
3561
4315
  let head = headRaw;
@@ -3565,13 +4319,15 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
3565
4319
  } else {
3566
4320
  if (!isValidRef(head))
3567
4321
  return res.status(400).json({ error: "Invalid head ref" });
3568
- headSha = await resolveRef(head);
4322
+ headSha = await resolveRef(root, head);
3569
4323
  if (!headSha) return res.status(404).json({ error: "Head ref not found" });
3570
4324
  }
3571
4325
 
3572
4326
  try {
3573
- const files = await getChangedFiles(base, head, mode);
4327
+ const files = await getChangedFiles(root, base, head, mode);
3574
4328
  res.json({
4329
+ rootId: root.id,
4330
+ rootName: root.name,
3575
4331
  base,
3576
4332
  baseSha,
3577
4333
  head: mode === "working-tree" ? WORKING_TREE_SENTINEL : head,
@@ -3586,20 +4342,27 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
3586
4342
  });
3587
4343
 
3588
4344
  app.get("/api/code-context/git/diff-file", async (req, res) => {
3589
- if (!GIT_DIFF_DIR) return res.status(400).json({ error: "No git directory" });
4345
+ if (GIT_DIFF_ROOTS.length === 0)
4346
+ return res.status(400).json({ error: "No git diff roots" });
3590
4347
 
3591
4348
  const base = String(req.query.base || "").trim();
3592
4349
  const headRaw = String(req.query.head || "").trim();
3593
4350
  const mode = parseDiffMode(req.query.mode);
3594
4351
  const filePath = String(req.query.path || "").trim();
3595
4352
  const view = (req.query.view as string) || "patch";
4353
+ const fileRef = getGitDiffFileRef(
4354
+ filePath,
4355
+ String(req.query.root || req.query.rootId || "").trim(),
4356
+ );
3596
4357
 
3597
4358
  if (!isValidRef(base))
3598
4359
  return res.status(400).json({ error: "Invalid base ref" });
3599
- if (!filePath || filePath.includes("\0"))
3600
- return res.status(400).json({ error: "Invalid path" });
4360
+ if (!fileRef) return res.status(400).json({ error: "Invalid path" });
3601
4361
 
3602
- const baseSha = await resolveRef(base);
4362
+ const { root } = fileRef;
4363
+ const relPath = fileRef.relPath;
4364
+
4365
+ const baseSha = await resolveRef(root, base);
3603
4366
  if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
3604
4367
 
3605
4368
  let head = headRaw;
@@ -3608,15 +4371,15 @@ app.get("/api/code-context/git/diff-file", async (req, res) => {
3608
4371
  } else {
3609
4372
  if (!isValidRef(head))
3610
4373
  return res.status(400).json({ error: "Invalid head ref" });
3611
- const headSha = await resolveRef(head);
4374
+ const headSha = await resolveRef(root, head);
3612
4375
  if (!headSha) return res.status(404).json({ error: "Head ref not found" });
3613
4376
  }
3614
4377
 
3615
4378
  // Verify the path is actually part of the diff so callers cannot read arbitrary repo files.
3616
4379
  let entry: GitDiffFileEntry | undefined;
3617
4380
  try {
3618
- const files = await getChangedFiles(base, head, mode);
3619
- entry = files.find((f) => f.path === filePath || f.oldPath === filePath);
4381
+ const files = await getChangedFiles(root, base, head, mode);
4382
+ entry = files.find((f) => f.path === relPath || f.oldPath === relPath);
3620
4383
  } catch (err: any) {
3621
4384
  return res.status(500).json({ error: err?.message || "git diff failed" });
3622
4385
  }
@@ -3625,23 +4388,53 @@ app.get("/api/code-context/git/diff-file", async (req, res) => {
3625
4388
  try {
3626
4389
  if (view === "before") {
3627
4390
  if (entry.status === "A" || entry.status === "?") {
3628
- return res.json({ path: filePath, view, content: "" });
4391
+ return res.json({
4392
+ path: fileRef.id,
4393
+ rootId: root.id,
4394
+ rootName: root.name,
4395
+ view,
4396
+ content: "",
4397
+ });
3629
4398
  }
3630
- const refPath = entry.oldPath ?? filePath;
3631
- const content = await getFileAtRef(base, refPath);
3632
- return res.json({ path: filePath, view, content });
4399
+ const refPath = entry.oldPath ?? relPath;
4400
+ const content = await getFileAtRef(root, base, refPath);
4401
+ return res.json({
4402
+ path: fileRef.id,
4403
+ rootId: root.id,
4404
+ rootName: root.name,
4405
+ view,
4406
+ content,
4407
+ });
3633
4408
  }
3634
4409
  if (view === "after") {
3635
4410
  if (entry.status === "D") {
3636
- return res.json({ path: filePath, view, content: "" });
4411
+ return res.json({
4412
+ path: fileRef.id,
4413
+ rootId: root.id,
4414
+ rootName: root.name,
4415
+ view,
4416
+ content: "",
4417
+ });
3637
4418
  }
3638
4419
  const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
3639
- const content = await getFileAtRef(ref, filePath);
3640
- return res.json({ path: filePath, view, content });
4420
+ const content = await getFileAtRef(root, ref, relPath);
4421
+ return res.json({
4422
+ path: fileRef.id,
4423
+ rootId: root.id,
4424
+ rootName: root.name,
4425
+ view,
4426
+ content,
4427
+ });
3641
4428
  }
3642
4429
  // default: patch
3643
- const patch = await getDiffPatch(base, head, mode, filePath);
3644
- return res.json({ path: filePath, view: "patch", content: patch });
4430
+ const patch = await getDiffPatch(root, base, head, mode, relPath);
4431
+ return res.json({
4432
+ path: fileRef.id,
4433
+ rootId: root.id,
4434
+ rootName: root.name,
4435
+ view: "patch",
4436
+ content: patch,
4437
+ });
3645
4438
  } catch (err: any) {
3646
4439
  res.status(500).json({ error: err?.message || "git read failed" });
3647
4440
  }
@@ -3728,14 +4521,15 @@ app.post("/api/code-line-ask", async (req, res) => {
3728
4521
  const scopedCodeContextFiles =
3729
4522
  await getScopedCodeContextFiles(codeContextFiles);
3730
4523
 
3731
- if (scopedCodeContextFiles.length && CODE_CONTEXT_DIR) {
4524
+ if (scopedCodeContextFiles.length && CODE_CONTEXT_ROOTS.length > 0) {
3732
4525
  for (const fp of scopedCodeContextFiles) {
3733
- const resolved = resolveCodeContextFilePath(fp);
3734
- if (!resolved) continue;
4526
+ const ref = getCodeContextFileRef(fp);
4527
+ if (!ref) continue;
3735
4528
  fileRegistry.set(
3736
4529
  `code:${fp}`,
3737
- makeCodeReferenceFileEntry(`[code] ${fp}`, () =>
3738
- fs.readFile(resolved, "utf-8"),
4530
+ makeCodeReferenceFileEntry(
4531
+ `[code:${ref.root.name}] ${ref.relPath}`,
4532
+ () => fs.readFile(ref.absolutePath, "utf-8"),
3739
4533
  ),
3740
4534
  );
3741
4535
  }
@@ -3776,7 +4570,7 @@ app.post("/api/code-line-ask", async (req, res) => {
3776
4570
  }
3777
4571
 
3778
4572
  if (codeFilePaths.length > 0) {
3779
- system += `\n--- Code Context Exploration Tools ---\nYou also have read-only tools to list, search, read line ranges, trace textual references, inspect CI/CD config, and run whitelisted read-only inspection commands. These tools are restricted to selected, non-ignored files under CODE_CONTEXT_DIR. If something is missing, ask the user to select more code context or adjust ignore settings.\n\n--- Linking Code Files in Your Response ---\nWhen you mention a [code] file, format it as a clickable link:\n [DisplayText](coderef://relative/path/to/file)\nOnly use coderef:// for [code] files.`;
4573
+ system += `\n--- Code Context Exploration Tools ---\nYou also have read-only tools to list, search, read line ranges, trace textual references, inspect CI/CD config, and run whitelisted read-only inspection commands. These tools are restricted to selected, non-ignored files under configured code-context roots. File IDs are root-qualified like "root::relative/path". If something is missing, ask the user to select more code context or adjust ignore settings.\n\n--- Linking Code Files in Your Response ---\nWhen you mention a [code] file, format it as a clickable link using the exact code file id without the "code:" prefix:\n [DisplayText](coderef://root::relative/path/to/file)\nOnly use coderef:// for [code] files.`;
3780
4574
  }
3781
4575
  }
3782
4576
 
@@ -3816,6 +4610,7 @@ app.post("/api/code-line-ask", async (req, res) => {
3816
4610
  ? { readFile: createReadFileTool(fileRegistry) }
3817
4611
  : {}),
3818
4612
  ...createCodeContextTools(scopedCodeContextFiles),
4613
+ ...createGitDiffTools(gitDiffContext || {}),
3819
4614
  }
3820
4615
  : undefined,
3821
4616
  stopWhen: stepCountIs(4),