/** * @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`). * * The file explorer also serves a workspace folder that is NOT a git repo * (issue #78): `listTree`, `listTreeAll` and `listPaths` then read the disk * (`walkFolder` — confined to the folder's real path, never following a * symlink, capped) instead of `git ls-files`, and the file reads above never * needed git for the working copy. * @author Nguyễn Ngọc Trí Vĩ */ const crypto = require("node:crypto"); 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 AgentHub once wrote a lane's AI * Proxy 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; } /** * S3: whether `abs` is a dot entry at the TOP of a member's home — `.claude`, * `.codex`, `.config/gh`, `.ssh`, `.git-credentials`, `.agenthub/handoff`…: * where that member's logins and pane tokens live. A workspace is a folder * under the home, so this never hides project files; it closes a workspace * rooted AT the home (made before that was refused, or a symlink) to anyone * reading it, analysts included. Not by basename: `auth`/`token` patterns * would hide `AuthGate.tsx` in every member workspace. */ function inMemberDotDir(abs) { const runAs = require("./run-as"); const acct = require("./linux-accounts").s3Enabled() ? runAs.ownerForRead(abs) : null; if (!acct) return false; const rel = path.relative(acct.home, abs); return Boolean(rel) && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("."); } /** * The real path of a workspace folder for a filesystem read or write (S3: * refused, EFOREIGN, when it resolves into another account's files). The * account a read or write runs as comes from the PATH (run-as.js), so a * member who swaps their workspace folder for a link to another member's * folder, the host's home or /etc must not have the dashboard follow it. */ async function laneRoot(dir) { const root = await fs.promises.realpath(dir); if (!require("./run-as").sameOwner(path.resolve(dir), root)) { throw badRequest("EFOREIGN", "the workspace folder resolves outside its owner's files"); } return root; } /** * 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) || inMemberDotDir(path.resolve(dir, 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)) || inMemberDotDir(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 workspace"); } // 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 workspace"); } 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 workspace directory"); } const root = await laneRoot(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 workspace 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; } /** * The repo's default branch, for a lane that recorded no base of its own (an * adopted folder, a session's `claude --worktree` tree): what `origin/HEAD` * points at, else `main` or `master` when one exists, else null. */ async function defaultBranch(dir) { const head = await git(dir, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]).then( (r) => r.stdout.trim(), () => "" ); if (head.startsWith("origin/")) return head.slice("origin/".length); for (const name of ["main", "master"]) { if (await resolveBaseRef(dir, name)) return name; } 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. * `--untracked-files=all` (#64): `normal` folds a wholly untracked folder * into one "dir/" entry, which the panel can neither open nor diff — every * new file is listed instead, as JetBrains and VS Code do. */ async function laneStatus(dir) { const [status, prefix] = await Promise.all([ git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), 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(() => null), ]); const stats = parseNumstat(numstat?.stdout ?? ""); const files = []; for (const { code, path: file } of entries) { const untracked = code === "??"; const stat = stats.get(file); // `MM` with no line against HEAD: a staged change the working tree has // reverted — nothing to commit from the file on disk. JetBrains' Changes // view drops it the same way (#72). Only when the numstat answered. if (code === "MM" && numstat && !stat) continue; // 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 require("./run-as").readFileSafeAsync(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 require("./run-as").readFileSafeAsync(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"`; * none falls back to the repo's default branch (`defaultBranch`). * @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, mergeBase, 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; // The commit a branch diff is taken from (`...HEAD`): the editor // reads a file's original text there. let mergeBase = null; if (range === "branch") { base = base || (await defaultBranch(dir)); baseRef = await resolveBaseRef(dir, base); if (!baseRef) { throw badRequest("ENOBASE", "no base branch to compare against"); } mergeBase = await git(dir, ["merge-base", baseRef, "HEAD"]) .then((r) => r.stdout.trim() || null) .catch(() => null); } 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, mergeBase, 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. * @param {string|null} [opts.ref] Another branch's history instead of HEAD's * (where cherry-pick finds its commits): a short branch name, looked up * with `branchRef` and ENOBRANCH when it is not one. */ async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) { const n = Math.min(Math.max(Number(limit) || 30, 1), 200); const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f"); // --date-order: no parent before all its children, which the history's // commit graph needs to draw a lane down to each parent. const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`]; if (ref) { const full = await worktree.branchRef(dir, ref); if (!full) throw badRequest("ENOBRANCH", `no branch ${ref}`); args.push(full); } // Through the same lexical guard as every other path: in a subdirectory // lane `../x` would list commit metadata for a path outside the lane. // `--parents` makes git REWRITE each parent to the file's own previous // commit, so a file's history draws one joined-up line, not loose ends. if (file) args.push("--parents", "--", 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, parents] = 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), parents: (parents || "").split(" ").filter(Boolean), }); } return commits; } /** * Annotate (JetBrains' blame gutter): who last changed each line of the * working-tree file. `git blame --porcelain` on the file on disk, so a line * nobody committed yet is `null`. Same guards as a whole-file read: the * lexical lane guard, a secret refused (ESECRET), and the file's size capped. * Answers `{commits: {sha: {short, author, date, summary}}, lines: [sha|null]}`. */ async function blameFile(dir, requested) { const file = lanePath(requested); if (await isSecretOnDisk(dir, file)) { throw badRequest("ESECRET", "that file holds secrets and is not readable here"); } const st = await fs.promises.stat(path.join(dir, file)).catch(() => null); if (!st?.isFile()) throw badRequest("ENOFILE", "no such file"); if (st.size > MAX_FILE_VIEW_BYTES) throw badRequest("ETOOBIG", "file too large to annotate"); const out = ( await git(dir, ["blame", "--porcelain", "--", file], { maxBuffer: 64 * 1024 * 1024 }) ).stdout; const commits = {}; const lines = []; let sha = null; for (const line of out.split("\n")) { const head = /^([0-9a-f]{40,64}) \d+ \d+/.exec(line); if (head) { sha = head[1]; if (!/^0+$/.test(sha)) commits[sha] ??= { short: sha.slice(0, 7) }; continue; } if (line.startsWith("\t")) { lines.push(/^0+$/.test(sha) ? null : sha); continue; } const c = commits[sha]; if (!c) continue; const sp = line.indexOf(" "); const key = line.slice(0, sp); const val = line.slice(sp + 1); if (key === "author") c.author = val; else if (key === "author-time") c.date = new Date(Number(val) * 1000).toISOString(); else if (key === "summary") c.summary = val; } return { path: file, commits, lines }; } /** * 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; let sha; 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 require("./run-as").openSafeAsync(abs); 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"); // What an editor must send back to save over THIS content and nothing // newer (lane-file-write.js). Only for a whole, textual file. if (!binary && buf.length <= MAX_FILE_VIEW_BYTES) sha = contentSha(buf); } 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, ...(sha ? { sha } : {}) }; } /** The image types the viewer shows, by extension. Raster and SVG only. */ const IMAGE_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", bmp: "image/bmp", ico: "image/x-icon", svg: "image/svg+xml", }; const MAX_IMAGE_BYTES = 20 * 1024 * 1024; /** * One image's bytes at a revision, for the viewer's ``: `readFileAt`'s * guards (lexical path, safe rev, secret, realpath-in-lane for `work`), plus * an extension allowlist that also picks the Content-Type and a size cap. */ async function readImageAt(dir, requested, rev = "work") { const file = lanePath(requested); const type = IMAGE_TYPES[path.extname(file).slice(1).toLowerCase()]; if (!type) throw badRequest("ENOTIMAGE", "not an image file"); if (rev !== "work" && !SAFE_REV.test(rev)) { throw badRequest("EBADREV", "rev is not a valid revision"); } if (await isSecretOnDisk(dir, file)) { throw badRequest("ESECRET", "that file holds secrets and is not readable here"); } const tooBig = () => badRequest("ETOOBIG", "image is larger than 20 MB"); if (rev === "work") { const abs = await resolveInLane(dir, file); let stat; try { stat = await fs.promises.stat(abs); } catch { throw badRequest("ENOFILE", "no such file in the working tree"); } if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree"); if (stat.size > MAX_IMAGE_BYTES) throw tooBig(); return { type, buf: await require("./run-as").readFileSafeAsync(abs) }; } try { const { stdout } = await git(dir, ["show", `${rev}:./${file}`], { encoding: "buffer", maxBuffer: MAX_IMAGE_BYTES, }); return { type, buf: stdout }; } catch (err) { if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw tooBig(); throw badRequest("ENOFILE", "no such file at that revision"); } } /** The version stamp of a file's bytes, for a stale-save check. */ function contentSha(buf) { return crypto.createHash("sha1").update(buf).digest("hex"); } /** * 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, []).catch(async (err) => { if (await inRepo(dir)) throw err; const walk = await walkFolder(dir); return { all: walk.files, cut: walk.cut }; }); 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() !== ""; // Mid-merge, `ls-files --cached` prints a conflicted path once per stage // (base, ours, theirs): one path is one file. return { all: [...new Set(all.filter(Boolean))], cut }; } /** Whether `dir` is inside a git work tree (false for a plain folder). */ async function inRepo(dir) { try { return (await git(dir, ["rev-parse", "--is-inside-work-tree"])).stdout.trim() === "true"; } catch { return false; } } /** Most entries one `walkFolder` visits before it stops and reports `cut`. */ const MAX_WALK = 20_000; /** Folders a walk lists but never enters (a repo's tree shows them as ignored). */ const WALK_SHUT = new Set(["node_modules"]); /** * A plain folder's files and folders under `sub`, read off the disk, for a * workspace that is not a git repo: `depth` levels down (1 = `sub`'s own * entries), `.git` never listed, a `WALK_SHUT` folder listed but not entered, * a symlink listed as a file and never followed, the start confined to the * workspace's real path like `addIgnored`. Paths are workspace-relative. */ async function walkFolder(dir, sub = "", depth = Infinity) { const root = await laneRoot(dir); const start = await fs.promises.realpath(path.join(root, sub)); const r = path.relative(root, start); if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) { throw badRequest("EBADPATH", "path escapes the workspace"); } const files = []; const dirs = []; const shut = new Set(); let seen = 0; let cut = false; const queue = [[sub, 1]]; while (queue.length && !cut) { const [rel, level] = queue.shift(); let dirents; try { dirents = await fs.promises.readdir(path.join(root, rel), { withFileTypes: true }); } catch { continue; } for (const d of dirents) { if (d.name === ".git") continue; if (++seen > MAX_WALK) { cut = true; break; } const p = rel ? `${rel}/${d.name}` : d.name; if (!d.isDirectory()) files.push(p); else { dirs.push(p); if (WALK_SHUT.has(d.name)) shut.add(p); else if (level < depth) queue.push([p, level + 1]); } } } return { files, dirs, shut, cut }; } /** One explorer level of a plain folder (`listTree` off the disk). */ async function folderLevel(dir, sub) { const { files, dirs, shut, cut } = await walkFolder(dir, sub, 1); const prefix = sub ? `${sub}/` : ""; const name = (p) => p.slice(prefix.length); const entries = [ ...dirs .map((p) => ({ name: name(p), path: p, dir: true, ...(shut.has(p) ? { ignored: true } : {}), })) .sort((a, b) => a.name.localeCompare(b.name)), ...files .map((p) => ({ name: name(p), path: p, dir: false })) .sort((a, b) => a.name.localeCompare(b.name)), ]; return { entries: entries.slice(0, MAX_PATHS), truncated: cut || entries.length > MAX_PATHS }; } /** What the explorer lists: tracked files plus untracked ones git does not * ignore — the new file an agent just wrote is the one you want to open. */ const TREE_LS = ["--cached", "--others", "--exclude-standard"]; /** * A lane's working-copy status for decorating the explorer, from ONE * `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file, * `dirChanged(path)` is whether anything under a folder is. */ async function treeStatus(dir) { const codes = new Map(); const dirs = new Set(); for (const { code, path: p } of await laneStatus(dir).catch(() => [])) { codes.set(p, code); const parts = p.split("/"); for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/")); } return { fileStatus: (p) => codes.get(p), dirChanged: (p) => dirs.has(p), }; } /** * Adds to one level what git does not list there — ignored files and folders * (and an empty folder, which git never lists either) — marked `ignored`, * read off the disk because `ls-files --ignored` cannot list INSIDE an * ignored folder. Returns the folder names it added. */ async function addIgnored(dir, sub, dirNames, files) { const added = new Set(); let dirents; try { const root = await laneRoot(dir); const real = await fs.promises.realpath(path.join(root, sub)); const r = path.relative(root, real); // A symlinked folder pointing out of the workspace lists nothing. if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) return added; dirents = await fs.promises.readdir(real, { withFileTypes: true }); } catch { return added; } const prefix = sub ? `${sub}/` : ""; const listed = new Set(files.map((f) => f.name)); for (const d of dirents) { if (d.name === ".git" || dirNames.has(d.name) || listed.has(d.name)) continue; if (d.isDirectory()) { dirNames.add(d.name); added.add(d.name); } else { files.push({ name: d.name, path: `${prefix}${d.name}`, dir: false, ignored: true }); } } return added; } /** A folded level's entries, each carrying its status (see `treeStatus`). */ function treeEntries(prefix, dirNames, files, st, ignoredDirs = new Set()) { return [ ...[...dirNames].sort().map((name) => { const p = `${prefix}${name}`; if (ignoredDirs.has(name)) return { name, path: p, dir: true, ignored: true }; return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) }; }), ...files .sort((a, b) => a.name.localeCompare(b.name)) .map((f) => { const status = st.fileStatus(f.path); return status ? { ...f, status } : f; }), ]; } /** * One directory level of the working tree, for the file explorer: tracked * files plus untracked-but-not-ignored ones (`TREE_LS`) and, marked * `ignored`, what git ignores there (`addIgnored`), 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. A changed file * carries its porcelain `status`, a folder holding one `changed: true`. */ async function listTree(dir, { subdir = "" } = {}) { const clean = String(subdir || "") .replace(/^\/+|\/+$/g, "") .trim(); if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the workspace"); let listed; try { listed = await lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS); } catch (err) { if (await inRepo(dir)) throw err; return { path: clean, ...(await folderLevel(dir, clean)) }; } const { all, cut } = listed; const st = await treeStatus(dir); 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 ignored = await addIgnored(dir, clean, dirs, files); const entries = treeEntries(prefix, dirs, files, st, ignored); return { path: clean, entries: entries.slice(0, MAX_PATHS), truncated: cut || entries.length > MAX_PATHS, }; } /** * ONE hunk of a tracked file's working-copy diff (`git diff HEAD`, the same * read the Changes panel shows), as a standalone patch `git apply` accepts: * the file's own header lines plus that hunk. The hunk is named by its `@@` * line and matched against a FRESH diff at the caller's context, so what gets * applied is always git's own text of the current tree — never a patch a * request carried. Null when no hunk matches (the tree moved on, or the file * is a secret, binary or too large to read whole). * * `block` narrows it to one CHANGE BLOCK of that hunk — a run of consecutive * +/- lines. git merges changes up to 2×context lines apart into one hunk, so * without this a single Undo/Keep acted on every nearby edit at once (#50). * `reverse` says which side the patch will be applied to (see `onlyBlock`). */ async function hunkPatch(dir, file, header, context, { block, reverse = false } = {}) { if (!header) return null; const diff = await diffHunks(dir, file, context); const hunk = diff?.hunks.find((h) => h.slice(0, h.indexOf("\n")) === header); if (!hunk) return null; const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse); return body ? diff.head + (body.endsWith("\n") ? body : `${body}\n`) : null; } /** A tracked file's fresh `git diff HEAD` at `context`: its header lines and * its hunks' texts, in order. Null for a secret, binary or too-large file. */ async function diffHunks(dir, file, context) { if (await isSecretOnDisk(dir, file)) return null; const u = `-U${normalizeContext(context)}`; const raw = await readCapped( dir, ["diff", u, "HEAD", "--no-color", "--no-ext-diff", "--", file], MAX_FILE_BYTES ).catch(() => ""); // A capped read may end mid-hunk; applying half a hunk is worse than none. if (!raw || raw.length >= MAX_FILE_BYTES) return null; const at = raw.indexOf("\n@@ "); if (at < 0) return null; // A content line starts with " ", "+", "-" or "\\" — never "@@" — so every // line that does is a hunk boundary. return { head: raw.slice(0, at + 1), hunks: raw.slice(at + 1).split(/^(?=@@ )/m) }; } /** * The hunk-level commit's patch for one file (JetBrains' partial commit): * the file's header plus each picked hunk cut down to its picked `blocks`, * in file order, to be applied FORWARD onto an index holding HEAD. Every * header is matched against a fresh diff like `hunkPatch`'s; null when one is * gone or names a block the hunk lacks. Each hunk's new-side start is left as * git wrote it — `git apply` locates a hunk by its old side. */ async function selectionPatch(dir, file, context, picks) { const diff = await diffHunks(dir, file, context); if (!diff || !picks.length) return null; const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header); if (picks.some((p) => at(p.hunk) < 0)) return null; const bodies = []; for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) { const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false); if (!body) return null; bodies.push(body); } return diff.head + bodies.join(""); } /** * One hunk's text cut down to its `block`-th change block, the way an edited * `git add -p` hunk is: every OTHER block is folded into the side the patch * will be checked against, so that side stays byte-for-byte the hunk's own and * `git apply` finds it exactly where git put it. * * - forward (stage, applied to the index = the OLD side): another block's * "-" lines become context and its "+" lines are dropped; * - reverse (undo, `apply -R` on the working tree = the NEW side): its "+" * lines become context and its "-" lines are dropped. * * A "\ No newline" marker follows the line it annotates, kept or dropped with * it. The header's counts are recomputed; its starts do not move, because the * checked side is unchanged. Null when the hunk has no such block. */ function onlyBlock(hunk, block, reverse) { // One block, or several (the hunk-level commit keeps every ticked one). const keep = Array.isArray(block) ? block : [block]; if (!keep.length || keep.some((b) => !Number.isInteger(b) || b < 0)) return null; const [header, ...lines] = hunk.replace(/\n$/, "").split("\n"); const m = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/); if (!m) return null; const out = []; let index = -1; let inBlock = false; let keptPrev = false; let oldCount = 0; let newCount = 0; for (const line of lines) { const mark = line[0]; if (mark === "\\") { if (keptPrev) out.push(line); continue; } const change = mark === "+" || mark === "-"; if (change && !inBlock) index += 1; inBlock = change; let kept = line; if (change && !keep.includes(index)) { const fold = reverse ? "+" : "-"; kept = mark === fold ? ` ${line.slice(1)}` : null; } keptPrev = kept !== null; if (kept === null) continue; out.push(kept); if (kept[0] !== "+") oldCount += 1; if (kept[0] !== "-") newCount += 1; } if (Math.max(...keep) > index) return null; return [`@@ -${m[1]},${oldCount} +${m[2]},${newCount} @@${m[3]}`, ...out].join("\n") + "\n"; } /** * Every level of the explorer tree (tracked + untracked-not-ignored, plus each level's ignored entries) from ONE `git ls-files`, keyed by directory * path ("" is the root), each level shaped and capped exactly like * `listTree`'s. "Expand all folders" used to be one request — one ls-files — * per folder, which on a large repo is thousands of git processes. */ async function listTreeAll(dir) { let listed; try { listed = await lsFiles(dir, TREE_LS); } catch (err) { if (await inRepo(dir)) throw err; return plainTreeAll(dir); } const { all, cut } = listed; const st = await treeStatus(dir); const byDir = new Map(); const level = (d) => { let l = byDir.get(d); if (!l) byDir.set(d, (l = { dirs: new Set(), files: [] })); return l; }; level(""); for (const p of all) { const parts = p.split("/"); for (let i = 0; i < parts.length - 1; i++) { level(parts.slice(0, i).join("/")).dirs.add(parts[i]); } const parent = parts.slice(0, -1).join("/"); level(parent).files.push({ name: parts[parts.length - 1], path: p, dir: false }); } const levels = {}; let truncated = cut; // An ignored folder gets no level of its own: it stays shut (node_modules). const ignored = new Map( await Promise.all( [...byDir].map(async ([d, l]) => [d, await addIgnored(dir, d, l.dirs, l.files)]) ) ); for (const [d, { dirs, files }] of byDir) { const entries = treeEntries(d ? `${d}/` : "", dirs, files, st, ignored.get(d)); if (entries.length > MAX_PATHS) truncated = true; levels[d] = entries.slice(0, MAX_PATHS); } return { levels, truncated }; } /** `listTreeAll` for a plain folder: one capped walk, every level from it. */ async function plainTreeAll(dir) { const { files, dirs, shut, cut } = await walkFolder(dir); const levels = { "": [] }; const parentOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : ""); const nameOf = (p) => p.slice(p.lastIndexOf("/") + 1); // A shut folder (node_modules) gets no level of its own: it stays closed. for (const p of dirs) if (!shut.has(p)) levels[p] = []; for (const p of dirs) { levels[parentOf(p)]?.push({ name: nameOf(p), path: p, dir: true, ...(shut.has(p) ? { ignored: true } : {}), }); } for (const p of files) levels[parentOf(p)]?.push({ name: nameOf(p), path: p, dir: false }); let truncated = cut; for (const [d, entries] of Object.entries(levels)) { entries.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name)); if (entries.length > MAX_PATHS) truncated = true; levels[d] = entries.slice(0, MAX_PATHS); } return { levels, truncated }; } module.exports = { collectChanges, CONFLICT_CODES, contentSha, inRepo, isSecretOnDisk, lanePath, laneRoot, looksBinary, resolveInLane, MAX_FILE_VIEW_BYTES, hunkPatch, selectionPatch, blameFile, laneStatus, listCommits, listPaths, listTree, listTreeAll, parseNumstat, parsePorcelainZ, parseUnifiedDiff, readFileAt, readImageAt, resolveBaseRef, defaultBranch, DEFAULT_CONTEXT, MAX_CONTEXT, MAX_FILES, MAX_FILE_BYTES, MAX_TOTAL_BYTES, };