create-interview-cockpit 0.20.0 → 0.22.0

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.
@@ -162,6 +162,103 @@ function makeCodeReferenceFileEntry(
162
162
  return { label, originalName: label, reader };
163
163
  }
164
164
 
165
+ interface GitDiffContextPayload {
166
+ baseRef?: string;
167
+ headRef?: string;
168
+ mode?: string;
169
+ selectedFiles?: string[];
170
+ }
171
+
172
+ // Register lazily-fetched git diff entries on the model's file registry and
173
+ // append a short manifest line to the system prompt. Full patches / blobs are
174
+ // only pulled when the model calls readFile with a gitdiff:* id.
175
+ async function registerGitDiffContext(
176
+ fileRegistry: Map<string, ReferenceFileEntry>,
177
+ ctx: GitDiffContextPayload,
178
+ ): Promise<string> {
179
+ if (!GIT_DIFF_DIR) return "";
180
+ const base = (ctx.baseRef || "").trim();
181
+ if (!base || !isValidRef(base)) return "";
182
+ const mode = parseDiffMode(ctx.mode);
183
+ const headRaw = (ctx.headRef || "").trim();
184
+ const head = mode === "working-tree" ? WORKING_TREE_SENTINEL : headRaw;
185
+ if (mode !== "working-tree" && (!head || !isValidRef(head))) return "";
186
+
187
+ const selected = Array.isArray(ctx.selectedFiles)
188
+ ? ctx.selectedFiles.filter(
189
+ (p) => typeof p === "string" && p && !p.includes("\0"),
190
+ )
191
+ : [];
192
+ if (selected.length === 0) return "";
193
+
194
+ let changedFiles: GitDiffFileEntry[];
195
+ try {
196
+ changedFiles = await getChangedFiles(base, head, mode);
197
+ } catch {
198
+ return "";
199
+ }
200
+ const byPath = new Map(changedFiles.map((f) => [f.path, f]));
201
+ const validSelected = selected.filter((p) => byPath.has(p));
202
+ if (validSelected.length === 0) return "";
203
+
204
+ const headLabel = mode === "working-tree" ? "working tree" : head;
205
+ const rangeLabel =
206
+ mode === "working-tree"
207
+ ? `${base} → working tree`
208
+ : `${base} ${mode === "two-dot" ? ".." : "..."} ${headLabel}`;
209
+
210
+ let manifest = `\n\n--- Available Git Diff Context (${rangeLabel}) ---
211
+ You can pull lazy git diff context with the readFile tool. Three views are available per file:
212
+ • gitdiff:patch:<path> — unified diff hunks (recommended starting point)
213
+ • gitdiff:before:<path> — full file contents at the base ref
214
+ • gitdiff:after:<path> — full file contents at the head ref (or working tree)
215
+ Only read what you need; each file can be expensive.
216
+
217
+ `;
218
+ for (const filePath of validSelected) {
219
+ const entry = byPath.get(filePath)!;
220
+ const sign = entry.binary
221
+ ? "binary"
222
+ : `+${entry.additions}/-${entry.deletions}`;
223
+ const renamed = entry.oldPath ? ` (renamed from ${entry.oldPath})` : "";
224
+ manifest += `• [${entry.status}] ${entry.path} ${sign}${renamed}\n`;
225
+ manifest += ` patch id: "gitdiff:patch:${entry.path}"\n`;
226
+ if (entry.status !== "A" && entry.status !== "?") {
227
+ manifest += ` before id: "gitdiff:before:${entry.path}"\n`;
228
+ }
229
+ if (entry.status !== "D") {
230
+ manifest += ` after id: "gitdiff:after:${entry.path}"\n`;
231
+ }
232
+
233
+ const safePath = filePath; // already validated to be in diff
234
+ fileRegistry.set(
235
+ `gitdiff:patch:${safePath}`,
236
+ makeCodeReferenceFileEntry(`[git diff] ${safePath}`, () =>
237
+ getDiffPatch(base, head, mode, safePath),
238
+ ),
239
+ );
240
+ if (entry.status !== "A" && entry.status !== "?") {
241
+ const refPath = entry.oldPath ?? safePath;
242
+ fileRegistry.set(
243
+ `gitdiff:before:${safePath}`,
244
+ makeCodeReferenceFileEntry(`[git before ${base}] ${safePath}`, () =>
245
+ getFileAtRef(base, refPath),
246
+ ),
247
+ );
248
+ }
249
+ if (entry.status !== "D") {
250
+ const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
251
+ fileRegistry.set(
252
+ `gitdiff:after:${safePath}`,
253
+ makeCodeReferenceFileEntry(`[git after ${headLabel}] ${safePath}`, () =>
254
+ getFileAtRef(ref, safePath),
255
+ ),
256
+ );
257
+ }
258
+ }
259
+ return manifest;
260
+ }
261
+
165
262
  function createReadFileTool(fileRegistry: Map<string, ReferenceFileEntry>) {
166
263
  return tool({
167
264
  description:
@@ -895,6 +992,26 @@ app.patch("/api/questions/:id", async (req, res) => {
895
992
  if (!q) return res.status(404).json({ error: "Not found" });
896
993
  if (req.body.codeContextFiles !== undefined)
897
994
  q.codeContextFiles = req.body.codeContextFiles;
995
+ if (req.body.gitDiffContext !== undefined) {
996
+ const gdc = req.body.gitDiffContext;
997
+ if (gdc === null) {
998
+ delete q.gitDiffContext;
999
+ } else if (gdc && typeof gdc === "object") {
1000
+ q.gitDiffContext = {
1001
+ baseRef: String(gdc.baseRef || ""),
1002
+ headRef: String(gdc.headRef || ""),
1003
+ mode:
1004
+ gdc.mode === "two-dot" ||
1005
+ gdc.mode === "three-dot" ||
1006
+ gdc.mode === "working-tree"
1007
+ ? gdc.mode
1008
+ : "three-dot",
1009
+ selectedFiles: Array.isArray(gdc.selectedFiles)
1010
+ ? gdc.selectedFiles.filter((p: unknown) => typeof p === "string")
1011
+ : [],
1012
+ };
1013
+ }
1014
+ }
898
1015
  if (req.body.systemContext !== undefined)
899
1016
  q.systemContext = req.body.systemContext;
900
1017
  if (req.body.title !== undefined) q.title = req.body.title;
@@ -1759,6 +1876,7 @@ app.post("/api/chat", async (req, res) => {
1759
1876
  systemContext,
1760
1877
  responseLength,
1761
1878
  linkedConversationIds,
1879
+ gitDiffContext,
1762
1880
  } = req.body;
1763
1881
 
1764
1882
  const aiSettings = await storage.getAiSettings();
@@ -1881,6 +1999,12 @@ app.post("/api/chat", async (req, res) => {
1881
1999
  }
1882
2000
  }
1883
2001
 
2002
+ // Git diff context (lazy patches / before / after blobs)
2003
+ const gitDiffManifest = await registerGitDiffContext(
2004
+ fileRegistry,
2005
+ gitDiffContext || {},
2006
+ );
2007
+
1884
2008
  // Tell the model what files are available
1885
2009
  if (fileRegistry.size > 0) {
1886
2010
  // collect just the code-context file paths for linking instructions
@@ -1895,9 +2019,14 @@ For image files, readFile returns visual image data so you can inspect what is v
1895
2019
 
1896
2020
  `;
1897
2021
  for (const [id, { label }] of fileRegistry) {
2022
+ if (id.startsWith("gitdiff:")) continue;
1898
2023
  system += `• ${label} (id: "${id}")\n`;
1899
2024
  }
1900
2025
 
2026
+ if (gitDiffManifest) {
2027
+ system += gitDiffManifest;
2028
+ }
2029
+
1901
2030
  if (codeFilePaths.length > 0) {
1902
2031
  system += `
1903
2032
  --- Linking Code Files in Your Response ---
@@ -2281,6 +2410,490 @@ app.get("/api/code-context/file", async (req, res) => {
2281
2410
  }
2282
2411
  });
2283
2412
 
2413
+ // ─── Git Diff Context ────────────────────────────────────
2414
+ //
2415
+ // Lets the user pick a base/head ref (or working tree) and a list of changed
2416
+ // files. The LLM only sees a short manifest in the system prompt; full patches
2417
+ // or before/after blobs are pulled lazily via the readFile tool using ids of
2418
+ // the form gitdiff:patch:<path> / gitdiff:before:<path> / gitdiff:after:<path>.
2419
+
2420
+ const GIT_DIFF_DIR = process.env.GIT_DIFF_DIR || CODE_CONTEXT_DIR;
2421
+
2422
+ // Hard caps so a runaway repo can never blow up the response or the model's context.
2423
+ const MAX_DIFF_FILES = 500;
2424
+ const MAX_DIFF_FILE_BYTES = 256 * 1024; // 256 KB per patch / blob
2425
+ const MAX_BRANCHES = 200;
2426
+ const REF_PATTERN = /^[A-Za-z0-9._\/\-]{1,200}$/;
2427
+ const WORKING_TREE_SENTINEL = "WORKING_TREE";
2428
+
2429
+ type GitDiffMode = "two-dot" | "three-dot" | "working-tree";
2430
+ type GitDiffStatus = "A" | "M" | "D" | "R" | "C" | "T" | "U" | "?";
2431
+
2432
+ interface GitDiffFileEntry {
2433
+ path: string;
2434
+ oldPath?: string;
2435
+ status: GitDiffStatus;
2436
+ additions: number;
2437
+ deletions: number;
2438
+ binary: boolean;
2439
+ }
2440
+
2441
+ function runGit(
2442
+ args: string[],
2443
+ opts: {
2444
+ cwd: string;
2445
+ maxBuffer?: number;
2446
+ allowFail?: boolean;
2447
+ timeoutMs?: number;
2448
+ } = {
2449
+ cwd: GIT_DIFF_DIR,
2450
+ },
2451
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
2452
+ return new Promise((resolve, reject) => {
2453
+ const child = spawn("git", args, {
2454
+ cwd: opts.cwd,
2455
+ env: {
2456
+ ...process.env,
2457
+ GIT_OPTIONAL_LOCKS: "0",
2458
+ GIT_TERMINAL_PROMPT: "0",
2459
+ GCM_INTERACTIVE: "Never",
2460
+ },
2461
+ shell: false,
2462
+ });
2463
+ const maxBuffer = opts.maxBuffer ?? 16 * 1024 * 1024;
2464
+ const outChunks: Buffer[] = [];
2465
+ const errChunks: Buffer[] = [];
2466
+ let outLen = 0;
2467
+ let killed = false;
2468
+ const timer = opts.timeoutMs
2469
+ ? setTimeout(() => {
2470
+ killed = true;
2471
+ child.kill("SIGKILL");
2472
+ }, opts.timeoutMs)
2473
+ : null;
2474
+ child.stdout.on("data", (b: Buffer) => {
2475
+ outLen += b.length;
2476
+ if (outLen > maxBuffer) {
2477
+ killed = true;
2478
+ child.kill("SIGKILL");
2479
+ return;
2480
+ }
2481
+ outChunks.push(b);
2482
+ });
2483
+ child.stderr.on("data", (b: Buffer) => errChunks.push(b));
2484
+ child.on("error", (err) => reject(err));
2485
+ child.on("close", (code) => {
2486
+ if (timer) clearTimeout(timer);
2487
+ const stdout = Buffer.concat(outChunks).toString("utf-8");
2488
+ const stderr = Buffer.concat(errChunks).toString("utf-8");
2489
+ if (killed)
2490
+ return reject(
2491
+ new Error("git command timed out or exceeded buffer cap"),
2492
+ );
2493
+ if (code !== 0 && !opts.allowFail) {
2494
+ return reject(new Error(stderr.trim() || `git exited with ${code}`));
2495
+ }
2496
+ resolve({ stdout, stderr, code: code ?? 0 });
2497
+ });
2498
+ });
2499
+ }
2500
+
2501
+ function uniqueStrings(values: string[]): string[] {
2502
+ return Array.from(new Set(values.filter(Boolean)));
2503
+ }
2504
+
2505
+ function chooseDefaultBaseBranch(head: string, branches: string[]): string {
2506
+ const candidates = [
2507
+ "origin/main",
2508
+ "main",
2509
+ "origin/master",
2510
+ "master",
2511
+ "origin/develop",
2512
+ "develop",
2513
+ "origin/dev",
2514
+ "dev",
2515
+ ];
2516
+ return (
2517
+ candidates.find((b) => branches.includes(b) && b !== head) ??
2518
+ branches.find((b) => b !== head && !b.endsWith("/HEAD")) ??
2519
+ head
2520
+ );
2521
+ }
2522
+
2523
+ function isValidRef(ref: string): boolean {
2524
+ return REF_PATTERN.test(ref);
2525
+ }
2526
+
2527
+ async function resolveRef(ref: string): Promise<string | null> {
2528
+ if (!isValidRef(ref)) return null;
2529
+ try {
2530
+ const { stdout } = await runGit(
2531
+ ["rev-parse", "--verify", `${ref}^{commit}`],
2532
+ {
2533
+ cwd: GIT_DIFF_DIR,
2534
+ },
2535
+ );
2536
+ return stdout.trim() || null;
2537
+ } catch {
2538
+ return null;
2539
+ }
2540
+ }
2541
+
2542
+ function parseDiffMode(value: unknown): GitDiffMode {
2543
+ if (
2544
+ value === "three-dot" ||
2545
+ value === "two-dot" ||
2546
+ value === "working-tree"
2547
+ ) {
2548
+ return value;
2549
+ }
2550
+ return "three-dot";
2551
+ }
2552
+
2553
+ function buildDiffRange(
2554
+ base: string,
2555
+ head: string,
2556
+ mode: GitDiffMode,
2557
+ ): string[] {
2558
+ if (mode === "working-tree") return [base];
2559
+ if (mode === "two-dot") return [`${base}..${head}`];
2560
+ return [`${base}...${head}`];
2561
+ }
2562
+
2563
+ async function getChangedFiles(
2564
+ base: string,
2565
+ head: string,
2566
+ mode: GitDiffMode,
2567
+ ): Promise<GitDiffFileEntry[]> {
2568
+ const range = buildDiffRange(base, head, mode);
2569
+ const baseArgs = ["diff", "--find-renames", "--no-color"];
2570
+
2571
+ const nameStatus = await runGit(
2572
+ [...baseArgs, "--name-status", "-z", ...range],
2573
+ { cwd: GIT_DIFF_DIR },
2574
+ );
2575
+ const numStat = await runGit([...baseArgs, "--numstat", "-z", ...range], {
2576
+ cwd: GIT_DIFF_DIR,
2577
+ });
2578
+
2579
+ // numstat with -z uses NUL between records AND between additions/deletions/path,
2580
+ // and for renames emits an extra leading NUL before the two paths. Simpler: parse with newlines fallback.
2581
+ const numMap = new Map<
2582
+ string,
2583
+ { add: number; del: number; binary: boolean }
2584
+ >();
2585
+ for (const line of numStat.stdout.split(/\0|\n/).filter(Boolean)) {
2586
+ const parts = line.split("\t");
2587
+ if (parts.length < 3) continue;
2588
+ const [a, d, ...rest] = parts;
2589
+ const filePath = rest.join("\t");
2590
+ const additions = a === "-" ? 0 : Number(a) || 0;
2591
+ const deletions = d === "-" ? 0 : Number(d) || 0;
2592
+ const binary = a === "-" && d === "-";
2593
+ numMap.set(filePath, { add: additions, del: deletions, binary });
2594
+ }
2595
+
2596
+ // Parse --name-status -z. Renames take 3 tokens: "R<score>", old, new. Others take 2.
2597
+ const tokens = nameStatus.stdout.split("\0").filter((t) => t.length > 0);
2598
+ const entries: GitDiffFileEntry[] = [];
2599
+ for (let i = 0; i < tokens.length; i++) {
2600
+ const statusToken = tokens[i];
2601
+ const letter = statusToken[0] as GitDiffStatus;
2602
+ if (letter === "R" || letter === "C") {
2603
+ const oldPath = tokens[++i];
2604
+ const newPath = tokens[++i];
2605
+ if (!newPath) continue;
2606
+ const num = numMap.get(newPath) ?? numMap.get(`${oldPath} => ${newPath}`);
2607
+ entries.push({
2608
+ path: newPath,
2609
+ oldPath,
2610
+ status: letter,
2611
+ additions: num?.add ?? 0,
2612
+ deletions: num?.del ?? 0,
2613
+ binary: num?.binary ?? false,
2614
+ });
2615
+ } else {
2616
+ const filePath = tokens[++i];
2617
+ if (!filePath) continue;
2618
+ const num = numMap.get(filePath);
2619
+ entries.push({
2620
+ path: filePath,
2621
+ status: letter,
2622
+ additions: num?.add ?? 0,
2623
+ deletions: num?.del ?? 0,
2624
+ binary: num?.binary ?? false,
2625
+ });
2626
+ }
2627
+ }
2628
+
2629
+ // Working tree mode: also surface untracked files so the user can include them.
2630
+ if (mode === "working-tree") {
2631
+ try {
2632
+ const untracked = await runGit(
2633
+ ["ls-files", "--others", "--exclude-standard", "-z"],
2634
+ { cwd: GIT_DIFF_DIR },
2635
+ );
2636
+ for (const filePath of untracked.stdout
2637
+ .split("\0")
2638
+ .filter((t) => t.length > 0)) {
2639
+ entries.push({
2640
+ path: filePath,
2641
+ status: "?",
2642
+ additions: 0,
2643
+ deletions: 0,
2644
+ binary: false,
2645
+ });
2646
+ }
2647
+ } catch {
2648
+ // ignore
2649
+ }
2650
+ }
2651
+
2652
+ return entries.slice(0, MAX_DIFF_FILES);
2653
+ }
2654
+
2655
+ async function getDiffPatch(
2656
+ base: string,
2657
+ head: string,
2658
+ mode: GitDiffMode,
2659
+ filePath: string,
2660
+ ): Promise<string> {
2661
+ const range = buildDiffRange(base, head, mode);
2662
+ const { stdout } = await runGit(
2663
+ [
2664
+ "diff",
2665
+ "--no-color",
2666
+ "--find-renames",
2667
+ "--unified=3",
2668
+ ...range,
2669
+ "--",
2670
+ filePath,
2671
+ ],
2672
+ { cwd: GIT_DIFF_DIR, maxBuffer: MAX_DIFF_FILE_BYTES * 4 },
2673
+ );
2674
+ if (stdout.length > MAX_DIFF_FILE_BYTES) {
2675
+ return (
2676
+ stdout.slice(0, MAX_DIFF_FILE_BYTES) +
2677
+ `\n\n[diff truncated after ${MAX_DIFF_FILE_BYTES} bytes]`
2678
+ );
2679
+ }
2680
+ return stdout;
2681
+ }
2682
+
2683
+ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
2684
+ if (ref === WORKING_TREE_SENTINEL) {
2685
+ const resolved = path.resolve(path.join(GIT_DIFF_DIR, filePath));
2686
+ if (!resolved.startsWith(path.resolve(GIT_DIFF_DIR))) {
2687
+ throw new Error("Access denied");
2688
+ }
2689
+ const buf = await fs.readFile(resolved);
2690
+ if (buf.byteLength > MAX_DIFF_FILE_BYTES) {
2691
+ return (
2692
+ buf.slice(0, MAX_DIFF_FILE_BYTES).toString("utf-8") +
2693
+ `\n\n[file truncated after ${MAX_DIFF_FILE_BYTES} bytes]`
2694
+ );
2695
+ }
2696
+ return buf.toString("utf-8");
2697
+ }
2698
+ const { stdout } = await runGit(["show", `${ref}:${filePath}`], {
2699
+ cwd: GIT_DIFF_DIR,
2700
+ maxBuffer: MAX_DIFF_FILE_BYTES * 4,
2701
+ });
2702
+ if (stdout.length > MAX_DIFF_FILE_BYTES) {
2703
+ return (
2704
+ stdout.slice(0, MAX_DIFF_FILE_BYTES) +
2705
+ `\n\n[file truncated after ${MAX_DIFF_FILE_BYTES} bytes]`
2706
+ );
2707
+ }
2708
+ return stdout;
2709
+ }
2710
+
2711
+ app.get("/api/code-context/git/branches", async (_req, res) => {
2712
+ if (!GIT_DIFF_DIR) return res.json({ enabled: false, branches: [] });
2713
+ try {
2714
+ // Refresh remote-tracking refs first. This is intentionally non-interactive
2715
+ // and time-capped so private/offline repos fall back to already-local refs.
2716
+ await runGit(["fetch", "--all", "--prune", "--quiet"], {
2717
+ cwd: GIT_DIFF_DIR,
2718
+ allowFail: true,
2719
+ timeoutMs: 8000,
2720
+ });
2721
+
2722
+ const [head, branches, tags, remotes] = await Promise.all([
2723
+ runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: GIT_DIFF_DIR }),
2724
+ runGit(
2725
+ [
2726
+ "for-each-ref",
2727
+ "--sort=-committerdate",
2728
+ `--count=${MAX_BRANCHES}`,
2729
+ "--format=%(refname:short)",
2730
+ "refs/heads/",
2731
+ "refs/remotes/",
2732
+ ],
2733
+ { cwd: GIT_DIFF_DIR },
2734
+ ),
2735
+ runGit(
2736
+ [
2737
+ "for-each-ref",
2738
+ "--sort=-creatordate",
2739
+ "--count=50",
2740
+ "--format=%(refname:short)",
2741
+ "refs/tags/",
2742
+ ],
2743
+ { cwd: GIT_DIFF_DIR, allowFail: true },
2744
+ ),
2745
+ runGit(["remote"], { cwd: GIT_DIFF_DIR, allowFail: true }),
2746
+ ]);
2747
+ const remoteNames = remotes.stdout
2748
+ .split("\n")
2749
+ .map((s) => s.trim())
2750
+ .filter(Boolean);
2751
+ const lsRemoteResults = await Promise.all(
2752
+ remoteNames.slice(0, 5).map((remote) =>
2753
+ runGit(["ls-remote", "--heads", remote], {
2754
+ cwd: GIT_DIFF_DIR,
2755
+ allowFail: true,
2756
+ timeoutMs: 5000,
2757
+ maxBuffer: 1024 * 1024,
2758
+ }).then((result) =>
2759
+ result.stdout
2760
+ .split("\n")
2761
+ .map((line) => line.trim().split("\t")[1] || "")
2762
+ .filter((ref) => ref.startsWith("refs/heads/"))
2763
+ .map((ref) => `${remote}/${ref.replace("refs/heads/", "")}`),
2764
+ ),
2765
+ ),
2766
+ );
2767
+ const branchList = uniqueStrings([
2768
+ ...branches.stdout
2769
+ .split("\n")
2770
+ .map((s) => s.trim())
2771
+ .filter(Boolean),
2772
+ ...lsRemoteResults.flat(),
2773
+ ]).filter((b) => !b.endsWith("/HEAD"));
2774
+ const tagList = tags.stdout
2775
+ .split("\n")
2776
+ .map((s) => s.trim())
2777
+ .filter(Boolean);
2778
+ const currentHead = head.stdout.trim();
2779
+ res.json({
2780
+ enabled: true,
2781
+ head: currentHead,
2782
+ defaultBranch: chooseDefaultBaseBranch(currentHead, branchList),
2783
+ branches: branchList,
2784
+ tags: tagList,
2785
+ });
2786
+ } catch (err: any) {
2787
+ res.status(500).json({
2788
+ enabled: false,
2789
+ branches: [],
2790
+ error: err?.message || "git not available",
2791
+ });
2792
+ }
2793
+ });
2794
+
2795
+ app.get("/api/code-context/git/diff-tree", async (req, res) => {
2796
+ if (!GIT_DIFF_DIR) return res.status(400).json({ error: "No git directory" });
2797
+
2798
+ const base = String(req.query.base || "").trim();
2799
+ const headRaw = String(req.query.head || "").trim();
2800
+ const mode = parseDiffMode(req.query.mode);
2801
+
2802
+ if (!isValidRef(base))
2803
+ return res.status(400).json({ error: "Invalid base ref" });
2804
+
2805
+ const baseSha = await resolveRef(base);
2806
+ if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
2807
+
2808
+ let head = headRaw;
2809
+ let headSha: string | null = WORKING_TREE_SENTINEL;
2810
+ if (mode === "working-tree") {
2811
+ head = WORKING_TREE_SENTINEL;
2812
+ } else {
2813
+ if (!isValidRef(head))
2814
+ return res.status(400).json({ error: "Invalid head ref" });
2815
+ headSha = await resolveRef(head);
2816
+ if (!headSha) return res.status(404).json({ error: "Head ref not found" });
2817
+ }
2818
+
2819
+ try {
2820
+ const files = await getChangedFiles(base, head, mode);
2821
+ res.json({
2822
+ base,
2823
+ baseSha,
2824
+ head: mode === "working-tree" ? WORKING_TREE_SENTINEL : head,
2825
+ headSha,
2826
+ mode,
2827
+ truncated: files.length >= MAX_DIFF_FILES,
2828
+ files,
2829
+ });
2830
+ } catch (err: any) {
2831
+ res.status(500).json({ error: err?.message || "git diff failed" });
2832
+ }
2833
+ });
2834
+
2835
+ app.get("/api/code-context/git/diff-file", async (req, res) => {
2836
+ if (!GIT_DIFF_DIR) return res.status(400).json({ error: "No git directory" });
2837
+
2838
+ const base = String(req.query.base || "").trim();
2839
+ const headRaw = String(req.query.head || "").trim();
2840
+ const mode = parseDiffMode(req.query.mode);
2841
+ const filePath = String(req.query.path || "").trim();
2842
+ const view = (req.query.view as string) || "patch";
2843
+
2844
+ if (!isValidRef(base))
2845
+ return res.status(400).json({ error: "Invalid base ref" });
2846
+ if (!filePath || filePath.includes("\0"))
2847
+ return res.status(400).json({ error: "Invalid path" });
2848
+
2849
+ const baseSha = await resolveRef(base);
2850
+ if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
2851
+
2852
+ let head = headRaw;
2853
+ if (mode === "working-tree") {
2854
+ head = WORKING_TREE_SENTINEL;
2855
+ } else {
2856
+ if (!isValidRef(head))
2857
+ return res.status(400).json({ error: "Invalid head ref" });
2858
+ const headSha = await resolveRef(head);
2859
+ if (!headSha) return res.status(404).json({ error: "Head ref not found" });
2860
+ }
2861
+
2862
+ // Verify the path is actually part of the diff so callers cannot read arbitrary repo files.
2863
+ let entry: GitDiffFileEntry | undefined;
2864
+ try {
2865
+ const files = await getChangedFiles(base, head, mode);
2866
+ entry = files.find((f) => f.path === filePath || f.oldPath === filePath);
2867
+ } catch (err: any) {
2868
+ return res.status(500).json({ error: err?.message || "git diff failed" });
2869
+ }
2870
+ if (!entry) return res.status(404).json({ error: "File not in diff" });
2871
+
2872
+ try {
2873
+ if (view === "before") {
2874
+ if (entry.status === "A" || entry.status === "?") {
2875
+ return res.json({ path: filePath, view, content: "" });
2876
+ }
2877
+ const refPath = entry.oldPath ?? filePath;
2878
+ const content = await getFileAtRef(base, refPath);
2879
+ return res.json({ path: filePath, view, content });
2880
+ }
2881
+ if (view === "after") {
2882
+ if (entry.status === "D") {
2883
+ return res.json({ path: filePath, view, content: "" });
2884
+ }
2885
+ const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
2886
+ const content = await getFileAtRef(ref, filePath);
2887
+ return res.json({ path: filePath, view, content });
2888
+ }
2889
+ // default: patch
2890
+ const patch = await getDiffPatch(base, head, mode, filePath);
2891
+ return res.json({ path: filePath, view: "patch", content: patch });
2892
+ } catch (err: any) {
2893
+ res.status(500).json({ error: err?.message || "git read failed" });
2894
+ }
2895
+ });
2896
+
2284
2897
  // ─── Code Line Ask ──────────────────────────────────────
2285
2898
 
2286
2899
  app.post("/api/code-line-ask", async (req, res) => {
@@ -2293,6 +2906,7 @@ app.post("/api/code-line-ask", async (req, res) => {
2293
2906
  codeContextFiles,
2294
2907
  codeSnippets,
2295
2908
  preferenceSuffix,
2909
+ gitDiffContext,
2296
2910
  } = req.body;
2297
2911
  if (typeof prompt !== "string" || !prompt.trim()) {
2298
2912
  return res.status(400).json({ error: "prompt is required" });
@@ -2372,6 +2986,11 @@ app.post("/api/code-line-ask", async (req, res) => {
2372
2986
  }
2373
2987
  }
2374
2988
 
2989
+ const gitDiffManifestCla = await registerGitDiffContext(
2990
+ fileRegistry,
2991
+ gitDiffContext || {},
2992
+ );
2993
+
2375
2994
  let system =
2376
2995
  "You are a senior software engineer and interview coach. Answer questions about the highlighted code lines. Be concise, accurate, and practical. Use markdown formatting for code references.";
2377
2996
 
@@ -2393,9 +3012,14 @@ app.post("/api/code-line-ask", async (req, res) => {
2393
3012
 
2394
3013
  system += `\n\n--- Available Reference Files ---\nThe following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant. For image files, readFile returns visual image data so you can inspect what is visible.\n\n`;
2395
3014
  for (const [id, { label }] of fileRegistry) {
3015
+ if (id.startsWith("gitdiff:")) continue;
2396
3016
  system += `• ${label} (id: "${id}")\n`;
2397
3017
  }
2398
3018
 
3019
+ if (gitDiffManifestCla) {
3020
+ system += gitDiffManifestCla;
3021
+ }
3022
+
2399
3023
  if (codeFilePaths.length > 0) {
2400
3024
  system += `\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.`;
2401
3025
  }