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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user