cb19d7e5d2
Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):
- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
/:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
(git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
the session menu, pinned first); text size setting (lib/fontSize.ts,
Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().
Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1368 lines
52 KiB
JavaScript
1368 lines
52 KiB
JavaScript
/**
|
||
* @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`).
|
||
*
|
||
* 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ĩ <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 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 `<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) || 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/<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;
|
||
}
|
||
|
||
/**
|
||
* 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<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.
|
||
* `--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: `<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 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 (`<baseRef>...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 <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 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
|
||
// `<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 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 `<img>`: `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,
|
||
};
|