7bc4594b94
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.
640 lines
26 KiB
JavaScript
640 lines
26 KiB
JavaScript
/**
|
|
* @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,
|
|
});
|
|
});
|
|
});
|