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:
@@ -0,0 +1,639 @@
|
||||
/**
|
||||
* @file Regression tests for the lane git surface's hardening: the file viewer
|
||||
* and untracked-file diff never read a secret file or follow a symlink out of
|
||||
* the lane, `git status` paths survive C-quoting (non-ASCII, spaces, quotes),
|
||||
* discarding a staged rename restores the original path, and untracked reads
|
||||
* are async (no fs.*Sync on the poll path). Real git fixture, like
|
||||
* git-diff-ops.test.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { collectChanges, parseNumstat, readFileAt } = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
let ROOT;
|
||||
let LANE;
|
||||
|
||||
const g = (cwd, ...args) => {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", env });
|
||||
};
|
||||
|
||||
function write(dir, file, body) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), body);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-githarden-"));
|
||||
LANE = path.join(ROOT, "lane");
|
||||
g(ROOT, "init", "-q", "-b", "main", LANE);
|
||||
g(LANE, "config", "user.email", "t@h");
|
||||
g(LANE, "config", "user.name", "t");
|
||||
write(LANE, "a.txt", "one\ntwo\n");
|
||||
write(LANE, "old name.txt", "keep me\nplease\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "init");
|
||||
write(ROOT, "outside.txt", "TOP SECRET OUTSIDE\n");
|
||||
});
|
||||
|
||||
afterEach(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
describe("readFileAt: secret files and symlink escapes", () => {
|
||||
it("refuses .claude/settings.local.json at rev=work", async () => {
|
||||
write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}');
|
||||
for (const p of [".claude/settings.local.json", "./.claude//settings.local.json"]) {
|
||||
await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses the secret file at a committed revision too", async () => {
|
||||
write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}');
|
||||
g(LANE, "add", "-f", ".claude/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "oops");
|
||||
await assert.rejects(readFileAt(LANE, ".claude/settings.local.json", "HEAD"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a symlink whose target is outside the lane", async () => {
|
||||
fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "link.txt"));
|
||||
await assert.rejects(readFileAt(LANE, "link.txt", "work"), { code: "EBADPATH" });
|
||||
});
|
||||
|
||||
it("refuses a symlink that points at the secret file", async () => {
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}');
|
||||
fs.symlinkSync(".claude/settings.local.json", path.join(LANE, "innocent.json"));
|
||||
await assert.rejects(readFileAt(LANE, "innocent.json", "work"), { code: "ESECRET" });
|
||||
});
|
||||
|
||||
it("still reads a symlink that stays inside the lane", async () => {
|
||||
fs.symlinkSync("a.txt", path.join(LANE, "alias.txt"));
|
||||
const out = await readFileAt(LANE, "alias.txt", "work");
|
||||
assert.deepEqual(out.lines, ["one", "two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectChanges: untracked content reads", () => {
|
||||
it("never shows the content of an untracked symlink pointing outside the lane", async () => {
|
||||
fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "escape.txt"));
|
||||
const out = await collectChanges(LANE, { range: "work", full: true });
|
||||
const entry = out.files.find((f) => f.path === "escape.txt");
|
||||
assert.ok(entry, "the symlink is still listed");
|
||||
assert.equal(JSON.stringify(entry).includes("TOP SECRET"), false);
|
||||
});
|
||||
|
||||
it("never shows the secret file's content in a diff", async () => {
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-old"}');
|
||||
g(LANE, "add", "-f", ".claude/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "tracked by mistake");
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-leak"}');
|
||||
const out = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(out).includes("sk-leak"), false);
|
||||
});
|
||||
|
||||
it("does not use synchronous fs reads", async () => {
|
||||
write(LANE, "new.txt", "x\ny\n");
|
||||
const saved = { readFileSync: fs.readFileSync, statSync: fs.statSync };
|
||||
fs.readFileSync = () => {
|
||||
throw new Error("sync read");
|
||||
};
|
||||
fs.statSync = () => {
|
||||
throw new Error("sync stat");
|
||||
};
|
||||
let out;
|
||||
try {
|
||||
out = await collectChanges(LANE, { range: "work", full: true });
|
||||
} finally {
|
||||
Object.assign(fs, saved);
|
||||
}
|
||||
const entry = out.files.find((f) => f.path === "new.txt");
|
||||
assert.equal(entry.added, 2);
|
||||
assert.equal(entry.hunks[0].lines.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("C-quoted status paths", () => {
|
||||
const NAME = 'tài liệu "mới".txt';
|
||||
|
||||
it("collectChanges reports a non-ASCII/quoted path verbatim", async () => {
|
||||
write(LANE, NAME, "xin chào\n");
|
||||
write(LANE, "a.txt", "one\ntwo\nthree\n");
|
||||
g(LANE, "mv", "old name.txt", "mới tên.txt");
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
const paths = out.files.map((f) => f.path);
|
||||
assert.ok(paths.includes(NAME), paths.join("|"));
|
||||
assert.ok(paths.includes("mới tên.txt"), paths.join("|"));
|
||||
assert.equal(out.files.find((f) => f.path === NAME).added, 1);
|
||||
assert.equal(out.files.find((f) => f.path === "a.txt").added, 1);
|
||||
});
|
||||
|
||||
it("stage accepts a non-ASCII/quoted path", async () => {
|
||||
write(LANE, NAME, "xin chào\n");
|
||||
await runGitAction(LANE, "stage", { paths: [NAME] });
|
||||
assert.match(g(LANE, "diff", "--cached", "--name-only", "-z"), /mới/);
|
||||
});
|
||||
|
||||
it("branch range and commit range report renamed non-ASCII paths", async () => {
|
||||
g(LANE, "checkout", "-qb", "feat");
|
||||
g(LANE, "mv", "old name.txt", "mới tên.txt");
|
||||
g(LANE, "commit", "-qm", "rename");
|
||||
const branch = await collectChanges(LANE, { range: "branch", base: "main" });
|
||||
assert.deepEqual(
|
||||
branch.files.map((f) => f.path),
|
||||
["mới tên.txt"]
|
||||
);
|
||||
const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD" });
|
||||
assert.deepEqual(
|
||||
commit.files.map((f) => f.path),
|
||||
["mới tên.txt"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discard of a staged rename", () => {
|
||||
it("restores the original path and removes the new one", async () => {
|
||||
g(LANE, "mv", "old name.txt", "new name.txt");
|
||||
await runGitAction(LANE, "discard", { paths: ["new name.txt"], confirm: true });
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "old name.txt"), "utf8"), "keep me\nplease\n");
|
||||
assert.equal(fs.existsSync(path.join(LANE, "new name.txt")), false);
|
||||
assert.equal(g(LANE, "status", "--porcelain"), "");
|
||||
});
|
||||
|
||||
it("still requires confirm: true", async () => {
|
||||
g(LANE, "mv", "old name.txt", "new name.txt");
|
||||
await assert.rejects(runGitAction(LANE, "discard", { paths: ["new name.txt"] }), {
|
||||
code: "ECONFIRM",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
|
||||
it("answers 403 for the router's settings file", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
const { createApp, startServer } = require("../index");
|
||||
const server = await startServer(createApp(), 0);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}');
|
||||
const created = await fetch(`${base}/api/lanes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cwd: LANE, title: "secret" }),
|
||||
}).then((r) => r.json());
|
||||
const id = created.lane.id;
|
||||
const r = await fetch(
|
||||
`${base}/api/lanes/${id}/git/file?path=.claude/settings.local.json&rev=work`
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
const body = await r.json();
|
||||
assert.equal(body.error.code, "ESECRET");
|
||||
assert.equal(JSON.stringify(body).includes("sk-x"), false);
|
||||
await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("unstage of a staged rename", () => {
|
||||
it("unstages both halves: nothing staged, the new file left untracked, the old one deleted in the tree only", async () => {
|
||||
g(LANE, "mv", "old name.txt", "new name.txt");
|
||||
await runGitAction(LANE, "unstage", { paths: ["new name.txt"] });
|
||||
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "");
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "new name.txt"), "utf8"), "keep me\nplease\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFileAt: one lexical guard for every rev", () => {
|
||||
let SUB;
|
||||
beforeEach(() => {
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-parent"}');
|
||||
write(LANE, "root-only.txt", "ROOT CONTENT\n");
|
||||
write(LANE, "pkg/in.txt", "inside\n");
|
||||
write(LANE, "pkg/.claude/settings.local.json", '{"k":"sk-sub"}');
|
||||
g(LANE, "add", "-f", "-A");
|
||||
g(LANE, "commit", "-qm", "sub");
|
||||
SUB = path.join(LANE, "pkg");
|
||||
});
|
||||
|
||||
it("refuses ../ and absolute paths at a committed rev in a subdirectory lane", async () => {
|
||||
for (const p of [
|
||||
"../.claude/settings.local.json",
|
||||
"./../root-only.txt",
|
||||
"x/../../root-only.txt",
|
||||
"/etc/passwd",
|
||||
]) {
|
||||
await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "EBADPATH" }, p);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads committed paths relative to the lane, not the repo root", async () => {
|
||||
const out = await readFileAt(SUB, "./in.txt", "HEAD");
|
||||
assert.deepEqual(out.lines, ["inside"]);
|
||||
assert.equal(out.path, "in.txt");
|
||||
// A repo-root path is NOT reachable by naming it without ../
|
||||
await assert.rejects(readFileAt(SUB, "root-only.txt", "HEAD"), { code: "ENOFILE" });
|
||||
});
|
||||
|
||||
it("refuses the lane's own secret file in its normalised spellings", async () => {
|
||||
for (const p of ["./.claude/settings.local.json", ".claude//settings.local.json"]) {
|
||||
await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "ESECRET" }, p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths are literal, never pathspec patterns", () => {
|
||||
it("discarding an untracked 'new*.js' deletes only that file", async () => {
|
||||
for (const f of ["new*.js", "new1.js", "new2.js"]) write(LANE, f, "x\n");
|
||||
await runGitAction(LANE, "discard", { paths: ["new*.js"], confirm: true });
|
||||
assert.equal(fs.existsSync(path.join(LANE, "new*.js")), false);
|
||||
assert.equal(fs.existsSync(path.join(LANE, "new1.js")), true);
|
||||
assert.equal(fs.existsSync(path.join(LANE, "new2.js")), true);
|
||||
});
|
||||
|
||||
it("staging 'app/[id]/page.js' does not stage app/i/page.js", async () => {
|
||||
write(LANE, "app/[id]/page.js", "a\n");
|
||||
write(LANE, "app/i/page.js", "b\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "app");
|
||||
write(LANE, "app/[id]/page.js", "a2\n");
|
||||
write(LANE, "app/i/page.js", "b2\n");
|
||||
await runGitAction(LANE, "stage", { paths: ["app/[id]/page.js"] });
|
||||
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "app/[id]/page.js\n");
|
||||
});
|
||||
|
||||
it("a single-file diff of 'a[b].txt' carries only that file's hunks", async () => {
|
||||
write(LANE, "a[b].txt", "one\n");
|
||||
write(LANE, "ab.txt", "one\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "brackets");
|
||||
write(LANE, "a[b].txt", "one\nmine\n");
|
||||
write(LANE, "ab.txt", "one\nOTHER\n");
|
||||
const out = await collectChanges(LANE, { range: "work", file: "a[b].txt" });
|
||||
assert.equal(JSON.stringify(out.files[0].hunks).includes("OTHER"), false);
|
||||
assert.equal(out.files[0].hunks.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a lane whose cwd is a subdirectory of its repo", () => {
|
||||
let SUB;
|
||||
beforeEach(() => {
|
||||
write(LANE, "pkg/in.txt", "one\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "pkg");
|
||||
SUB = path.join(LANE, "pkg");
|
||||
write(LANE, "a.txt", "one\ntwo\nOUTSIDE EDIT\n");
|
||||
write(LANE, "outside-new.txt", "no\n");
|
||||
write(SUB, "in.txt", "one\ntwo\n");
|
||||
write(SUB, "fresh.txt", "a\nb\nc\n");
|
||||
});
|
||||
|
||||
it("lists only its own changes, lane-relative, with hunks", async () => {
|
||||
const out = await collectChanges(SUB, { range: "work", full: true });
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => f.path),
|
||||
["fresh.txt", "in.txt"]
|
||||
);
|
||||
const tracked = out.files.find((f) => f.path === "in.txt");
|
||||
assert.equal(tracked.added, 1);
|
||||
assert.equal(tracked.hunks.length, 1);
|
||||
const fresh = out.files.find((f) => f.path === "fresh.txt");
|
||||
assert.equal(fresh.added, 3);
|
||||
assert.equal(fresh.hunks[0].lines.length, 3);
|
||||
});
|
||||
|
||||
it("branch and commit ranges are lane-relative and lane-scoped too", async () => {
|
||||
g(LANE, "checkout", "-qb", "feat");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "both");
|
||||
const branch = await collectChanges(SUB, { range: "branch", base: "main", full: true });
|
||||
assert.deepEqual(
|
||||
branch.files.map((f) => f.path),
|
||||
["fresh.txt", "in.txt"]
|
||||
);
|
||||
assert.equal(branch.files.find((f) => f.path === "in.txt").added, 1);
|
||||
const commit = await collectChanges(SUB, { range: "commit", sha: "HEAD", file: "in.txt" });
|
||||
assert.equal(commit.files[0].hunks.length, 1);
|
||||
});
|
||||
|
||||
it("stage, unstage and discard work on lane-relative paths", async () => {
|
||||
await runGitAction(SUB, "stage", { paths: ["in.txt", "fresh.txt"] });
|
||||
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\npkg/in.txt\n");
|
||||
await runGitAction(SUB, "unstage", { paths: ["in.txt"] });
|
||||
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\n");
|
||||
await runGitAction(SUB, "discard", { paths: ["in.txt"], confirm: true });
|
||||
assert.equal(fs.readFileSync(path.join(SUB, "in.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
it("cannot name a change outside its cwd", async () => {
|
||||
for (const p of ["../a.txt", "a.txt", "../outside-new.txt"]) {
|
||||
await assert.rejects(
|
||||
runGitAction(SUB, "discard", { paths: [p], confirm: true }),
|
||||
{ code: "EBADPATH" },
|
||||
p
|
||||
);
|
||||
}
|
||||
assert.equal(fs.existsSync(path.join(LANE, "outside-new.txt")), true);
|
||||
});
|
||||
|
||||
it("gitFacts (the lane card) counts and lists only the lane's own files, cwd-relative", async () => {
|
||||
write(SUB, "mới tên.txt", "x\n");
|
||||
const facts = await gitFacts(SUB);
|
||||
const paths = facts.files.map((f) => f.path).sort();
|
||||
assert.deepEqual(paths, ["fresh.txt", "in.txt", "mới tên.txt"]);
|
||||
assert.equal(facts.dirty, 1);
|
||||
assert.equal(facts.untracked, 2);
|
||||
assert.equal(facts.files.find((f) => f.path === "in.txt").added, 1);
|
||||
});
|
||||
|
||||
it("commit sweeps in only the lane's own files", async () => {
|
||||
await runGitAction(SUB, "commit", { message: "lane only" });
|
||||
assert.equal(
|
||||
g(LANE, "show", "--name-only", "--format=", "HEAD"),
|
||||
"pkg/fresh.txt\npkg/in.txt\n"
|
||||
);
|
||||
assert.match(g(LANE, "status", "--porcelain"), /a\.txt/);
|
||||
});
|
||||
|
||||
it("refuses to commit while something OUTSIDE the lane is staged, and leaves it staged", async () => {
|
||||
const before = g(LANE, "rev-parse", "HEAD");
|
||||
g(LANE, "add", "a.txt");
|
||||
for (const only of [undefined, "staged"]) {
|
||||
g(SUB, "add", "in.txt");
|
||||
await assert.rejects(runGitAction(SUB, "commit", { message: "lane only", only }), {
|
||||
code: "ESTAGEDOUTSIDE",
|
||||
});
|
||||
}
|
||||
assert.match(g(LANE, "diff", "--cached", "--name-only"), /^a\.txt$/m);
|
||||
assert.equal(g(LANE, "rev-parse", "HEAD"), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reads larger than the git() buffer report truncated, not missing", () => {
|
||||
const BIG = `${"x".repeat(99)}\n`.repeat(100_000); // 10 MB
|
||||
|
||||
it("readFileAt at a rev", async () => {
|
||||
write(LANE, "big.txt", BIG);
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "big");
|
||||
const out = await readFileAt(LANE, "big.txt", "HEAD");
|
||||
assert.equal(out.truncated, true);
|
||||
assert.ok(out.lines.length > 1000);
|
||||
});
|
||||
|
||||
it("a single-file diff", async () => {
|
||||
write(LANE, "big.txt", "seed\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "seed");
|
||||
write(LANE, "big.txt", BIG);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "big.txt" });
|
||||
assert.equal(out.files[0].truncated, true);
|
||||
assert.ok(out.files[0].hunks[0].lines.length > 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: secret file at any depth, symlinked .claude", () => {
|
||||
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
|
||||
|
||||
it("a root lane cannot read a subdir lane's settings.local.json (work, rev, hunks)", async () => {
|
||||
write(LANE, "sub/.claude/settings.local.json", KEY);
|
||||
for (const p of ["sub/.claude/settings.local.json", "SUB/.Claude/Settings.Local.json"]) {
|
||||
await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" }, p);
|
||||
}
|
||||
const untracked = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(untracked).includes("sk-deep"), false);
|
||||
g(LANE, "add", "-f", "sub/.claude/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "oops");
|
||||
await assert.rejects(readFileAt(LANE, "sub/.claude/settings.local.json", "HEAD"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
write(LANE, "sub/.claude/settings.local.json", KEY.replace("deep", "deep2"));
|
||||
const tracked = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(tracked).includes("sk-deep"), false);
|
||||
const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD", full: true });
|
||||
assert.equal(JSON.stringify(commit).includes("sk-deep"), false);
|
||||
});
|
||||
|
||||
it("a symlinked .claude does not expose the key under its target's name", async () => {
|
||||
write(LANE, "conf/settings.local.json", KEY);
|
||||
fs.symlinkSync("conf", path.join(LANE, ".claude"));
|
||||
await assert.rejects(readFileAt(LANE, "conf/settings.local.json", "work"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
const untracked = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(untracked).includes("sk-deep"), false);
|
||||
g(LANE, "add", "conf/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "conf");
|
||||
await assert.rejects(readFileAt(LANE, "conf/settings.local.json", "HEAD"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
write(LANE, "conf/settings.local.json", KEY.replace("deep", "deep2"));
|
||||
const tracked = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(tracked).includes("sk-deep"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: subdir lane boundaries", () => {
|
||||
let SUB;
|
||||
beforeEach(() => {
|
||||
write(LANE, "pkg/in.txt", "one\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "pkg");
|
||||
SUB = path.join(LANE, "pkg");
|
||||
});
|
||||
|
||||
it("listCommits refuses a ?file= outside the lane", async () => {
|
||||
const { listCommits } = require("../lib/git-diff");
|
||||
for (const p of ["../a.txt", "/etc/passwd"]) {
|
||||
await assert.rejects(listCommits(SUB, { file: p }), { code: "EBADPATH" }, p);
|
||||
}
|
||||
assert.equal((await listCommits(SUB, { file: "in.txt" })).length, 1);
|
||||
});
|
||||
|
||||
it("commit (all) in a clean subdir lane of a dirty repo is ENOCHANGES", async () => {
|
||||
write(LANE, "a.txt", "dirty outside\n");
|
||||
await assert.rejects(runGitAction(SUB, "commit", { message: "x" }), { code: "ENOCHANGES" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: readFileAt rev=work reads at most the cap", () => {
|
||||
it("never loads a whole huge file, still reports truncated", async () => {
|
||||
write(LANE, "huge.txt", `${"y".repeat(99)}\n`.repeat(30_000)); // 3 MB
|
||||
const orig = fs.promises.readFile;
|
||||
fs.promises.readFile = async () => {
|
||||
throw new Error("whole-file read");
|
||||
};
|
||||
try {
|
||||
const out = await readFileAt(LANE, "huge.txt", "work");
|
||||
assert.equal(out.truncated, true);
|
||||
assert.ok(out.lines.length > 1000);
|
||||
assert.ok(out.lines.join("\n").length <= 1_000_000);
|
||||
} finally {
|
||||
fs.promises.readFile = orig;
|
||||
}
|
||||
const small = await readFileAt(LANE, "a.txt", "work");
|
||||
assert.deepEqual(small.lines, ["one", "two"]);
|
||||
assert.equal(small.truncated, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: push picks the right remote", () => {
|
||||
const bare = (name) => {
|
||||
const dir = path.join(ROOT, `${name}.git`);
|
||||
g(ROOT, "init", "-q", "--bare", dir);
|
||||
return dir;
|
||||
};
|
||||
const head = () => g(LANE, "rev-parse", "HEAD").trim();
|
||||
|
||||
it("pushes to the sole remote even when it is not origin", async () => {
|
||||
const dir = bare("only");
|
||||
g(LANE, "remote", "add", "backup", dir);
|
||||
await runGitAction(LANE, "push");
|
||||
assert.equal(g(dir, "rev-parse", "main").trim(), head());
|
||||
});
|
||||
|
||||
it("pushes to the branch's upstream remote over origin", async () => {
|
||||
const o = bare("o");
|
||||
const u = bare("u");
|
||||
g(LANE, "remote", "add", "origin", o);
|
||||
g(LANE, "remote", "add", "fork", u);
|
||||
g(LANE, "push", "-q", "-u", "fork", "HEAD");
|
||||
write(LANE, "a.txt", "new\n");
|
||||
g(LANE, "commit", "-qam", "next");
|
||||
await runGitAction(LANE, "push");
|
||||
assert.equal(g(u, "rev-parse", "main").trim(), head());
|
||||
assert.throws(() => g(o, "rev-parse", "--verify", "-q", "main"));
|
||||
});
|
||||
|
||||
it("falls back to origin among several remotes", async () => {
|
||||
const o = bare("o");
|
||||
g(LANE, "remote", "add", "fork", bare("f"));
|
||||
g(LANE, "remote", "add", "origin", o);
|
||||
await runGitAction(LANE, "push");
|
||||
assert.equal(g(o, "rev-parse", "main").trim(), head());
|
||||
});
|
||||
|
||||
it("ENOREMOTE with no remote, or several and no origin", async () => {
|
||||
await assert.rejects(runGitAction(LANE, "push"), { code: "ENOREMOTE" });
|
||||
g(LANE, "remote", "add", "a", bare("a"));
|
||||
g(LANE, "remote", "add", "b", bare("b"));
|
||||
await assert.rejects(runGitAction(LANE, "push"), { code: "ENOREMOTE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: a claude and a codex lane on one cwd share one git-write lock", () => {
|
||||
it("concurrent writes from both lanes never collide on index.lock", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api2.db");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
const { createApp, startServer } = require("../index");
|
||||
const server = await startServer(createApp(), 0);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const post = (url, body) =>
|
||||
fetch(`${base}${url}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
try {
|
||||
const ids = [];
|
||||
for (const tool of ["claude", "codex"]) {
|
||||
const r = await post("/api/lanes/ensure", { cwd: LANE, tool, title: tool }).then((x) =>
|
||||
x.json()
|
||||
);
|
||||
ids.push(r.lane.id);
|
||||
}
|
||||
assert.notEqual(ids[0], ids[1]);
|
||||
// A clean filter runs while `git add` HOLDS index.lock, so a write
|
||||
// from the sibling lane in that window fails unless serialized.
|
||||
g(LANE, "config", "filter.slow.clean", "sleep 1; cat");
|
||||
write(LANE, ".gitattributes", "slow.txt filter=slow\n");
|
||||
write(LANE, "slow.txt", "s\n");
|
||||
write(LANE, "b.txt", "new\n");
|
||||
const slow = post(`/api/lanes/${ids[0]}/git/stage`, { paths: ["slow.txt"] });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const other = await post(`/api/lanes/${ids[1]}/git/stage`, { paths: ["b.txt"] });
|
||||
const first = await slow;
|
||||
assert.equal(first.status, 200, await first.text());
|
||||
assert.equal(other.status, 200, await other.text());
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 3: a subdirectory lane's key behind its own symlinked .claude", () => {
|
||||
it("is refused to a root lane by its target name, on disk and at a committed rev", async () => {
|
||||
write(LANE, "sub/conf/settings.local.json", '{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-SUB"}}');
|
||||
fs.symlinkSync("conf", path.join(LANE, "sub/.claude"));
|
||||
await assert.rejects(readFileAt(LANE, "sub/conf/settings.local.json", "work"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
g(LANE, "add", "-f", "sub/conf/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "oops");
|
||||
fs.rmSync(path.join(LANE, "sub"), { recursive: true });
|
||||
await assert.rejects(readFileAt(LANE, "sub/conf/settings.local.json", "HEAD"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 3: a staged rename INTO a subdirectory lane", () => {
|
||||
it("is refused by ESTAGEDOUTSIDE, since it also deletes the old path outside the lane", async () => {
|
||||
write(LANE, "shared/x.js", "export const x = 1;\n");
|
||||
write(LANE, "pkg/keep.txt", "k\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "base");
|
||||
g(LANE, "mv", "shared/x.js", "pkg/x.js");
|
||||
await assert.rejects(
|
||||
runGitAction(path.join(LANE, "pkg"), "commit", { message: "move", only: "staged" }),
|
||||
{ code: "ESTAGEDOUTSIDE" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one numstat parser for the Changes panel and the lane card", () => {
|
||||
it("keys a rename by its new path and a binary file with null counts", () => {
|
||||
// Real `git diff --numstat -z` shapes: a rename is "a\td\t\0old\0new\0",
|
||||
// a binary file is "-\t-\tpath\0". `git show --format=` adds a leading \n.
|
||||
const stats = parseNumstat("\n1\t2\t\0old name.txt\0new name.txt\0-\t-\tlogo.png\0");
|
||||
assert.deepEqual([...stats.keys()], ["new name.txt", "logo.png"]);
|
||||
assert.deepEqual(stats.get("new name.txt"), { added: 1, deleted: 2, binary: false });
|
||||
assert.deepEqual(stats.get("logo.png"), { added: null, deleted: null, binary: true });
|
||||
});
|
||||
|
||||
it("gitFacts lists a staged rename and a binary file with the same counts, no extra field", async () => {
|
||||
g(LANE, "mv", "old name.txt", "new name.txt");
|
||||
write(LANE, "new name.txt", "keep me\nplease\nmore\n");
|
||||
fs.writeFileSync(path.join(LANE, "logo.png"), Buffer.from([0, 1, 2, 0, 255]));
|
||||
g(LANE, "add", "-A");
|
||||
const { files } = await gitFacts(LANE);
|
||||
const byPath = Object.fromEntries(files.map((f) => [f.path, f]));
|
||||
assert.deepEqual(byPath["new name.txt"], {
|
||||
path: "new name.txt",
|
||||
status: "R ",
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
});
|
||||
assert.deepEqual(byPath["logo.png"], {
|
||||
path: "logo.png",
|
||||
status: "A ",
|
||||
added: null,
|
||||
deleted: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1022,6 +1022,22 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
assert.equal(g(dir, "branch", "--list", "feat/sibling").trim(), "");
|
||||
});
|
||||
|
||||
it("maps a git exit status to 409 EGIT with git's stderr in the message", async () => {
|
||||
// A remote that is not a repository: `git push` exits 128, and execFile
|
||||
// reports that as a NUMERIC err.code. It must never reach the client as
|
||||
// `code: 128`, nor as a 500 — git refusing is not a server fault.
|
||||
g(dir, "remote", "add", "origin", path.join(ROOT, "no-such-remote"));
|
||||
try {
|
||||
const r = await request("POST", `/api/lanes/${laneId}/git/push`, {});
|
||||
assert.equal(r.status, 409, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "EGIT");
|
||||
assert.match(r.body.error.message, /^git push failed\n/);
|
||||
assert.match(r.body.error.message, /no-such-remote/);
|
||||
} finally {
|
||||
g(dir, "remote", "remove", "origin");
|
||||
}
|
||||
});
|
||||
|
||||
it("404s every git route for a lane that does not exist", async () => {
|
||||
for (const url of [
|
||||
"/api/lanes/999999/git/changes",
|
||||
|
||||
+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)) };
|
||||
|
||||
@@ -511,7 +511,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "The lane's recent commits",
|
||||
description:
|
||||
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
|
||||
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneCommits",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -520,6 +520,7 @@ const paths = {
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." },
|
||||
400: { description: "EBADPATH (file= is absolute or has a `..` segment)." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
@@ -538,7 +539,13 @@ const paths = {
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, path, rev, binary, truncated, lines}." },
|
||||
400: { description: "EBADPATH, EBADREV or ENOFILE." },
|
||||
400: {
|
||||
description: "EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.",
|
||||
},
|
||||
403: {
|
||||
description:
|
||||
"ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
@@ -597,7 +604,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "One git write on the lane's working copy",
|
||||
description:
|
||||
'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent\'s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.',
|
||||
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
|
||||
operationId: "runLaneGitAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -659,10 +666,14 @@ const paths = {
|
||||
200: { description: "{action, ok:true, output} — git's own stdout+stderr." },
|
||||
400: {
|
||||
description:
|
||||
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.",
|
||||
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
500: { description: "git failed; its stderr is appended to error.message." },
|
||||
409: {
|
||||
description:
|
||||
"EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.",
|
||||
},
|
||||
500: { description: "Unexpected server-side failure." },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+47
-9
@@ -31,6 +31,7 @@ const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
LANES_ROOT,
|
||||
addWorktree,
|
||||
git,
|
||||
gitFacts,
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
@@ -293,7 +294,9 @@ router.get("/branches", async (req, res) => {
|
||||
const worktrees = await listWorktrees(sourceRepo).catch(() => []);
|
||||
res.json({ branches, current, worktrees });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: { code: gitErrorCode(err), message: err.message, git: err.git } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -808,7 +811,10 @@ router.get("/:id/git/log", async (req, res) => {
|
||||
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
|
||||
});
|
||||
res.json({ available: true, commits });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err.code === "EBADPATH") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json({ available: false, commits: [] });
|
||||
}
|
||||
});
|
||||
@@ -817,7 +823,8 @@ router.get("/:id/git/log", async (req, res) => {
|
||||
* One file's whole content at a revision (`?rev=work|HEAD|<sha>`), for the
|
||||
* viewer's "show the entire file" mode. Path validation lives in
|
||||
* git-diff.js's `readFileAt` — the `work` revision is the one branch that
|
||||
* touches the filesystem, and it is realpath-confined to the lane.
|
||||
* touches the filesystem, and it is realpath-confined to the lane (symlinks
|
||||
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
|
||||
*/
|
||||
router.get("/:id/git/file", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
@@ -830,6 +837,10 @@ router.get("/:id/git/file", async (req, res) => {
|
||||
);
|
||||
res.json({ available: true, ...file });
|
||||
} catch (err) {
|
||||
// A secret file (the router's API key) is refused at every rev.
|
||||
if (err.code === "ESECRET") {
|
||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
@@ -884,8 +895,10 @@ router.get("/:id/git/branches", async (req, res) => {
|
||||
/**
|
||||
* The git WRITES the dashboard offers: fetch, pull, push, stage, unstage,
|
||||
* discard, commit, switch, branch-create and branch-delete.
|
||||
* Guarded (they mutate), serialized per lane (`withLaneLock`) so a push and a
|
||||
* commit fired from two tabs cannot interleave on one index, and thin — every
|
||||
* Guarded (they mutate), serialized per git index (`withLaneLock` keyed on
|
||||
* the worktree's git dir, so a claude and a codex lane on one directory share
|
||||
* it) so a push and a commit fired from two tabs or two lanes cannot
|
||||
* interleave on one index, and thin — every
|
||||
* rule about what each action may do lives in server/lib/git-ops.js, not here.
|
||||
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
|
||||
*/
|
||||
@@ -893,7 +906,15 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const result = await withLaneLock(lane.id, () =>
|
||||
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
|
||||
// cwd — or a root lane and a subdirectory lane of one repo — share one
|
||||
// index, and their writes would otherwise race on index.lock. The git dir
|
||||
// is per worktree, so sibling worktree lanes still run in parallel; a
|
||||
// cwd that is not a repo falls back to its real path.
|
||||
const gitDir = await git(lane.cwd, ["rev-parse", "--absolute-git-dir"])
|
||||
.then((r) => r.stdout.trim())
|
||||
.catch(() => fs.promises.realpath(lane.cwd).catch(() => lane.cwd));
|
||||
const result = await withLaneLock(`git:${gitDir}`, () =>
|
||||
runGitAction(lane.cwd, req.params.action, req.body || {})
|
||||
);
|
||||
res.json(result);
|
||||
@@ -910,8 +931,15 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
"EBADPATH",
|
||||
"ECONFIRM",
|
||||
"EPROTECTED",
|
||||
"ESTAGEDOUTSIDE",
|
||||
"ENOREMOTE",
|
||||
];
|
||||
const status = badRequest.includes(err.code) ? 400 : 500;
|
||||
// A numeric code is git's own exit status (execFile reports one that way):
|
||||
// git ran and refused — a rejected push, a failed credential, a diverged
|
||||
// pull. That is the repository's state answering a valid request, not a
|
||||
// server fault, so it is a 409 under the string code the client expects.
|
||||
const exited = typeof err.code === "number";
|
||||
const status = badRequest.includes(err.code) ? 400 : exited ? 409 : 500;
|
||||
// git's own stderr is the useful half of a failed push or pull (a
|
||||
// rejection, a missing credential, a diverged branch), so it joins the
|
||||
// message rather than riding a side field the client would have to know
|
||||
@@ -919,7 +947,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
const detail = err.git ? (err.git.stderr || "").trim() : "";
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "EGIT",
|
||||
code: gitErrorCode(err),
|
||||
message: detail ? `${err.message}\n${detail}` : err.message,
|
||||
},
|
||||
});
|
||||
@@ -1154,7 +1182,7 @@ function sendLifecycleError(res, err) {
|
||||
}
|
||||
if (err.git) {
|
||||
return res.status(500).json({
|
||||
error: { code: err.code, message: err.message, stderr: err.git.stderr },
|
||||
error: { code: gitErrorCode(err), message: err.message, stderr: err.git.stderr },
|
||||
});
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
@@ -1207,6 +1235,16 @@ function sendRuntimeError(res, err) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The string `error.code` for an error that may have come out of `git()`.
|
||||
* When git exits non-zero, execFile sets `err.code` to the exit STATUS (a
|
||||
* number, e.g. 128); the client branches on string codes, so that becomes
|
||||
* `EGIT`. A missing code (a timeout kill) is `EGIT` too; string codes pass.
|
||||
*/
|
||||
function gitErrorCode(err) {
|
||||
return typeof err.code === "string" && err.code ? err.code : "EGIT";
|
||||
}
|
||||
|
||||
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
|
||||
function laneOr404(req, res) {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
|
||||
Reference in New Issue
Block a user