-
Notifications
You must be signed in to change notification settings - Fork 13
feat: file context expansion + collapse/expand nav button #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fd387de
b3e73c1
89f63dc
6d63bc7
c537bc4
878d775
33b98b0
34418e8
e31d137
84b0fa7
47d9996
c54519e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,21 @@ | ||
| import { spawn } from "node:child_process"; | ||
| import type { ServerResponse } from "node:http"; | ||
| import { execFile } from "node:child_process"; | ||
| import fs from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { promisify } from "node:util"; | ||
| import type { DiffResponse, FileContentsMap } from "@stagereview/types/diff"; | ||
| import { eq } from "drizzle-orm"; | ||
| import type { StageDb } from "../db/client.js"; | ||
| import type { ChapterRunRow } from "../db/schema/chapter-run.js"; | ||
| import { chapterRun } from "../db/schema/index.js"; | ||
| import { buildDiffArgs } from "../git.js"; | ||
| import { SCOPE_KIND } from "../schema.js"; | ||
| import { SCOPE_KIND, WORKING_TREE_REF } from "../schema.js"; | ||
| import type { Route } from "../server.js"; | ||
| import { writeJson } from "./json.js"; | ||
|
|
||
| const execFileAsync = promisify(execFile); | ||
|
|
||
| const MAX_FILE_BYTES = 5 * 1024 * 1024; | ||
|
|
||
| export function diffRoutes(db: StageDb): Route[] { | ||
| return [ | ||
| { | ||
|
|
@@ -27,9 +34,6 @@ export function diffRoutes(db: StageDb): Route[] { | |
| return; | ||
| } | ||
|
|
||
| // Defense in depth: repoRoot was validated at ingest, but the diff endpoint is | ||
| // a fresh boundary. Refuse non-absolute paths or any path containing `..` | ||
| // segments so we can't be tricked into spawning git against a traversal. | ||
| const repoRoot = run.repoRoot; | ||
| if (!path.isAbsolute(repoRoot) || repoRoot.split(path.sep).includes("..")) { | ||
| writeJson(res, 500, { | ||
|
|
@@ -42,81 +46,142 @@ export function diffRoutes(db: StageDb): Route[] { | |
| const cacheControl = | ||
| run.scopeKind === SCOPE_KIND.COMMITTED ? "private, max-age=300" : "no-store"; | ||
|
|
||
| await streamGitDiff(res, repoRoot, args, cacheControl); | ||
| try { | ||
| const { stdout: patch } = await execFileAsync("git", args, { | ||
| cwd: repoRoot, | ||
| encoding: "utf8", | ||
| maxBuffer: 50 * 1024 * 1024, | ||
| }); | ||
| const fileContents = await buildFileContents(run, repoRoot, patch); | ||
| const body: DiffResponse = { patch, fileContents }; | ||
| res.writeHead(200, { | ||
| "Content-Type": "application/json; charset=utf-8", | ||
| "Cache-Control": cacheControl, | ||
| }); | ||
| res.end(JSON.stringify(body)); | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| writeJson(res, 500, { error: message }); | ||
| } | ||
| }, | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| function streamGitDiff( | ||
| res: ServerResponse, | ||
| const MINUS_RE = /^--- (?:a\/)?(.+)$/m; | ||
| const PLUS_RE = /^\+\+\+ (?:b\/)?(.+)$/m; | ||
| const BINARY_RE = /^Binary files/m; | ||
|
|
||
| interface ParsedFilePaths { | ||
| oldPath: string | null; | ||
| newPath: string | null; | ||
| isBinary: boolean; | ||
| } | ||
|
|
||
| function parseFilePathsFromPatch(patch: string): ParsedFilePaths[] { | ||
| if (!patch.trim()) return []; | ||
|
|
||
| const segments = patch.split(/\ndiff --git /); | ||
| const results: ParsedFilePaths[] = []; | ||
|
|
||
| for (let i = 0; i < segments.length; i++) { | ||
| const segment = segments[i]; | ||
| if (segment === undefined) continue; | ||
| const text = i === 0 ? segment : `diff --git ${segment}`; | ||
| if (!text.startsWith("diff --git ")) continue; | ||
|
|
||
| const isBinary = BINARY_RE.test(text); | ||
|
|
||
| const minus = text.match(MINUS_RE); | ||
| const plus = text.match(PLUS_RE); | ||
|
|
||
| const oldPath = minus?.[1] && minus[1] !== "/dev/null" ? minus[1] : null; | ||
| const newPath = plus?.[1] && plus[1] !== "/dev/null" ? plus[1] : null; | ||
|
|
||
| results.push({ oldPath, newPath, isBinary }); | ||
| } | ||
|
|
||
| return results; | ||
| } | ||
|
|
||
| async function getGitFileContent( | ||
| cwd: string, | ||
| args: string[], | ||
| cacheControl: string, | ||
| ): Promise<void> { | ||
| return new Promise((resolve) => { | ||
| const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); | ||
|
|
||
| let stderr = ""; | ||
| let settled = false; | ||
|
|
||
| const settle = () => { | ||
| if (settled) return; | ||
| settled = true; | ||
| resolve(); | ||
| }; | ||
|
|
||
| const writeSuccessHeaders = () => { | ||
| if (res.headersSent) return; | ||
| res.writeHead(200, { | ||
| "Content-Type": "text/plain; charset=utf-8", | ||
| "Cache-Control": cacheControl, | ||
| }); | ||
| }; | ||
|
|
||
| // If the client disconnects before git finishes, kill the child so we don't leak | ||
| // a long-running diff for a request nobody is reading. | ||
| res.once("close", () => { | ||
| if (!child.killed) child.kill("SIGTERM"); | ||
| ref: string, | ||
| filePath: string, | ||
| ): Promise<string | null> { | ||
| try { | ||
| const { stdout } = await execFileAsync("git", ["show", `${ref}:${filePath}`], { | ||
| cwd, | ||
| encoding: "utf8", | ||
| maxBuffer: MAX_FILE_BYTES, | ||
| }); | ||
| return stdout; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| child.on("error", (err) => { | ||
| if (!res.headersSent) { | ||
| writeJson(res, 500, { error: `git failed: ${err.message}` }); | ||
| } else if (!res.writableEnded) { | ||
| res.end(); | ||
| } | ||
| settle(); | ||
| }); | ||
| async function readFileContent(repoRoot: string, filePath: string): Promise<string | null> { | ||
| const resolved = path.resolve(repoRoot, filePath); | ||
| const rel = path.relative(repoRoot, resolved); | ||
| if (rel.startsWith("..") || path.isAbsolute(rel)) return null; | ||
| try { | ||
| return await fs.readFile(resolved, "utf8"); | ||
|
Comment on lines
+125
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
dastratakos marked this conversation as resolved.
|
||
|
|
||
| child.stderr.on("data", (chunk: Buffer) => { | ||
| stderr += chunk.toString("utf8"); | ||
| }); | ||
| function getContentRefs(run: ChapterRunRow): { oldRef: string; newRef: string | "DISK" } { | ||
| if (run.scopeKind === SCOPE_KIND.COMMITTED) { | ||
| return { oldRef: run.baseSha, newRef: run.headSha }; | ||
| } | ||
| switch (run.workingTreeRef) { | ||
| case WORKING_TREE_REF.UNSTAGED: | ||
| return { oldRef: "", newRef: "DISK" }; | ||
| case WORKING_TREE_REF.STAGED: | ||
| return { oldRef: "HEAD", newRef: "" }; | ||
| case WORKING_TREE_REF.WORK: | ||
| return { oldRef: "HEAD", newRef: "DISK" }; | ||
| default: | ||
| return { oldRef: "HEAD", newRef: "HEAD" }; | ||
| } | ||
| } | ||
|
|
||
| child.stdout.on("data", (chunk: Buffer) => { | ||
| writeSuccessHeaders(); | ||
| if (!res.write(chunk)) { | ||
| child.stdout.pause(); | ||
| res.once("drain", () => child.stdout.resume()); | ||
| } | ||
| }); | ||
| function fetchContent( | ||
| repoRoot: string, | ||
| ref: string | "DISK", | ||
| filePath: string, | ||
| ): Promise<string | null> { | ||
| if (ref === "DISK") return readFileContent(repoRoot, filePath); | ||
| return getGitFileContent(repoRoot, ref, filePath); | ||
| } | ||
|
|
||
| child.on("close", (code) => { | ||
| if (settled) return; | ||
| if (code === 0) { | ||
| // Successful exit with no stdout (empty diff) still needs headers + end. | ||
| writeSuccessHeaders(); | ||
| res.end(); | ||
| } else if (!res.headersSent) { | ||
| const message = stderr.trim() || `git exited with code ${code}`; | ||
| writeJson(res, 500, { error: message }); | ||
| } else { | ||
| // Headers were already sent, so we can't change the status. Log and terminate | ||
| // the response — the client will see a truncated patch. | ||
| process.stderr.write(`git diff failed mid-stream (exit ${code}): ${stderr}\n`); | ||
| if (!res.writableEnded) res.end(); | ||
| } | ||
| settle(); | ||
| }); | ||
| }); | ||
| async function buildFileContents( | ||
| run: ChapterRunRow, | ||
| repoRoot: string, | ||
| patch: string, | ||
| ): Promise<FileContentsMap> { | ||
| const files = parseFilePathsFromPatch(patch); | ||
| const { oldRef, newRef } = getContentRefs(run); | ||
|
|
||
| const entries = await Promise.all( | ||
|
dastratakos marked this conversation as resolved.
|
||
| files.map(async ({ oldPath, newPath, isBinary }) => { | ||
| const key = newPath ?? oldPath; | ||
| if (!key || isBinary) return null; | ||
|
|
||
| const [oldContent, newContent] = await Promise.all([ | ||
| oldPath ? fetchContent(repoRoot, oldRef, oldPath) : Promise.resolve(null), | ||
| newPath ? fetchContent(repoRoot, newRef, newPath) : Promise.resolve(null), | ||
| ]); | ||
|
Comment on lines
+168
to
+176
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| return [key, { oldContent, newContent }] as const; | ||
| }), | ||
| ); | ||
|
|
||
| const map: FileContentsMap = {}; | ||
| for (const entry of entries) { | ||
| if (entry) map[entry[0]] = entry[1]; | ||
| } | ||
| return map; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.