feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)

- Changes card beside lane detail with five modes: Files tree, overview,
  Review (whole diff, per-file collapse), single file, commit history.
- LaneDiffView: unified / side-by-side with independent scroll on both axes
  and mirrored scrollLeft, expand/collapse context, intra-line emphasis.
- Staged-only commit box; git actions (fetch/pull/push/stage/unstage/
  discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js.
- Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and
  POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind.
- Add lane: new/existing branch, current branch, worktree list, prefill
  from the git bar's "New lane here"; /api/run/cwds gains a repo kind.
- GlobalTooltip upgrades every native title into a themed anchored bubble;
  chart/info popovers use theme tokens.
This commit is contained in:
2026-09-23 09:18:17 +07:00
parent 32a8887419
commit 1b561c22d9
47 changed files with 7354 additions and 895 deletions
+554
View File
@@ -0,0 +1,554 @@
/**
* @file Tests for server/lib/git-diff.js and server/lib/git-ops.js against a
* REAL git fixture (a bare origin, a clone, a second clone that pushes behind
* the first one's back). Diff parsing, --ff-only's refusal, the dirty-tree
* guard on `switch` and the one-worktree-per-branch rule are all git's own
* behavior — a mocked git would only test our idea of it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, beforeEach } = 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,
listCommits,
listPaths,
listTree,
parseUnifiedDiff,
readFileAt,
} = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-"));
const ORIGIN = path.join(ROOT, "origin.git");
const LANE = path.join(ROOT, "lane");
const OTHER = path.join(ROOT, "other");
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 });
};
const gc = (cwd, ...args) => g(cwd, "-c", "user.email=t@h", "-c", "user.name=t", ...args);
/** `git status --porcelain` as lines — the XY columns matter, so never trim. */
function porcelain(dir) {
return g(dir, "status", "--porcelain").split("\n").filter(Boolean);
}
function write(dir, file, body) {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), body);
}
function freshFixture() {
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(ROOT, { recursive: true });
g(ROOT, "init", "-q", "--bare", ORIGIN);
const seed = path.join(ROOT, "seed");
g(ROOT, "init", "-q", "-b", "development", seed);
write(seed, "a.txt", "one\ntwo\nthree\n");
write(seed, "keep.txt", "unchanged\n");
gc(seed, "add", "-A");
gc(seed, "commit", "-qm", "init");
gc(seed, "remote", "add", "origin", ORIGIN);
gc(seed, "push", "-q", "origin", "development");
g(ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development");
g(ROOT, "clone", "-q", ORIGIN, LANE);
g(ROOT, "clone", "-q", ORIGIN, OTHER);
// runGitAction's `commit` uses the repo's own identity (it never injects
// one), so the fixture sets a local one — a CI box may have no global user.
for (const dir of [LANE, OTHER]) {
g(dir, "config", "user.email", "t@h");
g(dir, "config", "user.name", "t");
}
gc(LANE, "checkout", "-qb", "feat/thing");
}
before(freshFixture);
describe("parseUnifiedDiff", () => {
it("resolves both gutters across a hunk", () => {
const hunks = parseUnifiedDiff(
"@@ -10,3 +10,4 @@ function f()\n ctx\n-gone\n+new1\n+new2\n ctx2\n"
);
assert.equal(hunks.length, 1);
assert.equal(hunks[0].context, "function f()");
assert.deepEqual(
hunks[0].lines.map((l) => [l.type, l.oldLine, l.newLine, l.text]),
[
["ctx", 10, 10, "ctx"],
["del", 11, null, "gone"],
["add", null, 11, "new1"],
["add", null, 12, "new2"],
["ctx", 12, 13, "ctx2"],
]
);
});
it("ignores the preamble and the no-newline marker", () => {
const hunks = parseUnifiedDiff(
"diff --git a/x b/x\nindex 1..2 100644\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n"
);
assert.equal(hunks.length, 1);
assert.equal(hunks[0].lines.length, 2);
});
it("returns no hunks for an empty patch", () => {
assert.deepEqual(parseUnifiedDiff(""), []);
});
});
describe("collectChanges — work range", () => {
beforeEach(freshFixture);
it("summarizes tracked edits, deletions and untracked files without hunks", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\nfour\n");
fs.rmSync(path.join(LANE, "keep.txt"));
write(LANE, "new.txt", "fresh\nlines\n");
const out = await collectChanges(LANE, { range: "work" });
assert.equal(out.range, "work");
assert.equal(out.truncated, false);
const byPath = Object.fromEntries(out.files.map((f) => [f.path, f]));
assert.deepEqual(Object.keys(byPath).sort(), ["a.txt", "keep.txt", "new.txt"]);
assert.equal(byPath["a.txt"].added, 2);
assert.equal(byPath["a.txt"].deleted, 1);
assert.equal(byPath["keep.txt"].deleted, 1);
assert.equal(byPath["new.txt"].untracked, true);
assert.equal(byPath["new.txt"].added, 2);
// Overview mode is a summary: no file carries hunks.
assert.ok(out.files.every((f) => f.hunks === undefined));
});
it("returns hunks for every file in full mode", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
const out = await collectChanges(LANE, { range: "work", full: true });
assert.equal(out.files.length, 1);
assert.equal(out.files[0].hunks.length, 1);
const kinds = out.files[0].hunks[0].lines.map((l) => l.type);
assert.ok(kinds.includes("del") && kinds.includes("add"));
});
it("synthesizes an all-added patch for an untracked file", async () => {
write(LANE, "new.txt", "alpha\nbeta\n");
const out = await collectChanges(LANE, { range: "work", file: "new.txt" });
assert.equal(out.files.length, 1);
const lines = out.files[0].hunks[0].lines;
assert.deepEqual(
lines.map((l) => [l.type, l.newLine, l.text]),
[
["add", 1, "alpha"],
["add", 2, "beta"],
]
);
});
it("rejects a path with no changes in the range", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "work", file: "keep.txt" }), {
code: "ENOFILECHANGE",
});
});
it("still returns content when a huge file has no newline to cut on", async () => {
// A minified bundle is ONE line. Cutting the patch at the last newline
// inside the cap would land at index -1 here and hand back an empty
// patch — a file changed by a megabyte rendering as "no textual change".
const { MAX_FILE_BYTES } = require("../lib/git-diff");
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
const entry = out.files[0];
assert.equal(entry.truncated, true);
assert.equal(out.truncated, true);
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
});
it("reports a binary file without trying to diff it", async () => {
fs.writeFileSync(path.join(LANE, "blob.bin"), Buffer.from([0, 1, 2, 0, 3]));
const out = await collectChanges(LANE, { range: "work", full: true });
const blob = out.files.find((f) => f.path === "blob.bin");
assert.equal(blob.binary, true);
assert.deepEqual(blob.hunks, []);
});
});
describe("collectChanges — branch range", () => {
beforeEach(freshFixture);
it("diffs the branch against its base, ignoring uncommitted work", async () => {
write(LANE, "a.txt", "one\ntwo\nthree\nfour\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "add four");
write(LANE, "scratch.txt", "not committed\n");
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
assert.equal(out.baseRef, "origin/development");
assert.deepEqual(
out.files.map((f) => f.path),
["a.txt"]
);
assert.equal(out.files[0].added, 1);
});
it("refuses when the lane has no base to measure against", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: null }), {
code: "ENOBASE",
});
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
code: "ENOBASE",
});
});
});
describe("gitFacts tracking counts", () => {
beforeEach(freshFixture);
it("reports null ahead/behind for a branch with no upstream", async () => {
const facts = await gitFacts(LANE);
assert.equal(facts.branch, "feat/thing");
assert.equal(facts.hasRemote, true);
assert.equal(facts.upstream, null);
assert.equal(facts.ahead, null);
});
it("counts both sides once an upstream exists", async () => {
gc(LANE, "checkout", "-q", "development");
write(LANE, "a.txt", "one\ntwo\nthree\nlocal\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "local");
write(OTHER, "b.txt", "remote\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote");
gc(OTHER, "push", "-q", "origin", "development");
g(LANE, "fetch", "-q", "origin");
const facts = await gitFacts(LANE);
assert.equal(facts.upstream, "origin/development");
assert.equal(facts.ahead, 1);
assert.equal(facts.behind, 1);
});
});
describe("runGitAction", () => {
beforeEach(freshFixture);
it("rejects an unknown action", async () => {
await assert.rejects(() => runGitAction(LANE, "rebase"), { code: "EBADACTION" });
});
it("commits everything with the given message", async () => {
write(LANE, "a.txt", "changed\n");
write(LANE, "added.txt", "new\n");
const result = await runGitAction(LANE, "commit", { message: "feat: both files" });
assert.equal(result.ok, true);
assert.equal(g(LANE, "log", "-1", "--format=%s").trim(), "feat: both files");
assert.equal(g(LANE, "status", "--porcelain").trim(), "");
});
it("refuses a commit with no message and one with nothing to commit", async () => {
await assert.rejects(() => runGitAction(LANE, "commit", { message: " " }), {
code: "EBADMESSAGE",
});
await assert.rejects(() => runGitAction(LANE, "commit", { message: "empty" }), {
code: "ENOCHANGES",
});
});
it("pulls fast-forward only and refuses a diverged branch", async () => {
gc(LANE, "checkout", "-q", "development");
write(OTHER, "b.txt", "remote\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote");
gc(OTHER, "push", "-q", "origin", "development");
const ff = await runGitAction(LANE, "pull");
assert.equal(ff.ok, true);
assert.ok(fs.existsSync(path.join(LANE, "b.txt")));
// Diverge: a local commit here, a different one pushed there.
write(LANE, "c.txt", "local\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "local");
write(OTHER, "d.txt", "remote2\n");
gc(OTHER, "add", "-A");
gc(OTHER, "commit", "-qm", "remote2");
gc(OTHER, "push", "-q", "origin", "development");
await assert.rejects(() => runGitAction(LANE, "pull"));
// The refusal left no merge in progress.
assert.ok(!fs.existsSync(path.join(LANE, ".git", "MERGE_HEAD")));
});
it("pushes the current branch and sets its upstream", async () => {
write(LANE, "a.txt", "changed\n");
await runGitAction(LANE, "commit", { message: "wip" });
const result = await runGitAction(LANE, "push");
assert.equal(result.ok, true);
assert.equal(
g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(),
"origin/feat/thing"
);
});
it("switches branches only with a clean tree", async () => {
write(LANE, "a.txt", "dirty\n");
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), {
code: "EDIRTY",
});
gc(LANE, "checkout", "-q", "--", "a.txt");
const result = await runGitAction(LANE, "switch", { branch: "development" });
assert.equal(result.branch, "development");
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "development");
});
it("creates a branch only when it does not already exist", async () => {
await runGitAction(LANE, "switch", { branch: "feat/second", create: true });
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/second");
await assert.rejects(
() => runGitAction(LANE, "switch", { branch: "feat/thing", create: true }),
{
code: "EBRANCHEXISTS",
}
);
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "nope/missing" }), {
code: "ENOBRANCH",
});
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "..bad" }), {
code: "EBADBRANCH",
});
});
it("refuses a branch another worktree holds", async () => {
const wt = path.join(ROOT, "sibling");
g(LANE, "worktree", "add", "-q", wt, "development");
await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), {
code: "EBRANCHBUSY",
});
});
});
describe("collectChanges — commit range", () => {
beforeEach(freshFixture);
it("reads one commit's files and hunks", async () => {
write(LANE, "a.txt", "one\nTWO\nthree\n");
gc(LANE, "commit", "-qam", "tweak a");
const sha = g(LANE, "rev-parse", "HEAD").trim();
const out = await collectChanges(LANE, { range: "commit", sha, full: true });
assert.equal(out.sha, sha);
assert.deepEqual(
out.files.map((f) => f.path),
["a.txt"]
);
assert.ok(out.files[0].hunks.some((h) => h.lines.some((l) => l.type === "add")));
});
it("rejects a sha that is not a commit here", async () => {
await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "nope" }), {
code: "EBADREV",
});
await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "a b;rm" }), {
code: "EBADREV",
});
});
it("widens a hunk when context is raised", async () => {
write(LANE, "long.txt", Array.from({ length: 40 }, (_, i) => `line${i}`).join("\n") + "\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "long");
write(
LANE,
"long.txt",
Array.from({ length: 40 }, (_, i) => (i === 20 ? "CHANGED" : `line${i}`)).join("\n") + "\n"
);
const narrow = await collectChanges(LANE, { range: "work", file: "long.txt" });
const wide = await collectChanges(LANE, { range: "work", file: "long.txt", context: 20 });
const count = (o) => o.files[0].hunks.reduce((n, h) => n + h.lines.length, 0);
assert.equal(narrow.context, 3);
assert.equal(wide.context, 20);
assert.ok(count(wide) > count(narrow));
});
});
describe("listCommits / readFileAt / listPaths / listTree", () => {
beforeEach(freshFixture);
it("lists commits, newest first, and narrows to one file", async () => {
write(LANE, "a.txt", "two\n");
gc(LANE, "commit", "-qam", "touch a");
write(LANE, "keep.txt", "edited\n");
gc(LANE, "commit", "-qam", "touch keep");
const all = await listCommits(LANE, { limit: 10 });
assert.deepEqual(
all.map((c) => c.subject),
["touch keep", "touch a", "init"]
);
assert.ok(all[0].refs.includes("HEAD -> feat/thing"));
const forA = await listCommits(LANE, { file: "a.txt" });
assert.deepEqual(
forA.map((c) => c.subject),
["touch a", "init"]
);
});
it("reads a file at work and at a revision, and refuses to escape the lane", async () => {
write(LANE, "a.txt", "one\nEDITED\n");
const work = await readFileAt(LANE, "a.txt", "work");
assert.deepEqual(work.lines, ["one", "EDITED"]);
const head = await readFileAt(LANE, "a.txt", "HEAD");
assert.deepEqual(head.lines, ["one", "two", "three"]);
await assert.rejects(() => readFileAt(LANE, "../../etc/passwd", "work"), { code: "EBADPATH" });
await assert.rejects(() => readFileAt(LANE, "/etc/passwd", "work"), { code: "EBADPATH" });
await assert.rejects(() => readFileAt(LANE, "a.txt", "no such rev"), { code: "EBADREV" });
await assert.rejects(() => readFileAt(LANE, "ghost.txt", "work"), { code: "ENOFILE" });
});
it("quick-opens by substring, basename hits first, ignoring untracked files", async () => {
write(LANE, "src/api.ts", "x\n");
write(LANE, "src/api/helper.ts", "x\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
write(LANE, "untracked-api.ts", "x\n");
const out = await listPaths(LANE, { q: "api" });
assert.equal(out.paths[0], "src/api.ts");
assert.ok(out.paths.includes("src/api/helper.ts"));
assert.ok(!out.paths.includes("untracked-api.ts"));
});
it("folds the tree one directory level at a time", async () => {
write(LANE, "src/api/helper.ts", "x\n");
write(LANE, "src/main.ts", "x\n");
gc(LANE, "add", "-A");
gc(LANE, "commit", "-qm", "src");
const root = await listTree(LANE, {});
assert.deepEqual(
root.entries.map((e) => [e.name, e.dir]),
[
["src", true],
["a.txt", false],
["keep.txt", false],
]
);
const src = await listTree(LANE, { subdir: "src" });
assert.deepEqual(
src.entries.map((e) => e.path),
["src/api", "src/main.ts"]
);
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
});
});
describe("runGitAction — per-file staging, discard and branches", () => {
beforeEach(freshFixture);
it("stages and unstages one file, leaving the other alone", async () => {
write(LANE, "a.txt", "edited\n");
write(LANE, "keep.txt", "also edited\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
assert.deepEqual(porcelain(LANE), ["M a.txt", " M keep.txt"]);
await runGitAction(LANE, "unstage", { path: "a.txt" });
assert.deepEqual(porcelain(LANE), [" M a.txt", " M keep.txt"]);
});
it("refuses a path git is not reporting as changed", async () => {
await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["keep.txt"] }), {
code: "EBADPATH",
});
await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["../../etc/passwd"] }), {
code: "EBADPATH",
});
await assert.rejects(() => runGitAction(LANE, "stage", {}), { code: "EBADPATH" });
});
it("commits only the index when asked", async () => {
write(LANE, "a.txt", "staged\n");
write(LANE, "keep.txt", "left behind\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
const out = await runGitAction(LANE, "commit", { message: "only a", only: "staged" });
assert.equal(out.ok, true);
assert.deepEqual(porcelain(LANE), [" M keep.txt"]);
});
it("refuses a staged-only commit with an empty index", async () => {
write(LANE, "a.txt", "unstaged\n");
await assert.rejects(() => runGitAction(LANE, "commit", { message: "nope", only: "staged" }), {
code: "ENOCHANGES",
});
});
it("discards a tracked file and deletes an untracked one, but only on confirm", async () => {
write(LANE, "a.txt", "edited\n");
write(LANE, "junk.txt", "throwaway\n");
await assert.rejects(() => runGitAction(LANE, "discard", { paths: ["a.txt"] }), {
code: "ECONFIRM",
});
assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "edited\n");
await runGitAction(LANE, "discard", { paths: ["a.txt", "junk.txt"], confirm: true });
assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "one\ntwo\nthree\n");
assert.ok(!fs.existsSync(path.join(LANE, "junk.txt")));
});
it("discards a staged edit in both gutters", async () => {
write(LANE, "a.txt", "edited\n");
await runGitAction(LANE, "stage", { paths: ["a.txt"] });
await runGitAction(LANE, "discard", { paths: ["a.txt"], confirm: true });
assert.equal(g(LANE, "status", "--porcelain").trim(), "");
});
it("creates a branch without moving HEAD, and deletes it again", async () => {
const before = g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim();
await runGitAction(LANE, "branch-create", { branch: "feat/sibling" });
assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), before);
assert.ok(g(LANE, "branch", "--list", "feat/sibling").trim());
await assert.rejects(() => runGitAction(LANE, "branch-create", { branch: "feat/sibling" }), {
code: "EBRANCHEXISTS",
});
await runGitAction(LANE, "branch-delete", { branch: "feat/sibling" });
assert.equal(g(LANE, "branch", "--list", "feat/sibling").trim(), "");
});
it("refuses to delete a protected, current, missing or busy branch", async () => {
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "development" }), {
code: "EPROTECTED",
});
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/thing" }), {
code: "EBRANCHBUSY",
});
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "nope" }), {
code: "ENOBRANCH",
});
await runGitAction(LANE, "branch-create", { branch: "feat/held" });
g(LANE, "worktree", "add", "-q", path.join(ROOT, "held"), "feat/held");
await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/held" }), {
code: "EBRANCHBUSY",
});
});
});
+218 -2
View File
@@ -386,7 +386,12 @@ describe("hook → lane binding", () => {
// Session B PostToolUse: rebinds lane to B, but does NOT clear A's flag
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
data: {
session_id: "sess-b",
cwd: "/tmp/lane-cross-session",
tool_name: "Read",
tool_use_id: "t1",
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
@@ -394,7 +399,12 @@ describe("hook → lane binding", () => {
// Session B's second PostToolUse clears the flag (B is now bound)
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
data: {
session_id: "sess-b",
cwd: "/tmp/lane-cross-session",
tool_name: "Read",
tool_use_id: "t2",
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
@@ -821,6 +831,212 @@ describe("GET /api/lanes/:id/git", () => {
});
});
describe("the lane git surface: changes, branches, actions (issue #34)", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-api-gitops-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
let laneId;
let dir;
before(async () => {
dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/api-changes");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "one\ntwo\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "seed");
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTWO\n");
fs.writeFileSync(path.join(dir, "fresh.txt"), "new file\n");
const created = await request("POST", "/api/lanes", { cwd: dir, title: "git ops" });
laneId = created.body.lane.id;
});
after(async () => {
await request("DELETE", `/api/lanes/${laneId}`);
fs.rmSync(ROOT, { recursive: true, force: true });
});
it("summarizes the working tree without patch text", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.range, "work");
assert.deepEqual(r.body.files.map((f) => f.path).sort(), ["a.txt", "fresh.txt"]);
assert.ok(r.body.files.every((f) => f.hunks === undefined));
});
it("returns one file's hunks when a path is given", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/changes?path=a.txt`);
assert.equal(r.status, 200);
assert.equal(r.body.files.length, 1);
assert.ok(r.body.files[0].hunks.length > 0);
});
it("400s for a path with no changes", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/changes?path=nope.txt`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENOFILECHANGE");
});
it("400s for the branch range on a lane with no base branch", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/changes?range=branch`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENOBASE");
});
it("lists the lane's own branches", async () => {
const r = await request("GET", `/api/lanes/${laneId}/git/branches`);
assert.equal(r.status, 200);
assert.deepEqual(r.body.branches, ["feat/api-changes"]);
assert.equal(r.body.current, "feat/api-changes");
});
it("commits through the action route and leaves the tree clean", async () => {
const r = await request("POST", `/api/lanes/${laneId}/git/commit`, {
message: "feat: through the api",
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(g(dir, "log", "-1", "--format=%s").trim(), "feat: through the api");
const after = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.deepEqual(after.body.files, []);
});
it("400s a commit with nothing staged and an unknown action", async () => {
const empty = await request("POST", `/api/lanes/${laneId}/git/commit`, { message: "again" });
assert.equal(empty.status, 400);
assert.equal(empty.body.error.code, "ENOCHANGES");
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
assert.equal(bogus.status, 400);
assert.equal(bogus.body.error.code, "EBADACTION");
});
it("reads the log, one commit's diff, a whole file, the path list and the tree", async () => {
const log = await request("GET", `/api/lanes/${laneId}/git/log?limit=5`);
assert.equal(log.status, 200);
assert.deepEqual(
log.body.commits.map((c) => c.subject),
["feat: through the api", "seed"]
);
const sha = log.body.commits[0].sha;
const commit = await request(
"GET",
`/api/lanes/${laneId}/git/changes?range=commit&sha=${sha}&full=1`
);
assert.equal(commit.status, 200);
assert.equal(commit.body.sha, sha);
assert.ok(commit.body.files.some((f) => f.path === "a.txt" && f.hunks.length > 0));
const bad = await request("GET", `/api/lanes/${laneId}/git/changes?range=commit&sha=nope`);
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADREV");
const file = await request("GET", `/api/lanes/${laneId}/git/file?path=a.txt&rev=HEAD`);
assert.equal(file.status, 200);
assert.deepEqual(file.body.lines, ["one", "TWO"]);
const escape = await request("GET", `/api/lanes/${laneId}/git/file?path=../../etc/passwd`);
assert.equal(escape.status, 400);
assert.equal(escape.body.error.code, "EBADPATH");
const ls = await request("GET", `/api/lanes/${laneId}/git/ls?q=a.t`);
assert.equal(ls.status, 200);
assert.deepEqual(ls.body.paths, ["a.txt"]);
const tree = await request("GET", `/api/lanes/${laneId}/git/tree`);
assert.equal(tree.status, 200);
assert.ok(tree.body.entries.some((e) => e.name === "a.txt" && e.dir === false));
});
it("stages one file, commits only the index, and refuses an unchanged path", async () => {
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTHREE\n");
fs.writeFileSync(path.join(dir, "b.txt"), "left behind\n");
const staged = await request("POST", `/api/lanes/${laneId}/git/stage`, { paths: ["a.txt"] });
assert.equal(staged.status, 200, JSON.stringify(staged.body));
const unknown = await request("POST", `/api/lanes/${laneId}/git/stage`, {
paths: ["../../etc/passwd"],
});
assert.equal(unknown.status, 400);
assert.equal(unknown.body.error.code, "EBADPATH");
const committed = await request("POST", `/api/lanes/${laneId}/git/commit`, {
message: "feat: only the index",
only: "staged",
});
assert.equal(committed.status, 200, JSON.stringify(committed.body));
const after = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.deepEqual(
after.body.files.map((f) => f.path),
["b.txt"]
);
});
it("refuses a discard without confirm, then throws the file away with it", async () => {
const refused = await request("POST", `/api/lanes/${laneId}/git/discard`, {
paths: ["b.txt"],
});
assert.equal(refused.status, 400);
assert.equal(refused.body.error.code, "ECONFIRM");
assert.ok(fs.existsSync(path.join(dir, "b.txt")));
const done = await request("POST", `/api/lanes/${laneId}/git/discard`, {
paths: ["b.txt"],
confirm: true,
});
assert.equal(done.status, 200, JSON.stringify(done.body));
assert.ok(!fs.existsSync(path.join(dir, "b.txt")));
});
it("creates a branch without switching and refuses to delete a protected one", async () => {
const created = await request("POST", `/api/lanes/${laneId}/git/branch-create`, {
branch: "feat/sibling",
});
assert.equal(created.status, 200, JSON.stringify(created.body));
assert.equal(g(dir, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/api-changes");
const protectedBranch = await request("POST", `/api/lanes/${laneId}/git/branch-delete`, {
branch: "main",
});
assert.equal(protectedBranch.status, 400);
// No `main` in this fixture, so the missing-branch check answers first —
// what matters is that neither path deletes anything.
assert.ok(["EPROTECTED", "ENOBRANCH"].includes(protectedBranch.body.error.code));
const deleted = await request("POST", `/api/lanes/${laneId}/git/branch-delete`, {
branch: "feat/sibling",
});
assert.equal(deleted.status, 200, JSON.stringify(deleted.body));
assert.equal(g(dir, "branch", "--list", "feat/sibling").trim(), "");
});
it("404s every git route for a lane that does not exist", async () => {
for (const url of [
"/api/lanes/999999/git/changes",
"/api/lanes/999999/git/branches",
"/api/lanes/999999/git/log",
"/api/lanes/999999/git/file?path=a.txt",
"/api/lanes/999999/git/ls",
"/api/lanes/999999/git/tree",
]) {
const r = await request("GET", url);
assert.equal(r.status, 404);
}
});
});
describe("GET /api/lanes/branches", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");