Files
AgentHub/server/lib/git-diff.js
T
nntrivi2001 5b3884b309 feat(ui): call lanes "workspaces" in the UI, CLI output and docs
Copy-only rename (workspace redesign, phase 0). The word a user reads
changes — "Lane" → "Workspace", "Add lane" → "Add workspace" — in both
locales, in agenthub CLI prose and help, in the server's error messages
and in the docs. Every identifier stays: /api/lanes, `agenthub lanes`,
--lane, AGENTHUB_LANE_ID, error codes, tmux names, DB tables, file,
component and i18n key names. docs/LANES.md keeps its name, retitled
"Workspaces (lanes)".
2026-10-01 16:03:24 +07:00

993 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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" (`<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ĩ <vinnt@smartgift.vn>
*/
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 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 `<lane>/.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 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 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 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/<base>` 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<path, {added, deleted, binary}>. `-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: `<baseRef>...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 <file>` 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 <rev>:<path>` 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 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");
// 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
// `<rev>:<path>` 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 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, []);
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 };
}
/** 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
* (untracked inside a folded untracked dir included), `dirChanged(path)` is
* whether anything under a folder is.
*/
async function treeStatus(dir) {
const codes = new Map();
const folded = []; // `normal` reports a wholly untracked folder as "dir/"
const dirs = new Set();
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
if (p.endsWith("/")) folded.push(p);
else codes.set(p, code);
const parts = p.replace(/\/$/, "").split("/");
for (let i = 1; i <= parts.length - (p.endsWith("/") ? 0 : 1); i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const inFolded = (p) => folded.some((f) => p.startsWith(f));
return {
fileStatus: (p) => codes.get(p) ?? (inFolded(p) ? "??" : undefined),
dirChanged: (p) => dirs.has(p) || inFolded(`${p}/`),
};
}
/** A folded level's entries, each carrying its status (see `treeStatus`). */
function treeEntries(prefix, dirNames, files, st) {
return [
...[...dirNames].sort().map((name) => {
const p = `${prefix}${name}`;
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`), 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");
const [{ all, cut }, st] = await Promise.all([
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
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 entries = treeEntries(prefix, dirs, files, st);
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 || (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;
const head = raw.slice(0, at + 1);
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
// line that does is a hunk boundary.
const hunk = raw
.slice(at + 1)
.split(/^(?=@@ )/m)
.find((h) => h.slice(0, h.indexOf("\n")) === header);
if (!hunk) return null;
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
return body ? head + (body.endsWith("\n") ? body : `${body}\n`) : null;
}
/**
* 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) {
if (!Number.isInteger(block) || block < 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 && index !== block) {
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 (block > 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) 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) {
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), 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;
for (const [d, { dirs, files }] of byDir) {
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}
return { levels, truncated };
}
module.exports = {
collectChanges,
CONFLICT_CODES,
contentSha,
isSecretOnDisk,
lanePath,
looksBinary,
resolveInLane,
MAX_FILE_VIEW_BYTES,
hunkPatch,
laneStatus,
listCommits,
listPaths,
listTree,
listTreeAll,
parseNumstat,
parsePorcelainZ,
parseUnifiedDiff,
readFileAt,
resolveBaseRef,
DEFAULT_CONTEXT,
MAX_CONTEXT,
MAX_FILES,
MAX_FILE_BYTES,
MAX_TOTAL_BYTES,
};