/** * @file Reads a lane's code changes as structured hunks, for the Workspace's * Changes panel (overview / review / single-file / history / file). Three * ranges, because the questions a lane raises are different: `work` is "what * has the agent touched and not committed yet" (HEAD vs the working tree, * untracked files included), `branch` is "what does this lane add on top of * its base" (`...HEAD`), and `commit` is one recorded commit. * * Everything here is READ-ONLY and every git call goes through worktree.js's * `git()` argv wrapper — no shell strings, same rule as the rest of the lane * git surface. Untracked files are rendered as an all-added patch from the file * itself rather than via `git diff --no-index`, which exits non-zero on a * difference and would read as a failure. * * Output is capped in three places (files listed, bytes per file, bytes in * total) because this rides an HTTP response a browser has to render: a lane * mid-`npm install` or holding a committed lockfile would otherwise ship * megabytes of patch to a React list. A cap that bites sets `truncated`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { git } = require("./worktree"); /** Most files one response names. Beyond this the list is `truncated`. */ const MAX_FILES = 300; /** Most patch bytes read for ONE file before its hunks are cut short. */ const MAX_FILE_BYTES = 200_000; /** Most patch bytes across all files in a `full` (review-mode) response. */ const MAX_TOTAL_BYTES = 1_000_000; /** An untracked file larger than this is reported without content. */ const MAX_UNTRACKED_BYTES = 1_000_000; /** Most bytes `readFileAt` returns before cutting the tail off. */ const MAX_FILE_VIEW_BYTES = 1_000_000; /** Most paths `listPaths`/`listTree` answer with. */ const MAX_PATHS = 500; /** Lines of unchanged context around a hunk, and the ceiling "expand all" * is clamped to — a whole large file rendered as context is the one way this * panel can be made to ship more bytes than the caps above allow. */ const DEFAULT_CONTEXT = 3; const MAX_CONTEXT = 5000; const VALID_RANGES = new Set(["work", "branch", "commit"]); /** A commit-ish this module will pass to git. Deliberately strict: a sha, or a * ref name without the characters that would let it read as an option or a * pathspec. `git` is called with an argv array, so this is belt-and-braces. */ const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/; /** Throw a coded error the routes map onto a 400. */ function badRequest(code, message) { const err = new Error(message); err.code = code; return err; } /** Clamp a caller's context request into [0, MAX_CONTEXT]. */ function normalizeContext(value) { const n = Number(value); if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT; return Math.min(Math.floor(n), MAX_CONTEXT); } /** * Parse one file's unified diff into hunks with both gutters resolved, so the * client renders rows instead of re-parsing `@@` headers. * * Returns `[]` for an empty patch (an unchanged file, or a mode-only change). */ function parseUnifiedDiff(patch) { const hunks = []; let current = null; let oldLine = 0; let newLine = 0; for (const raw of patch.split("\n")) { const header = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/); if (header) { oldLine = Number(header[1]); newLine = Number(header[2]); current = { header: raw, context: header[3].trim(), oldStart: oldLine, newStart: newLine, lines: [], }; hunks.push(current); continue; } if (!current) continue; // preamble: ---/+++/index/diff lines // "\ No newline at end of file" annotates the previous line; it is not a // row of its own and carries no line number. if (raw.startsWith("\\")) continue; const marker = raw[0]; const text = raw.slice(1); if (marker === "+") { current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text }); } else if (marker === "-") { current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text }); } else if (marker === " ") { // An unchanged EMPTY line is " " — one space, no text. A truly empty // string is the tail of the final "\n", not a row. current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text }); } } return hunks; } /** Whether a buffer looks binary — the same NUL-byte heuristic git uses. */ function looksBinary(buf) { return buf.includes(0); } /** * Resolve the ref a `branch`-range diff measures against: `origin/` when * the remote-tracking ref exists, else the local branch, else null (a lane with * no base, or a base that has since been deleted — reported, not guessed). */ async function resolveBaseRef(dir, base) { if (!base) return null; for (const ref of [`origin/${base}`, base]) { try { await git(dir, ["rev-parse", "--verify", "--quiet", ref]); return ref; } catch { /* try the next candidate */ } } return null; } /** `git diff --numstat` output → Map. */ function parseNumstat(stdout) { const stats = new Map(); for (const line of stdout.split("\n")) { const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/); if (!m) continue; // A rename reports "old => new" (or "dir/{a => b}/file"); the path that // exists now is what every other source here is keyed by. const file = m[3].includes(" => ") ? renameTarget(m[3]) : m[3]; stats.set(file, { added: m[1] === "-" ? null : Number(m[1]), deleted: m[2] === "-" ? null : Number(m[2]), binary: m[1] === "-", }); } return stats; } /** `dir/{old => new}/f.js` or `old.js => new.js` → the post-rename path. */ function renameTarget(spec) { const braced = spec.match(/^(.*)\{(.*) => (.*)\}(.*)$/); if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/\//g, "/"); return spec.split(" => ").pop(); } /** * Porcelain codes that mean "unmerged" — a merge or a rebase left this file * with conflict markers. git's own list; `UU` is the common one, but a * delete/modify conflict reports `DU`/`UD` and an add/add reports `AA`. */ const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]); /** Working-tree summary: porcelain status, plus numstat deltas for tracked files. */ async function summarizeWork(dir) { const [status, numstat] = await Promise.all([ git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]), git(dir, ["diff", "--numstat", "HEAD"]).catch(() => ({ stdout: "" })), ]); const stats = parseNumstat(numstat.stdout); const files = []; for (const line of status.stdout.split("\n")) { if (!line.trim()) continue; const code = line.slice(0, 2); const raw = line.slice(3).trimEnd(); const file = raw.split(" -> ").pop().replace(/^"|"$/g, ""); const untracked = code === "??"; const stat = stats.get(file); // One read per untracked file, not two: its line count and its // binary-ness are the same buffer answered twice. const fresh = untracked ? untrackedFacts(dir, file) : null; files.push({ path: file, status: code, untracked, // The two porcelain columns are the index and the working tree, which // is the whole of what per-file staging needs to render: a file can be // BOTH (edited, staged, edited again) and the row has to say so rather // than pick one. staged: !untracked && code[0] !== " " && code[0] !== "?", unstaged: untracked || (code[1] !== " " && code[1] !== "?"), conflicted: CONFLICT_CODES.has(code), added: untracked ? fresh.lines : (stat?.added ?? 0), deleted: untracked ? 0 : (stat?.deleted ?? 0), binary: untracked ? fresh.binary : !!stat?.binary, }); } return files; } /** * An untracked file's line count and binary-ness, from ONE read. * * `lines` is null when the file is too big, binary, or unreadable — git has no * delta for an untracked file either, and a null there already means "unknown" * everywhere else in this response. A file over the cap counts as binary so * nothing downstream tries to render it. */ function untrackedFacts(dir, file) { try { const abs = path.join(dir, file); if (fs.statSync(abs).size > MAX_UNTRACKED_BYTES) return { lines: null, binary: true }; const buf = fs.readFileSync(abs); if (looksBinary(buf)) return { lines: null, binary: true }; const text = buf.toString("utf8"); if (!text) return { lines: 0, binary: false }; const lines = text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length; return { lines, binary: false }; } catch { // A directory entry (git reports untracked DIRS with a trailing slash), a // dangling symlink, a file deleted between the status call and this read. return { lines: null, binary: false }; } } /** Branch summary: `...HEAD`, name-status for the letter, numstat for counts. */ async function summarizeBranch(dir, baseRef) { const spec = `${baseRef}...HEAD`; const [nameStatus, numstat] = await Promise.all([ git(dir, ["diff", "--name-status", spec]), git(dir, ["diff", "--numstat", spec]), ]); return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout)); } /** * One commit's summary. `git show` against a MERGE commit prints nothing by * default (it diffs against no single parent), so `-m --first-parent` picks * the mainline side — which is what "what did this commit bring in" means to * a reader scrolling a branch's history. */ async function summarizeCommit(dir, sha) { const base = ["show", "--format=", "-m", "--first-parent", sha]; const [nameStatus, numstat] = await Promise.all([ git(dir, [...base, "--name-status"]), git(dir, [...base, "--numstat"]), ]); return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout)); } /** Shared `--name-status` reader for the ranges that have no working tree. */ function parseNameStatus(stdout, stats) { const files = []; const seen = new Set(); for (const line of stdout.split("\n")) { if (!line.trim()) continue; const parts = line.split("\t"); const letter = parts[0][0]; // A rename/copy line is "R100\told\tnew" — the new path is the live one. const file = parts.length > 2 ? parts[2] : parts[1]; // `-m` prints one diff per parent for a merge; --first-parent keeps it to // one, but a defensive de-dup costs nothing and a doubled row is a lie. if (seen.has(file)) continue; seen.add(file); const stat = stats.get(file); files.push({ path: file, status: `${letter} `, untracked: false, staged: false, unstaged: false, conflicted: false, added: stat?.added ?? 0, deleted: stat?.deleted ?? 0, binary: !!stat?.binary, }); } return files; } /** One file's patch text, for any range. Empty string when there is none. */ async function patchFor(dir, { range, baseRef, sha, file, untracked, context }) { if (untracked) return synthesizeAddedPatch(dir, file); const u = `-U${context}`; const args = range === "commit" ? ["show", "--format=", "-m", "--first-parent", u, sha, "--", file] : range === "branch" ? ["diff", u, `${baseRef}...HEAD`, "--", file] : ["diff", u, "HEAD", "--", file]; const result = await git(dir, [...args, "--no-color", "--no-ext-diff"]).catch(() => ({ stdout: "", })); return result.stdout; } /** * An untracked file has no git-side "before", so its patch is synthesized: one * hunk starting at line 1 with every line added. This is what `git diff * --no-index /dev/null ` would print, without the non-zero exit that * wrapper treats as a failure. */ function synthesizeAddedPatch(dir, file) { let buf; try { buf = fs.readFileSync(path.join(dir, file)); } catch { return ""; } if (looksBinary(buf)) return ""; const text = buf.toString("utf8"); if (!text) return ""; const lines = text.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); const body = lines.map((l) => `+${l}`).join("\n"); return `@@ -0,0 +1,${lines.length} @@\n${body}\n`; } /** * The lane's changes for one range. * * @param {string} dir Lane working directory. * @param {object} opts * @param {"work"|"branch"|"commit"} [opts.range="work"] * @param {string|null} [opts.base] The lane's base branch, for `range="branch"`. * @param {string|null} [opts.sha] The commit to read, for `range="commit"`. * @param {string|null} [opts.file] Only this path, with its hunks (single-file mode). * @param {boolean} [opts.full] Hunks for EVERY file (review mode). Ignored when * `file` is set, which is already a full read of one file. * @param {number} [opts.context] Unchanged lines around each hunk. The panel * raises this to expand a diff towards the whole file. * @returns {Promise<{range, base, baseRef, sha, context, files, truncated}>} */ async function collectChanges( dir, { range = "work", base = null, sha = null, file = null, full = false, context } = {} ) { if (!VALID_RANGES.has(range)) { throw badRequest("EBADRANGE", `unknown range ${range}`); } const ctx = normalizeContext(context); let baseRef = null; if (range === "branch") { baseRef = await resolveBaseRef(dir, base); if (!baseRef) { throw badRequest("ENOBASE", "no base branch to compare against"); } } if (range === "commit") { if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision"); try { // Resolve it once, so a bad ref is a 400 here rather than an empty diff // three calls later, and so the response reports the full sha. sha = (await git(dir, ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`])).stdout.trim(); } catch { throw badRequest("EBADREV", "no such commit in this repository"); } } let files = range === "branch" ? await summarizeBranch(dir, baseRef) : range === "commit" ? await summarizeCommit(dir, sha) : await summarizeWork(dir); files.sort((a, b) => a.path.localeCompare(b.path)); let truncated = false; if (file) { const one = files.find((f) => f.path === file); if (!one) { throw badRequest("ENOFILECHANGE", "file has no changes in this range"); } files = [one]; } else if (files.length > MAX_FILES) { files = files.slice(0, MAX_FILES); truncated = true; } if (file || full) { let budget = file ? MAX_FILE_BYTES : MAX_TOTAL_BYTES; for (const entry of files) { if (entry.binary) { entry.hunks = []; continue; } if (budget <= 0) { entry.hunks = []; entry.truncated = true; truncated = true; continue; } let patch = await patchFor(dir, { range, baseRef, sha, file: entry.path, untracked: entry.untracked, context: ctx, }); const cap = Math.min(MAX_FILE_BYTES, budget); if (patch.length > cap) { // Cut on a line boundary so the last row is a whole line — but only // when that boundary is near the cap. A minified file is one enormous // line, so its last newline inside the cap is the HUNK HEADER's, and // honouring it would return a hunk with no lines at all (and with no // newline anywhere, `lastIndexOf` is -1 and the patch came back empty) // — a file changed by a megabyte rendering as "no textual change". // Past the halfway mark the boundary is worth keeping; before it, the // content is, so the last row is cut mid-line instead. const nl = patch.lastIndexOf("\n", cap); patch = patch.slice(0, nl > cap / 2 ? nl + 1 : cap); entry.truncated = true; truncated = true; } budget -= patch.length; entry.hunks = parseUnifiedDiff(patch); } } return { range, base, baseRef, sha, context: ctx, files, truncated }; } /** * Recent commits on the lane's current branch, for the history list. * * `%x1f`/`%x1e` (unit/record separators) delimit the format instead of a * printable character: a commit SUBJECT can legally contain a tab, a pipe or * any punctuation a hand-picked delimiter would have used. * * @param {string} dir Lane working directory. * @param {object} [opts] * @param {number} [opts.limit=30] Commits to read, clamped to 200. * @param {string|null} [opts.file] Only commits touching this path. */ async function listCommits(dir, { limit = 30, file = null } = {}) { const n = Math.min(Math.max(Number(limit) || 30, 1), 200); const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f"); const args = ["log", `-${n}`, `--format=${format}%x1e`]; if (file) args.push("--", file); const result = await git(dir, args); const commits = []; for (const record of result.stdout.split("\x1e")) { const line = record.replace(/^\n/, ""); if (!line.trim()) continue; const [sha, short, subject, author, date, refs] = line.split("\x1f"); commits.push({ sha, short, subject, author, date, // `%D` is "HEAD -> main, origin/main, tag: v1" — the decorations, split // into the chips a history row shows. refs: (refs || "") .split(", ") .map((r) => r.trim()) .filter(Boolean), }); } return commits; } /** * One file's full content at a revision, for the "read the whole file" view * that a diff's three lines of context cannot answer. * * `rev` is `work` (the file on disk, including an untracked one), `HEAD`, or a * commit-ish. The path is resolved against git's own index/tree rather than * trusted: for `work` it is realpath-confined to the lane directory, because * that is the one branch that reads the filesystem directly. */ async function readFileAt(dir, file, rev = "work") { if (typeof file !== "string" || !file || file.startsWith("/") || file.includes("\0")) { throw badRequest("EBADPATH", "path must be a relative path inside the lane"); } if (rev !== "work" && !SAFE_REV.test(rev)) { throw badRequest("EBADREV", "rev is not a valid revision"); } let text; let binary = false; if (rev === "work") { const abs = path.resolve(dir, file); // `..` in the request must not walk out of the lane. Compare resolved // paths, not the string the caller sent. const root = path.resolve(dir); if (abs !== root && !abs.startsWith(root + path.sep)) { throw badRequest("EBADPATH", "path escapes the lane directory"); } let buf; try { buf = fs.readFileSync(abs); } catch { throw badRequest("ENOFILE", "no such file in the working tree"); } binary = looksBinary(buf); text = binary ? "" : buf.toString("utf8"); } else { let out; try { out = await git(dir, ["show", `${rev}:${file}`]); } catch { throw badRequest("ENOFILE", "no such file at that revision"); } text = out.stdout; binary = text.includes("\0"); if (binary) text = ""; } let truncated = false; if (text.length > MAX_FILE_VIEW_BYTES) { const nl = text.lastIndexOf("\n", MAX_FILE_VIEW_BYTES); text = text.slice(0, nl > MAX_FILE_VIEW_BYTES / 2 ? nl + 1 : MAX_FILE_VIEW_BYTES); truncated = true; } const lines = text ? text.split("\n") : []; if (lines[lines.length - 1] === "") lines.pop(); return { path: file, rev, binary, truncated, lines }; } /** * Tracked paths matching a substring query — the quick-open list. * * `git ls-files` rather than a filesystem walk: it already honours * `.gitignore`, so `node_modules` and build output never enter the list and no * skip-list has to be maintained here. */ async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) { const result = await git(dir, ["ls-files", "-z"]); const needle = String(q || "") .toLowerCase() .trim(); const cap = Math.min(Math.max(Number(limit) || MAX_PATHS, 1), MAX_PATHS); const all = result.stdout.split("\0").filter(Boolean); const hits = needle ? all.filter((p) => p.toLowerCase().includes(needle)) : all; // A basename hit beats a directory hit: typing "api" wants `api.ts`, not // every file under `src/api/`. if (needle) { hits.sort((a, b) => { const ab = path.basename(a).toLowerCase().includes(needle) ? 0 : 1; const bb = path.basename(b).toLowerCase().includes(needle) ? 0 : 1; return ab - bb || a.length - b.length || a.localeCompare(b); }); } return { paths: hits.slice(0, cap), truncated: hits.length > cap, total: hits.length }; } /** * One directory level of the working tree, for the file explorer. Tracked * entries only (same `.gitignore` reasoning as `listPaths`), folded into * directories at this level — a tree that lists a repo's every file at once is * both slow to render and not what "expand a folder" means. */ async function listTree(dir, { subdir = "" } = {}) { const clean = String(subdir || "") .replace(/^\/+|\/+$/g, "") .trim(); if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane"); const args = ["ls-files", "-z"]; if (clean) args.push("--", `${clean}/`); const result = await git(dir, args); const prefix = clean ? `${clean}/` : ""; const dirs = new Set(); const files = []; for (const p of result.stdout.split("\0").filter(Boolean)) { const rest = p.slice(prefix.length); const slash = rest.indexOf("/"); if (slash === -1) files.push({ name: rest, path: p, dir: false }); else dirs.add(rest.slice(0, slash)); } const entries = [ ...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })), ...files.sort((a, b) => a.name.localeCompare(b.name)), ]; return { path: clean, entries: entries.slice(0, MAX_PATHS), truncated: entries.length > MAX_PATHS, }; } module.exports = { collectChanges, listCommits, listPaths, listTree, parseUnifiedDiff, readFileAt, resolveBaseRef, DEFAULT_CONTEXT, MAX_CONTEXT, MAX_FILES, MAX_FILE_BYTES, MAX_TOTAL_BYTES, };