fix(lanes,git): harden the lane git surface and fix client races in the Changes panel
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js): - Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a file name is a file name, never a glob, and odd names parse correctly. - A lane that is a subdirectory of its repo sees and acts on ONLY its own paths (reads, stage/unstage/discard/commit, and gitFacts.files). - Any file named settings.local.json (the lane's router API key) is refused as 403 ESECRET on disk (symlinks resolved) and at every committed rev. - commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is staged (checked with --no-renames, so a move into the lane is caught). - push resolves its remote (upstream → only remote → origin), else 400 ENOREMOTE; unstaging a rename unstages both halves. - Git writes serialize on the repo's git dir, not the lane id, so two lanes on one index cannot collide on index.lock. - git refusing an action (a numeric exit status) answers 409 EGIT with git's stderr instead of a 500 carrying a numeric code; one shared parseNumstat. Client: - Changes panel: no stale answer from a previous lane or an older quick-open query repaints the panel; discard disarms on lane switch; Enter on a file path opens it instead of folding; bounded tree expand-all. - Large diffs (> 500 rows) render only the visible rows, with stable width and in-sync split columns; mirrored split scroll is clamped. - Git bar: no "in sync" without counts, a clear no-remote message, branch list re-read after a switch, and no previous-lane facts after a lane switch. - Tooltips no longer flicker or close on unrelated scrolls.
This commit is contained in:
+274
-69
@@ -16,13 +16,39 @@
|
||||
* 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 fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { git } = require("./worktree");
|
||||
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;
|
||||
@@ -36,6 +62,8 @@ const MAX_UNTRACKED_BYTES = 1_000_000;
|
||||
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. */
|
||||
@@ -47,6 +75,104 @@ const VALID_RANGES = new Set(["work", "branch", "commit"]);
|
||||
* 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 lane");
|
||||
}
|
||||
// A backslash is an ordinary file-name byte to git on POSIX, not a separator.
|
||||
if (file.startsWith("/") || file.split("/").includes("..")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
}
|
||||
const norm = path.posix
|
||||
.normalize(file)
|
||||
.replace(/^(\.\/)+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
if (!norm || norm === ".") throw badRequest("EBADPATH", "path must name a file");
|
||||
return norm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `file` inside the lane for a CONTENT read: realpath-confined to the
|
||||
* lane's own realpath, so neither `..` nor a symlink can walk out of it, and
|
||||
* refused when either the requested or the resolved path is a secret file.
|
||||
* Throws EBADPATH / ESECRET / ENOFILE.
|
||||
*/
|
||||
async function resolveInLane(dir, file) {
|
||||
if (isSecret(file))
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
const escapes = (base, target) => {
|
||||
const r = path.relative(base, target);
|
||||
return !r || r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r);
|
||||
};
|
||||
// Lexically first, so `../x` is EBADPATH whether or not x exists.
|
||||
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
|
||||
throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
}
|
||||
const root = await fs.promises.realpath(dir);
|
||||
let real;
|
||||
try {
|
||||
real = await fs.promises.realpath(path.resolve(root, file));
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
if (await isSecretOnDisk(root, file))
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
return real;
|
||||
}
|
||||
|
||||
/** Throw a coded error the routes map onto a 400. */
|
||||
function badRequest(code, message) {
|
||||
@@ -131,15 +257,26 @@ async function resolveBaseRef(dir, base) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `git diff --numstat` output → Map<path, {added, deleted, binary}>. */
|
||||
/**
|
||||
* `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();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
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;
|
||||
// A rename reports "old => new" (or "dir/{a => b}/file"); the path that
|
||||
// exists now is what every other source here is keyed by.
|
||||
const file = m[3].includes(" => ") ? renameTarget(m[3]) : m[3];
|
||||
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]),
|
||||
@@ -149,11 +286,48 @@ function parseNumstat(stdout) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** `dir/{old => new}/f.js` or `old.js => new.js` → the post-rename path. */
|
||||
function renameTarget(spec) {
|
||||
const braced = spec.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||
if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/\//g, "/");
|
||||
return spec.split(" => ").pop();
|
||||
/**
|
||||
* `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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,22 +339,20 @@ const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
|
||||
|
||||
/** Working-tree summary: porcelain status, plus numstat deltas for tracked files. */
|
||||
async function summarizeWork(dir) {
|
||||
const [status, numstat] = await Promise.all([
|
||||
git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]),
|
||||
git(dir, ["diff", "--numstat", "HEAD"]).catch(() => ({ stdout: "" })),
|
||||
const [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 line of status.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const code = line.slice(0, 2);
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
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.
|
||||
const fresh = untracked ? untrackedFacts(dir, file) : null;
|
||||
// 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,
|
||||
@@ -208,11 +380,12 @@ async function summarizeWork(dir) {
|
||||
* everywhere else in this response. A file over the cap counts as binary so
|
||||
* nothing downstream tries to render it.
|
||||
*/
|
||||
function untrackedFacts(dir, file) {
|
||||
async function untrackedFacts(dir, file) {
|
||||
try {
|
||||
const abs = path.join(dir, file);
|
||||
if (fs.statSync(abs).size > MAX_UNTRACKED_BYTES) return { lines: null, binary: true };
|
||||
const buf = fs.readFileSync(abs);
|
||||
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 };
|
||||
@@ -220,7 +393,8 @@ function untrackedFacts(dir, file) {
|
||||
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.
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
@@ -229,8 +403,8 @@ function untrackedFacts(dir, file) {
|
||||
async function summarizeBranch(dir, baseRef) {
|
||||
const spec = `${baseRef}...HEAD`;
|
||||
const [nameStatus, numstat] = await Promise.all([
|
||||
git(dir, ["diff", "--name-status", spec]),
|
||||
git(dir, ["diff", "--numstat", spec]),
|
||||
git(dir, ["diff", "--name-status", "-z", "--relative", spec]),
|
||||
git(dir, ["diff", "--numstat", "-z", "--relative", spec]),
|
||||
]);
|
||||
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
|
||||
}
|
||||
@@ -242,24 +416,26 @@ async function summarizeBranch(dir, baseRef) {
|
||||
* a reader scrolling a branch's history.
|
||||
*/
|
||||
async function summarizeCommit(dir, sha) {
|
||||
const base = ["show", "--format=", "-m", "--first-parent", sha];
|
||||
const base = ["show", "--format=", "-m", "--first-parent", "--relative", sha];
|
||||
const [nameStatus, numstat] = await Promise.all([
|
||||
git(dir, [...base, "--name-status"]),
|
||||
git(dir, [...base, "--numstat"]),
|
||||
git(dir, [...base, "--name-status", "-z"]),
|
||||
git(dir, [...base, "--numstat", "-z"]),
|
||||
]);
|
||||
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
|
||||
}
|
||||
|
||||
/** Shared `--name-status` reader for the ranges that have no working tree. */
|
||||
/** Shared `--name-status -z` reader for the ranges that have no working tree. */
|
||||
function parseNameStatus(stdout, stats) {
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
for (const line of stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.split("\t");
|
||||
const letter = parts[0][0];
|
||||
// A rename/copy line is "R100\told\tnew" — the new path is the live one.
|
||||
const file = parts.length > 2 ? parts[2] : parts[1];
|
||||
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;
|
||||
@@ -282,6 +458,9 @@ function parseNameStatus(stdout, stats) {
|
||||
|
||||
/** 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 =
|
||||
@@ -290,10 +469,7 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
: range === "branch"
|
||||
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
||||
: ["diff", u, "HEAD", "--", file];
|
||||
const result = await git(dir, [...args, "--no-color", "--no-ext-diff"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
return result.stdout;
|
||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,10 +478,10 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
* --no-index /dev/null <file>` would print, without the non-zero exit that
|
||||
* wrapper treats as a failure.
|
||||
*/
|
||||
function synthesizeAddedPatch(dir, file) {
|
||||
async function synthesizeAddedPatch(dir, file) {
|
||||
let buf;
|
||||
try {
|
||||
buf = fs.readFileSync(path.join(dir, file));
|
||||
buf = await fs.promises.readFile(await resolveInLane(dir, file));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -440,7 +616,9 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
|
||||
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
|
||||
const args = ["log", `-${n}`, `--format=${format}%x1e`];
|
||||
if (file) args.push("--", file);
|
||||
// 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")) {
|
||||
@@ -471,42 +649,60 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
* `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.
|
||||
* that is the one branch that reads the filesystem directly. A SECRET_PATHS
|
||||
* file is refused (ESECRET) at every rev.
|
||||
*/
|
||||
async function readFileAt(dir, file, rev = "work") {
|
||||
if (typeof file !== "string" || !file || file.startsWith("/") || file.includes("\0")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
}
|
||||
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;
|
||||
if (rev === "work") {
|
||||
const abs = path.resolve(dir, file);
|
||||
// `..` in the request must not walk out of the lane. Compare resolved
|
||||
// paths, not the string the caller sent.
|
||||
const root = path.resolve(dir);
|
||||
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
||||
throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
}
|
||||
// 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 {
|
||||
buf = fs.readFileSync(abs);
|
||||
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
|
||||
// whole to answer with its first megabyte. The extra byte is what
|
||||
// tells the cap below the file was longer.
|
||||
const fh = await fs.promises.open(abs, "r");
|
||||
try {
|
||||
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
|
||||
buf = Buffer.alloc(size);
|
||||
let off = 0;
|
||||
while (off < size) {
|
||||
const { bytesRead } = await fh.read(buf, off, size - off, off);
|
||||
if (!bytesRead) break;
|
||||
off += bytesRead;
|
||||
}
|
||||
buf = buf.subarray(0, off);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
binary = looksBinary(buf);
|
||||
text = binary ? "" : buf.toString("utf8");
|
||||
} else {
|
||||
let out;
|
||||
try {
|
||||
out = await git(dir, ["show", `${rev}:${file}`]);
|
||||
// `./`: 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");
|
||||
}
|
||||
text = out.stdout;
|
||||
binary = text.includes("\0");
|
||||
if (binary) text = "";
|
||||
}
|
||||
@@ -530,12 +726,11 @@ async function readFileAt(dir, file, rev = "work") {
|
||||
* skip-list has to be maintained here.
|
||||
*/
|
||||
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
|
||||
const result = await git(dir, ["ls-files", "-z"]);
|
||||
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 all = result.stdout.split("\0").filter(Boolean);
|
||||
const hits = needle ? all.filter((p) => p.toLowerCase().includes(needle)) : all;
|
||||
// A basename hit beats a directory hit: typing "api" wants `api.ts`, not
|
||||
// every file under `src/api/`.
|
||||
@@ -546,7 +741,16 @@ async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
|
||||
return ab - bb || a.length - b.length || a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
return { paths: hits.slice(0, cap), truncated: hits.length > cap, total: hits.length };
|
||||
return { paths: hits.slice(0, cap), truncated: cut || hits.length > cap, total: hits.length };
|
||||
}
|
||||
|
||||
/** `git ls-files -z [args]` → paths, and whether a huge listing was cut short. */
|
||||
async function lsFiles(dir, args) {
|
||||
const out = await readCapped(dir, ["ls-files", "-z", ...args], MAX_LS_CHARS);
|
||||
const all = out.split("\0");
|
||||
// A complete listing ends in NUL (last token ""); a cut one ends mid-path.
|
||||
const cut = all.pop() !== "";
|
||||
return { all: all.filter(Boolean), cut };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,13 +764,11 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.trim();
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
|
||||
const args = ["ls-files", "-z"];
|
||||
if (clean) args.push("--", `${clean}/`);
|
||||
const result = await git(dir, args);
|
||||
const { all, cut } = await lsFiles(dir, clean ? ["--", `${clean}/`] : []);
|
||||
const prefix = clean ? `${clean}/` : "";
|
||||
const dirs = new Set();
|
||||
const files = [];
|
||||
for (const p of result.stdout.split("\0").filter(Boolean)) {
|
||||
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 });
|
||||
@@ -579,15 +781,18 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
return {
|
||||
path: clean,
|
||||
entries: entries.slice(0, MAX_PATHS),
|
||||
truncated: entries.length > MAX_PATHS,
|
||||
truncated: cut || entries.length > MAX_PATHS,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
laneStatus,
|
||||
listCommits,
|
||||
listPaths,
|
||||
listTree,
|
||||
parseNumstat,
|
||||
parsePorcelainZ,
|
||||
parseUnifiedDiff,
|
||||
readFileAt,
|
||||
resolveBaseRef,
|
||||
|
||||
+86
-18
@@ -9,7 +9,11 @@
|
||||
*
|
||||
* Four rules hold for every action:
|
||||
* - argv only, via worktree.js's `git()` — never a shell string.
|
||||
* - the lane's own working directory is the only cwd.
|
||||
* - the lane's own working directory is the only cwd, and its boundary:
|
||||
* in a lane that is a subdirectory of its repo, paths are cwd-relative
|
||||
* and nothing outside the cwd can be named or swept into a commit.
|
||||
* - every path is a LITERAL file name, never a pathspec glob — `new*.js`
|
||||
* must not also discard new1.js (`literal: true` on every call).
|
||||
* - a caller-supplied path is never trusted as a path: it must be one
|
||||
* `git status` is reporting as changed right now (`resolvePaths`), so no
|
||||
* `..`, no absolute path and no untouched file is reachable.
|
||||
@@ -26,12 +30,15 @@
|
||||
*/
|
||||
|
||||
const {
|
||||
git,
|
||||
git: rawGit,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
} = require("./worktree");
|
||||
const { laneStatus } = require("./git-diff");
|
||||
|
||||
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
|
||||
|
||||
/** How long a fetch/pull/push may run before it is killed. */
|
||||
const NETWORK_TIMEOUT_MS = 60_000;
|
||||
@@ -68,7 +75,9 @@ async function isDirty(dir) {
|
||||
|
||||
/**
|
||||
* The paths a per-file action may name — exactly the ones `git status` is
|
||||
* reporting as changed right now, keyed by their porcelain code.
|
||||
* reporting as changed right now, keyed by path → `{code, from}` (`from` is a
|
||||
* rename's original path). `-z`, so a path git would C-quote (non-ASCII, a
|
||||
* space, a quote) matches the literal path the panel sends.
|
||||
*
|
||||
* This is the whole authorization story for `stage`/`unstage`/`discard`: the
|
||||
* request cannot reach a path git is not already talking about, so no `..`,
|
||||
@@ -76,18 +85,56 @@ async function isDirty(dir) {
|
||||
* that is not in this map is a 400, never a silent no-op.
|
||||
*/
|
||||
async function changedPaths(dir) {
|
||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||
const map = new Map();
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const code = line.slice(0, 2);
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
map.set(file, code);
|
||||
}
|
||||
for (const { code, path, from } of await laneStatus(dir)) map.set(path, { code, from });
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote a push goes to — never a name from the request: the current
|
||||
* branch's configured upstream remote, else the ONLY remote, else `origin`.
|
||||
* Anything else is ambiguous, so ENOREMOTE rather than a guess.
|
||||
*/
|
||||
async function pushRemote(dir) {
|
||||
const remotes = (await git(dir, ["remote"])).stdout.split("\n").filter(Boolean);
|
||||
const branch = (
|
||||
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
|
||||
).stdout.trim();
|
||||
if (branch) {
|
||||
const upstream = (
|
||||
await git(dir, ["config", "--get", `branch.${branch}.remote`]).catch(() => ({ stdout: "" }))
|
||||
).stdout.trim();
|
||||
if (remotes.includes(upstream)) return upstream;
|
||||
}
|
||||
if (remotes.length === 1) return remotes[0];
|
||||
if (remotes.includes("origin")) return "origin";
|
||||
throw bad(
|
||||
"ENOREMOTE",
|
||||
remotes.length
|
||||
? "several remotes and no upstream or origin: set this branch's upstream first"
|
||||
: "this repository has no remote to push to"
|
||||
);
|
||||
}
|
||||
|
||||
/** Throw ESTAGEDOUTSIDE when the index holds a change outside the lane's cwd. */
|
||||
async function assertNothingStagedOutside(dir) {
|
||||
const prefix = (await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim();
|
||||
if (!prefix) return; // the lane IS the repo root: nothing is outside it
|
||||
// No --relative: root-relative names, so outside entries are visible.
|
||||
// --no-renames: a staged move INTO the lane is listed as its new path only
|
||||
// otherwise, and the deletion of the old path outside would slip through.
|
||||
const staged = (await git(dir, ["diff", "--cached", "--name-only", "--no-renames", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const outside = staged.find((p) => !p.startsWith(prefix));
|
||||
if (outside) {
|
||||
throw bad(
|
||||
"ESTAGEDOUTSIDE",
|
||||
`refusing to commit: ${outside} is staged outside this lane's directory`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate `body.paths` against what git reports as changed. */
|
||||
async function resolvePaths(dir, body) {
|
||||
const wanted = Array.isArray(body.paths)
|
||||
@@ -104,7 +151,7 @@ async function resolvePaths(dir, body) {
|
||||
if (unknown.length) {
|
||||
throw bad("EBADPATH", `not a changed path: ${unknown[0]}`);
|
||||
}
|
||||
return wanted.map((p) => ({ path: p, code: changed.get(p) }));
|
||||
return wanted.map((p) => ({ path: p, ...changed.get(p) }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,9 +185,10 @@ async function runGitAction(dir, action, body = {}) {
|
||||
// HEAD, not a branch name from the request: the lane pushes the branch it
|
||||
// is actually on, and -u makes the first push set the upstream the
|
||||
// ahead/behind counts then read.
|
||||
const remote = await pushRemote(dir);
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["push", "-u", "origin", "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
await git(dir, ["push", "-u", remote, "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,7 +207,14 @@ async function runGitAction(dir, action, body = {}) {
|
||||
const args =
|
||||
untracked.length === paths.length && !(await hasHead(dir))
|
||||
? ["rm", "--cached", "-r", "--", ...paths.map((p) => p.path)]
|
||||
: ["restore", "--staged", "--", ...paths.map((p) => p.path)];
|
||||
: // A staged rename's ORIGINAL path rides along, as in `discard`:
|
||||
// restoring only the new one leaves the old path's deletion staged.
|
||||
[
|
||||
"restore",
|
||||
"--staged",
|
||||
"--",
|
||||
...paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])),
|
||||
];
|
||||
return done(action, await git(dir, args));
|
||||
}
|
||||
|
||||
@@ -167,7 +222,11 @@ async function runGitAction(dir, action, body = {}) {
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||
const tracked = paths.filter((p) => p.code !== "??").map((p) => p.path);
|
||||
// A rename's ORIGINAL path rides along: restoring only the new one would
|
||||
// drop it from the index and the tree, and leave the old one deleted.
|
||||
const tracked = paths
|
||||
.filter((p) => p.code !== "??")
|
||||
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
||||
const out = [];
|
||||
// Untracked files are deleted (git has no copy to restore); tracked ones
|
||||
// go back to HEAD in both gutters, so a staged-then-edited file lands in
|
||||
@@ -185,16 +244,25 @@ async function runGitAction(dir, action, body = {}) {
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
if (!message) throw bad("EBADMESSAGE", "commit message is required");
|
||||
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
|
||||
// `git commit` takes the WHOLE index, whatever the cwd, and a pathspec
|
||||
// would switch it to committing the working-tree copy (unstaged edits
|
||||
// included) — so a subdirectory lane refuses rather than sweep another
|
||||
// actor's staged work outside it into this lane's commit.
|
||||
await assertNothingStagedOutside(dir);
|
||||
if (body.only === "staged") {
|
||||
// The panel's per-file staging only means something if a commit can
|
||||
// take the index as it stands — no `add -A` sweeping in the rest.
|
||||
const staged = [...(await changedPaths(dir)).values()].some(
|
||||
(code) => code[0] !== " " && code[0] !== "?"
|
||||
({ code }) => code[0] !== " " && code[0] !== "?"
|
||||
);
|
||||
if (!staged) throw bad("ENOCHANGES", "nothing staged to commit");
|
||||
} else {
|
||||
if (!(await isDirty(dir))) throw bad("ENOCHANGES", "nothing to commit");
|
||||
await git(dir, ["add", "-A"]);
|
||||
// The LANE's status, not the repo's: `add -A -- .` below only takes the
|
||||
// lane's files, so a clean subdirectory lane in a repo dirty elsewhere
|
||||
// has nothing to commit — not a git failure after the fact.
|
||||
if (!(await laneStatus(dir)).length) throw bad("ENOCHANGES", "nothing to commit");
|
||||
// `-- .`: the lane's own directory, not the whole repo it sits in.
|
||||
await git(dir, ["add", "-A", "--", "."]);
|
||||
}
|
||||
// -m takes the message as one argv element, so a message containing
|
||||
// newlines, quotes or a leading dash is content, never syntax.
|
||||
|
||||
+55
-3
@@ -30,7 +30,7 @@ const STATUS_FILE_CAP = 20;
|
||||
* these variables. This module's whole job is to run git safely against repos other
|
||||
* than the one enclosing the current working directory.
|
||||
*/
|
||||
async function git(cwd, args, { timeout = 0 } = {}) {
|
||||
async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false } = {}) {
|
||||
// Build a clean environment: copy process.env but scrub git hook variables
|
||||
// that could point to the outer repo's git directory or index.
|
||||
const env = { ...process.env };
|
||||
@@ -52,12 +52,23 @@ async function git(cwd, args, { timeout = 0 } = {}) {
|
||||
}
|
||||
// Prevent credential prompts from hanging a background provisioning job
|
||||
env.GIT_TERMINAL_PROMPT = "0";
|
||||
// Pathspec behaviour is ours to choose, never the parent's. `literal` makes a
|
||||
// path argument name exactly one file: without it `new*.js` or `app/[id]/`
|
||||
// is a GLOB, and `git clean -- 'new*.js'` deletes every match.
|
||||
delete env.GIT_GLOB_PATHSPECS;
|
||||
delete env.GIT_NOGLOB_PATHSPECS;
|
||||
delete env.GIT_ICASE_PATHSPECS;
|
||||
if (literal) env.GIT_LITERAL_PATHSPECS = "1";
|
||||
else delete env.GIT_LITERAL_PATHSPECS;
|
||||
|
||||
try {
|
||||
const result = await execFileAsync("git", args, {
|
||||
cwd,
|
||||
env,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
// A read with its own size cap passes a bigger buffer and treats an
|
||||
// overflow (err.code ERR_CHILD_PROCESS_STDIO_MAXBUFFER, partial output in
|
||||
// err.git.stdout) as truncated rather than as a failure.
|
||||
maxBuffer,
|
||||
// 0 = no limit, node's own default. A network command (fetch/pull/push)
|
||||
// passes one so an unreachable remote fails instead of pinning a request
|
||||
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
|
||||
@@ -631,6 +642,46 @@ async function statusCounts(dir) {
|
||||
return { dirty, untracked, head, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane card's view of the working copy: only entries inside the lane's own
|
||||
* cwd, cwd-relative and read with `-z` — the same scoping the Changes panel
|
||||
* uses (`laneStatus`), so a subdirectory lane's card never counts or lists a
|
||||
* sibling's files, and a non-ASCII name is not shown C-quoted. `statusCounts`
|
||||
* stays whole-repo on purpose: `switch` and the preflight need to know about
|
||||
* ANY dirt a branch change would carry across.
|
||||
*/
|
||||
async function laneFiles(dir) {
|
||||
// Lazy: git-diff.js requires this module, so a top-level require would cycle.
|
||||
const { laneStatus, parseNumstat } = require("./git-diff");
|
||||
const entries = await laneStatus(dir);
|
||||
let stats = new Map();
|
||||
try {
|
||||
// -z --relative: cwd-scoped, cwd-relative, unquoted — keys the entries.
|
||||
stats = parseNumstat(
|
||||
(await git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"])).stdout
|
||||
);
|
||||
} catch {
|
||||
/* no HEAD to diff against -> no per-file deltas */
|
||||
}
|
||||
let dirty = 0;
|
||||
let untracked = 0;
|
||||
const files = [];
|
||||
for (const e of entries) {
|
||||
if (e.code === "??") untracked++;
|
||||
else dirty++;
|
||||
if (files.length < STATUS_FILE_CAP) {
|
||||
files.push({
|
||||
path: e.path,
|
||||
status: e.code,
|
||||
// Counts only: the card's file record has no `binary` field.
|
||||
added: stats.get(e.path)?.added ?? null,
|
||||
deleted: stats.get(e.path)?.deleted ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { dirty, untracked, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a lane's working copy looks like right now: which branch it is on, the
|
||||
* short HEAD, that commit's subject, and how much is uncommitted.
|
||||
@@ -641,7 +692,8 @@ async function statusCounts(dir) {
|
||||
* caller shows what git says rather than inventing a nicer word for it.
|
||||
*/
|
||||
async function gitFacts(dir) {
|
||||
const { dirty, untracked, head, files } = await statusCounts(dir);
|
||||
const { head } = await statusCounts(dir);
|
||||
const { dirty, untracked, files } = await laneFiles(dir);
|
||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };
|
||||
|
||||
Reference in New Issue
Block a user