/** * @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ĩ */ 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, readImageAt } = 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("readImageAt: an image's bytes for the viewer", () => { // Every byte value, so a utf8 round-trip anywhere would show. const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); it("reads the working file and a committed one byte for byte", async () => { write(LANE, "img/logo.PNG", bytes); g(LANE, "add", "-A"); g(LANE, "commit", "-qm", "img"); const work = await readImageAt(LANE, "img/logo.PNG", "work"); assert.equal(work.type, "image/png"); assert.deepEqual(work.buf, bytes); const head = await readImageAt(LANE, "img/logo.PNG", "HEAD"); assert.deepEqual(head.buf, bytes); }); it("refuses a non-image, a missing file and a symlink out of the lane", async () => { await assert.rejects(readImageAt(LANE, "a.txt", "work"), { code: "ENOTIMAGE" }); await assert.rejects(readImageAt(LANE, "none.png", "work"), { code: "ENOFILE" }); fs.writeFileSync(path.join(ROOT, "outside.png"), bytes); fs.symlinkSync(path.join(ROOT, "outside.png"), path.join(LANE, "leak.png")); await assert.rejects(readImageAt(LANE, "leak.png", "work")); await assert.rejects(readImageAt(LANE, "../outside.png", "work")); }); }); describe("GET /api/lanes/:id/git/raw", () => { it("serves an image with its type, and an SVG sandboxed", async () => { process.env.DASHBOARD_DB_PATH = path.join(ROOT, "raw.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, "pic.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255])); write( LANE, "x.svg", '' ); const created = await fetch(`${base}/api/lanes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: LANE, title: "raw" }), }).then((r) => r.json()); const id = created.lane.id; const png = await fetch(`${base}/api/lanes/${id}/git/raw?path=pic.png`); assert.equal(png.status, 200); assert.equal(png.headers.get("content-type"), "image/png"); assert.deepEqual( Buffer.from(await png.arrayBuffer()), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255]) ); const svg = await fetch(`${base}/api/lanes/${id}/git/raw?path=x.svg`); assert.equal(svg.headers.get("content-type"), "image/svg+xml"); assert.match(svg.headers.get("content-security-policy"), /sandbox/); assert.equal(svg.headers.get("x-content-type-options"), "nosniff"); const txt = await fetch(`${base}/api/lanes/${id}/git/raw?path=a.txt`); assert.equal(txt.status, 400); assert.equal((await txt.json()).error.code, "ENOTIMAGE"); await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" }); } finally { server.close(); } }); }); 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, }); }); });