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.
- package/package.json +1 -1
- package/template/client/src/api.ts +67 -0
- package/template/client/src/components/ChatView.tsx +2 -0
- package/template/client/src/components/CodeContextPanel.tsx +28 -1
- package/template/client/src/components/FileViewerModal.tsx +1 -0
- package/template/client/src/components/GitDiffPanel.tsx +403 -0
- package/template/client/src/components/GitDiffViewerModal.tsx +124 -0
- package/template/client/src/store.ts +14 -0
- package/template/client/src/types.ts +23 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +624 -0
- package/template/server/src/storage.ts +12 -0
|
@@ -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
|
}
|