/** * @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`. * * The lane's cwd is the boundary, even when it is a SUBDIRECTORY of its repo: * every path in and out is cwd-relative, status entries outside the cwd are * dropped, and every git call treats a path as a literal file name, never a * pathspec glob (`GIT_LITERAL_PATHSPECS`). * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const worktree = require("./worktree"); /** Every git call here: argv only, and a path argument is a literal name. */ const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts }); /** * A git READ whose output this module caps itself: the buffer is sized past * the cap (x4, a UTF-8 char is at most 4 bytes, plus headroom) so git's own * buffer can never be what fails first, and an overflow comes back as the * partial output — longer than `capChars`, so the caller's cap marks it * truncated — instead of an error read as "missing". */ async function readCapped(dir, args, capChars) { try { return (await git(dir, args, { maxBuffer: capChars * 4 + 64 * 1024 })).stdout; } catch (err) { if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && err.git?.stdout) { return String(err.git.stdout); } throw err; } } /** 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; /** Characters of `ls-files -z` read before the listing counts as truncated. */ const MAX_LS_CHARS = 16 * 1024 * 1024; /** 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}$/; /** The file whose CONTENT this module never returns, at any rev and at ANY * depth: `settings.local.json` is where router-apply.js writes a lane's AI * Router API key, and key material never leaves the server. Any depth, * because a lane at the repo root sees a subdirectory lane's key as * `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive * filesystem (macOS) opens it under any casing. */ const SECRET_FILE = ".claude/settings.local.json"; const SECRET_BASENAME = path.posix.basename(SECRET_FILE); /** Whether a lane-relative path names a secret file, however it is spelled. */ function isSecret(rel) { const norm = path.posix .normalize(String(rel).replace(/\\/g, "/")) .replace(/^\.\//, "") .toLowerCase(); // By BASENAME, in any directory: a `.claude` symlinked to `conf/` stores the // key as `conf/settings.local.json`, and a lane above that one (or a commit // of that target) never spells it through `.claude/`. A file with this name // is Claude Code's local settings in practice, so nothing legitimate is lost. return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME; } /** * Whether `file` in the lane is a secret by what it RESOLVES to, not by how * it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file * `conf/settings.local.json`, which no name check catches. Secret when the * real path, relative to the lane's real root, is one by `isSecret`, or when * it is the very file `/.claude/settings.local.json` resolves to. A * path that does not exist on disk is judged by its name alone. */ async function isSecretOnDisk(dir, file) { if (isSecret(file)) return true; let root; let real; try { root = await fs.promises.realpath(dir); real = await fs.promises.realpath(path.resolve(root, file)); } catch { return false; } if (isSecret(path.relative(root, real))) return true; try { return real === (await fs.promises.realpath(path.join(root, SECRET_FILE))); } catch { return false; } } /** * The ONE lexical guard for a caller-supplied path, at every rev: a relative * path with no `..` segment, normalised (`./a//b` → `a/b`) so the secret * check and git both see the lane-relative spelling. Throws EBADPATH. */ function lanePath(file) { if (typeof file !== "string" || !file || file.includes("\0")) { throw badRequest("EBADPATH", "path must be a relative path inside the lane"); } // A backslash is an ordinary file-name byte to git on POSIX, not a separator. if (file.startsWith("/") || file.split("/").includes("..")) { throw badRequest("EBADPATH", "path must be a relative path inside the lane"); } const norm = path.posix .normalize(file) .replace(/^(\.\/)+/, "") .replace(/\/+$/, ""); if (!norm || norm === ".") throw badRequest("EBADPATH", "path must name a file"); return norm; } /** * Resolve `file` inside the lane for a CONTENT read: realpath-confined to the * lane's own realpath, so neither `..` nor a symlink can walk out of it, and * refused when either the requested or the resolved path is a secret file. * Throws EBADPATH / ESECRET / ENOFILE. */ async function resolveInLane(dir, file) { if (isSecret(file)) throw badRequest("ESECRET", "that file holds secrets and is not readable here"); const escapes = (base, target) => { const r = path.relative(base, target); return !r || r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r); }; // Lexically first, so `../x` is EBADPATH whether or not x exists. if (escapes(path.resolve(dir), path.resolve(dir, file))) { throw badRequest("EBADPATH", "path escapes the lane directory"); } const root = await fs.promises.realpath(dir); let real; try { real = await fs.promises.realpath(path.resolve(root, file)); } catch { throw badRequest("ENOFILE", "no such file in the working tree"); } if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the lane directory"); if (await isSecretOnDisk(root, file)) throw badRequest("ESECRET", "that file holds secrets and is not readable here"); return real; } /** 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 -z` output → Map. `-z` * because without it git C-quotes any path with a space, a quote or a * non-ASCII byte (`"t\303\240i.txt"`), which then keys nothing. A rename is * `a\td\t\0old\0new\0`; the path that exists now is the key. Shared with * worktree.js's `laneFiles` (the lane card), so the card and the Changes panel * count a file the same way. */ function parseNumstat(stdout) { const stats = new Map(); const tokens = stdout.split("\0"); for (let i = 0; i < tokens.length; i++) { const m = tokens[i].replace(/^\n/, "").match(/^(\d+|-)\t(\d+|-)\t(.*)$/s); if (!m) continue; let file = m[3]; if (!file) { file = tokens[i + 2]; i += 2; } if (!file) continue; stats.set(file, { added: m[1] === "-" ? null : Number(m[1]), deleted: m[2] === "-" ? null : Number(m[2]), binary: m[1] === "-", }); } return stats; } /** * `git status --porcelain=v1 -z` → [{code, path, from}]. Each record is * `XY path\0`; a rename/copy carries its ORIGINAL path as the next record. * Shared with git-ops.js so the paths a write may name are the paths shown. */ function parsePorcelainZ(stdout) { const entries = []; const tokens = stdout.split("\0"); for (let i = 0; i < tokens.length; i++) { const rec = tokens[i]; if (rec.length < 4) continue; const code = rec.slice(0, 2); const entry = { code, path: rec.slice(3), from: null }; if (code[0] === "R" || code[0] === "C" || code[1] === "R" || code[1] === "C") { entry.from = tokens[++i] || null; } entries.push(entry); } return entries; } /** * `git status` for the LANE: porcelain paths are always repo-ROOT-relative, * while every pathspec and every fs read here resolves from the lane's cwd. * So entries are cut down to the ones inside the cwd and re-spelled relative * to it — a subdirectory lane neither shows nor can name a change outside * itself. A rename whose original path is outside keeps `from: null`. * Shared with git-ops.js so the paths a write may name are the paths shown. */ async function laneStatus(dir) { const [status, prefix] = await Promise.all([ git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]), git(dir, ["rev-parse", "--show-prefix"]), ]); const pre = prefix.stdout.trim(); const inside = (p) => (p && p.startsWith(pre) ? p.slice(pre.length) || null : null); const entries = []; for (const e of parsePorcelainZ(status.stdout)) { const rel = inside(e.path); if (rel) entries.push({ code: e.code, path: rel, from: inside(e.from) }); } return entries; } /** * 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 [entries, numstat] = await Promise.all([ laneStatus(dir), // --relative: cwd-scoped, cwd-relative paths, so they key the status ones. git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => ({ stdout: "" })), ]); const stats = parseNumstat(numstat.stdout); const files = []; for (const { code, path: file } of entries) { 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. // Sequential on purpose: `normal` folds untracked dirs, so this is short. const fresh = untracked ? await 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. */ async function untrackedFacts(dir, file) { try { const abs = await resolveInLane(dir, file); if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES) return { lines: null, binary: true }; const buf = await fs.promises.readFile(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, // or one resolveInLane refuses (a secret, a symlink out of the lane). 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", "-z", "--relative", spec]), git(dir, ["diff", "--numstat", "-z", "--relative", 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", "--relative", sha]; const [nameStatus, numstat] = await Promise.all([ git(dir, [...base, "--name-status", "-z"]), git(dir, [...base, "--numstat", "-z"]), ]); return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout)); } /** Shared `--name-status -z` reader for the ranges that have no working tree. */ function parseNameStatus(stdout, stats) { const files = []; const seen = new Set(); const tokens = stdout.split("\0"); for (let i = 0; i < tokens.length; i++) { const status = tokens[i].replace(/^\n/, ""); if (!/^[A-Z]/.test(status)) continue; const letter = status[0]; // A rename/copy is "R100\0old\0new" — the new path is the live one. const file = letter === "R" || letter === "C" ? tokens[(i += 2)] : tokens[++i]; if (!file) continue; // `-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 }) { // A secret file may be tracked by mistake; its hunks would carry the key — // under its own name, or under a symlinked `.claude`'s target name. if (await isSecretOnDisk(dir, file)) return ""; 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]; return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => ""); } /** * 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. */ async function synthesizeAddedPatch(dir, file) { let buf; try { buf = await fs.promises.readFile(await resolveInLane(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`]; // Through the same lexical guard as every other path: in a subdirectory // lane `../x` would list commit metadata for a path outside the lane. if (file) args.push("--", lanePath(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. A SECRET_PATHS * file is refused (ESECRET) at every rev. */ async function readFileAt(dir, requested, rev = "work") { // Lexical guard FIRST and for every rev: `git show :` reads the // whole repo, so a `../` here is a committed file outside the lane. const file = lanePath(requested); if (rev !== "work" && !SAFE_REV.test(rev)) { throw badRequest("EBADREV", "rev is not a valid revision"); } // By resolved path too, at every rev: the key committed under a symlinked // `.claude`'s target name is still the key. if (await isSecretOnDisk(dir, file)) { throw badRequest("ESECRET", "that file holds secrets and is not readable here"); } let text; let binary = false; if (rev === "work") { // Neither `..` nor a symlink may walk out of the lane: resolveInLane // compares REAL paths, not the string the caller sent. const abs = await resolveInLane(dir, file); let buf; try { // Only cap+1 bytes: a multi-gigabyte log in the lane must not be read // whole to answer with its first megabyte. The extra byte is what // tells the cap below the file was longer. const fh = await fs.promises.open(abs, "r"); try { const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1); buf = Buffer.alloc(size); let off = 0; while (off < size) { const { bytesRead } = await fh.read(buf, off, size - off, off); if (!bytesRead) break; off += bytesRead; } buf = buf.subarray(0, off); } finally { await fh.close(); } } catch { throw badRequest("ENOFILE", "no such file in the working tree"); } binary = looksBinary(buf); text = binary ? "" : buf.toString("utf8"); } else { try { // `./`: resolve from the lane's cwd, not the repo root (a bare // `:` is root-relative) — `file` has no `..`, so it stays in. text = await readCapped(dir, ["show", `${rev}:./${file}`], MAX_FILE_VIEW_BYTES); } catch { throw badRequest("ENOFILE", "no such file at that revision"); } 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 { all, cut } = await lsFiles(dir, []); const needle = String(q || "") .toLowerCase() .trim(); const cap = Math.min(Math.max(Number(limit) || MAX_PATHS, 1), MAX_PATHS); 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: cut || hits.length > cap, total: hits.length }; } /** `git ls-files -z [args]` → paths, and whether a huge listing was cut short. */ async function lsFiles(dir, args) { const out = await readCapped(dir, ["ls-files", "-z", ...args], MAX_LS_CHARS); const all = out.split("\0"); // A complete listing ends in NUL (last token ""); a cut one ends mid-path. const cut = all.pop() !== ""; return { all: all.filter(Boolean), cut }; } /** * 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 { all, cut } = await lsFiles(dir, clean ? ["--", `${clean}/`] : []); const prefix = clean ? `${clean}/` : ""; const dirs = new Set(); const files = []; for (const p of all) { 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: cut || entries.length > MAX_PATHS, }; } module.exports = { collectChanges, laneStatus, listCommits, listPaths, listTree, parseNumstat, parsePorcelainZ, parseUnifiedDiff, readFileAt, resolveBaseRef, DEFAULT_CONTEXT, MAX_CONTEXT, MAX_FILES, MAX_FILE_BYTES, MAX_TOTAL_BYTES, };