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:
@@ -524,6 +524,14 @@ Request body shape:
|
||||
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
|
||||
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
|
||||
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked files, folded. `400 EBADPATH` for a path escaping the lane. |
|
||||
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
|
||||
|
||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||
an existing absolute git repository. The response lane starts as
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* @file Reads a lane's code changes as structured hunks, for the Workspace's
|
||||
* Changes panel (overview / review / single-file / history / file). Three
|
||||
* ranges, because the questions a lane raises are different: `work` is "what
|
||||
* has the agent touched and not committed yet" (HEAD vs the working tree,
|
||||
* untracked files included), `branch` is "what does this lane add on top of
|
||||
* its base" (`<base>...HEAD`), and `commit` is one recorded commit.
|
||||
*
|
||||
* Everything here is READ-ONLY and every git call goes through worktree.js's
|
||||
* `git()` argv wrapper — no shell strings, same rule as the rest of the lane
|
||||
* git surface. Untracked files are rendered as an all-added patch from the file
|
||||
* itself rather than via `git diff --no-index`, which exits non-zero on a
|
||||
* difference and would read as a failure.
|
||||
*
|
||||
* Output is capped in three places (files listed, bytes per file, bytes in
|
||||
* total) because this rides an HTTP response a browser has to render: a lane
|
||||
* mid-`npm install` or holding a committed lockfile would otherwise ship
|
||||
* megabytes of patch to a React list. A cap that bites sets `truncated`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { git } = require("./worktree");
|
||||
|
||||
/** Most files one response names. Beyond this the list is `truncated`. */
|
||||
const MAX_FILES = 300;
|
||||
/** Most patch bytes read for ONE file before its hunks are cut short. */
|
||||
const MAX_FILE_BYTES = 200_000;
|
||||
/** Most patch bytes across all files in a `full` (review-mode) response. */
|
||||
const MAX_TOTAL_BYTES = 1_000_000;
|
||||
/** An untracked file larger than this is reported without content. */
|
||||
const MAX_UNTRACKED_BYTES = 1_000_000;
|
||||
/** Most bytes `readFileAt` returns before cutting the tail off. */
|
||||
const MAX_FILE_VIEW_BYTES = 1_000_000;
|
||||
/** Most paths `listPaths`/`listTree` answer with. */
|
||||
const MAX_PATHS = 500;
|
||||
/** Lines of unchanged context around a hunk, and the ceiling "expand all"
|
||||
* is clamped to — a whole large file rendered as context is the one way this
|
||||
* panel can be made to ship more bytes than the caps above allow. */
|
||||
const DEFAULT_CONTEXT = 3;
|
||||
const MAX_CONTEXT = 5000;
|
||||
|
||||
const VALID_RANGES = new Set(["work", "branch", "commit"]);
|
||||
/** A commit-ish this module will pass to git. Deliberately strict: a sha, or a
|
||||
* ref name without the characters that would let it read as an option or a
|
||||
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
|
||||
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
|
||||
|
||||
/** Throw a coded error the routes map onto a 400. */
|
||||
function badRequest(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Clamp a caller's context request into [0, MAX_CONTEXT]. */
|
||||
function normalizeContext(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT;
|
||||
return Math.min(Math.floor(n), MAX_CONTEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one file's unified diff into hunks with both gutters resolved, so the
|
||||
* client renders rows instead of re-parsing `@@` headers.
|
||||
*
|
||||
* Returns `[]` for an empty patch (an unchanged file, or a mode-only change).
|
||||
*/
|
||||
function parseUnifiedDiff(patch) {
|
||||
const hunks = [];
|
||||
let current = null;
|
||||
let oldLine = 0;
|
||||
let newLine = 0;
|
||||
|
||||
for (const raw of patch.split("\n")) {
|
||||
const header = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
||||
if (header) {
|
||||
oldLine = Number(header[1]);
|
||||
newLine = Number(header[2]);
|
||||
current = {
|
||||
header: raw,
|
||||
context: header[3].trim(),
|
||||
oldStart: oldLine,
|
||||
newStart: newLine,
|
||||
lines: [],
|
||||
};
|
||||
hunks.push(current);
|
||||
continue;
|
||||
}
|
||||
if (!current) continue; // preamble: ---/+++/index/diff lines
|
||||
// "\ No newline at end of file" annotates the previous line; it is not a
|
||||
// row of its own and carries no line number.
|
||||
if (raw.startsWith("\\")) continue;
|
||||
const marker = raw[0];
|
||||
const text = raw.slice(1);
|
||||
if (marker === "+") {
|
||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text });
|
||||
} else if (marker === "-") {
|
||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text });
|
||||
} else if (marker === " ") {
|
||||
// An unchanged EMPTY line is " " — one space, no text. A truly empty
|
||||
// string is the tail of the final "\n", not a row.
|
||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text });
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** Whether a buffer looks binary — the same NUL-byte heuristic git uses. */
|
||||
function looksBinary(buf) {
|
||||
return buf.includes(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ref a `branch`-range diff measures against: `origin/<base>` when
|
||||
* the remote-tracking ref exists, else the local branch, else null (a lane with
|
||||
* no base, or a base that has since been deleted — reported, not guessed).
|
||||
*/
|
||||
async function resolveBaseRef(dir, base) {
|
||||
if (!base) return null;
|
||||
for (const ref of [`origin/${base}`, base]) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "--verify", "--quiet", ref]);
|
||||
return ref;
|
||||
} catch {
|
||||
/* try the next candidate */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `git diff --numstat` output → Map<path, {added, deleted, binary}>. */
|
||||
function parseNumstat(stdout) {
|
||||
const stats = new Map();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
if (!m) continue;
|
||||
// A rename reports "old => new" (or "dir/{a => b}/file"); the path that
|
||||
// exists now is what every other source here is keyed by.
|
||||
const file = m[3].includes(" => ") ? renameTarget(m[3]) : m[3];
|
||||
stats.set(file, {
|
||||
added: m[1] === "-" ? null : Number(m[1]),
|
||||
deleted: m[2] === "-" ? null : Number(m[2]),
|
||||
binary: m[1] === "-",
|
||||
});
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** `dir/{old => new}/f.js` or `old.js => new.js` → the post-rename path. */
|
||||
function renameTarget(spec) {
|
||||
const braced = spec.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||
if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/\//g, "/");
|
||||
return spec.split(" => ").pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Porcelain codes that mean "unmerged" — a merge or a rebase left this file
|
||||
* with conflict markers. git's own list; `UU` is the common one, but a
|
||||
* delete/modify conflict reports `DU`/`UD` and an add/add reports `AA`.
|
||||
*/
|
||||
const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
|
||||
|
||||
/** Working-tree summary: porcelain status, plus numstat deltas for tracked files. */
|
||||
async function summarizeWork(dir) {
|
||||
const [status, numstat] = await Promise.all([
|
||||
git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]),
|
||||
git(dir, ["diff", "--numstat", "HEAD"]).catch(() => ({ stdout: "" })),
|
||||
]);
|
||||
const stats = parseNumstat(numstat.stdout);
|
||||
const files = [];
|
||||
for (const line of status.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const code = line.slice(0, 2);
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
const untracked = code === "??";
|
||||
const stat = stats.get(file);
|
||||
// One read per untracked file, not two: its line count and its
|
||||
// binary-ness are the same buffer answered twice.
|
||||
const fresh = untracked ? untrackedFacts(dir, file) : null;
|
||||
files.push({
|
||||
path: file,
|
||||
status: code,
|
||||
untracked,
|
||||
// The two porcelain columns are the index and the working tree, which
|
||||
// is the whole of what per-file staging needs to render: a file can be
|
||||
// BOTH (edited, staged, edited again) and the row has to say so rather
|
||||
// than pick one.
|
||||
staged: !untracked && code[0] !== " " && code[0] !== "?",
|
||||
unstaged: untracked || (code[1] !== " " && code[1] !== "?"),
|
||||
conflicted: CONFLICT_CODES.has(code),
|
||||
added: untracked ? fresh.lines : (stat?.added ?? 0),
|
||||
deleted: untracked ? 0 : (stat?.deleted ?? 0),
|
||||
binary: untracked ? fresh.binary : !!stat?.binary,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* An untracked file's line count and binary-ness, from ONE read.
|
||||
*
|
||||
* `lines` is null when the file is too big, binary, or unreadable — git has no
|
||||
* delta for an untracked file either, and a null there already means "unknown"
|
||||
* everywhere else in this response. A file over the cap counts as binary so
|
||||
* nothing downstream tries to render it.
|
||||
*/
|
||||
function untrackedFacts(dir, file) {
|
||||
try {
|
||||
const abs = path.join(dir, file);
|
||||
if (fs.statSync(abs).size > MAX_UNTRACKED_BYTES) return { lines: null, binary: true };
|
||||
const buf = fs.readFileSync(abs);
|
||||
if (looksBinary(buf)) return { lines: null, binary: true };
|
||||
const text = buf.toString("utf8");
|
||||
if (!text) return { lines: 0, binary: false };
|
||||
const lines = text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
|
||||
return { lines, binary: false };
|
||||
} catch {
|
||||
// A directory entry (git reports untracked DIRS with a trailing slash), a
|
||||
// dangling symlink, a file deleted between the status call and this read.
|
||||
return { lines: null, binary: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Branch summary: `<baseRef>...HEAD`, name-status for the letter, numstat for counts. */
|
||||
async function summarizeBranch(dir, baseRef) {
|
||||
const spec = `${baseRef}...HEAD`;
|
||||
const [nameStatus, numstat] = await Promise.all([
|
||||
git(dir, ["diff", "--name-status", spec]),
|
||||
git(dir, ["diff", "--numstat", spec]),
|
||||
]);
|
||||
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
|
||||
}
|
||||
|
||||
/**
|
||||
* One commit's summary. `git show` against a MERGE commit prints nothing by
|
||||
* default (it diffs against no single parent), so `-m --first-parent` picks
|
||||
* the mainline side — which is what "what did this commit bring in" means to
|
||||
* a reader scrolling a branch's history.
|
||||
*/
|
||||
async function summarizeCommit(dir, sha) {
|
||||
const base = ["show", "--format=", "-m", "--first-parent", sha];
|
||||
const [nameStatus, numstat] = await Promise.all([
|
||||
git(dir, [...base, "--name-status"]),
|
||||
git(dir, [...base, "--numstat"]),
|
||||
]);
|
||||
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
|
||||
}
|
||||
|
||||
/** Shared `--name-status` reader for the ranges that have no working tree. */
|
||||
function parseNameStatus(stdout, stats) {
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
for (const line of stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.split("\t");
|
||||
const letter = parts[0][0];
|
||||
// A rename/copy line is "R100\told\tnew" — the new path is the live one.
|
||||
const file = parts.length > 2 ? parts[2] : parts[1];
|
||||
// `-m` prints one diff per parent for a merge; --first-parent keeps it to
|
||||
// one, but a defensive de-dup costs nothing and a doubled row is a lie.
|
||||
if (seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
const stat = stats.get(file);
|
||||
files.push({
|
||||
path: file,
|
||||
status: `${letter} `,
|
||||
untracked: false,
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
conflicted: false,
|
||||
added: stat?.added ?? 0,
|
||||
deleted: stat?.deleted ?? 0,
|
||||
binary: !!stat?.binary,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** One file's patch text, for any range. Empty string when there is none. */
|
||||
async function patchFor(dir, { range, baseRef, sha, file, untracked, context }) {
|
||||
if (untracked) return synthesizeAddedPatch(dir, file);
|
||||
const u = `-U${context}`;
|
||||
const args =
|
||||
range === "commit"
|
||||
? ["show", "--format=", "-m", "--first-parent", u, sha, "--", file]
|
||||
: range === "branch"
|
||||
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
||||
: ["diff", u, "HEAD", "--", file];
|
||||
const result = await git(dir, [...args, "--no-color", "--no-ext-diff"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* An untracked file has no git-side "before", so its patch is synthesized: one
|
||||
* hunk starting at line 1 with every line added. This is what `git diff
|
||||
* --no-index /dev/null <file>` would print, without the non-zero exit that
|
||||
* wrapper treats as a failure.
|
||||
*/
|
||||
function synthesizeAddedPatch(dir, file) {
|
||||
let buf;
|
||||
try {
|
||||
buf = fs.readFileSync(path.join(dir, file));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (looksBinary(buf)) return "";
|
||||
const text = buf.toString("utf8");
|
||||
if (!text) return "";
|
||||
const lines = text.split("\n");
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
const body = lines.map((l) => `+${l}`).join("\n");
|
||||
return `@@ -0,0 +1,${lines.length} @@\n${body}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's changes for one range.
|
||||
*
|
||||
* @param {string} dir Lane working directory.
|
||||
* @param {object} opts
|
||||
* @param {"work"|"branch"|"commit"} [opts.range="work"]
|
||||
* @param {string|null} [opts.base] The lane's base branch, for `range="branch"`.
|
||||
* @param {string|null} [opts.sha] The commit to read, for `range="commit"`.
|
||||
* @param {string|null} [opts.file] Only this path, with its hunks (single-file mode).
|
||||
* @param {boolean} [opts.full] Hunks for EVERY file (review mode). Ignored when
|
||||
* `file` is set, which is already a full read of one file.
|
||||
* @param {number} [opts.context] Unchanged lines around each hunk. The panel
|
||||
* raises this to expand a diff towards the whole file.
|
||||
* @returns {Promise<{range, base, baseRef, sha, context, files, truncated}>}
|
||||
*/
|
||||
async function collectChanges(
|
||||
dir,
|
||||
{ range = "work", base = null, sha = null, file = null, full = false, context } = {}
|
||||
) {
|
||||
if (!VALID_RANGES.has(range)) {
|
||||
throw badRequest("EBADRANGE", `unknown range ${range}`);
|
||||
}
|
||||
const ctx = normalizeContext(context);
|
||||
|
||||
let baseRef = null;
|
||||
if (range === "branch") {
|
||||
baseRef = await resolveBaseRef(dir, base);
|
||||
if (!baseRef) {
|
||||
throw badRequest("ENOBASE", "no base branch to compare against");
|
||||
}
|
||||
}
|
||||
if (range === "commit") {
|
||||
if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision");
|
||||
try {
|
||||
// Resolve it once, so a bad ref is a 400 here rather than an empty diff
|
||||
// three calls later, and so the response reports the full sha.
|
||||
sha = (await git(dir, ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`])).stdout.trim();
|
||||
} catch {
|
||||
throw badRequest("EBADREV", "no such commit in this repository");
|
||||
}
|
||||
}
|
||||
|
||||
let files =
|
||||
range === "branch"
|
||||
? await summarizeBranch(dir, baseRef)
|
||||
: range === "commit"
|
||||
? await summarizeCommit(dir, sha)
|
||||
: await summarizeWork(dir);
|
||||
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
let truncated = false;
|
||||
if (file) {
|
||||
const one = files.find((f) => f.path === file);
|
||||
if (!one) {
|
||||
throw badRequest("ENOFILECHANGE", "file has no changes in this range");
|
||||
}
|
||||
files = [one];
|
||||
} else if (files.length > MAX_FILES) {
|
||||
files = files.slice(0, MAX_FILES);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
if (file || full) {
|
||||
let budget = file ? MAX_FILE_BYTES : MAX_TOTAL_BYTES;
|
||||
for (const entry of files) {
|
||||
if (entry.binary) {
|
||||
entry.hunks = [];
|
||||
continue;
|
||||
}
|
||||
if (budget <= 0) {
|
||||
entry.hunks = [];
|
||||
entry.truncated = true;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
let patch = await patchFor(dir, {
|
||||
range,
|
||||
baseRef,
|
||||
sha,
|
||||
file: entry.path,
|
||||
untracked: entry.untracked,
|
||||
context: ctx,
|
||||
});
|
||||
const cap = Math.min(MAX_FILE_BYTES, budget);
|
||||
if (patch.length > cap) {
|
||||
// Cut on a line boundary so the last row is a whole line — but only
|
||||
// when that boundary is near the cap. A minified file is one enormous
|
||||
// line, so its last newline inside the cap is the HUNK HEADER's, and
|
||||
// honouring it would return a hunk with no lines at all (and with no
|
||||
// newline anywhere, `lastIndexOf` is -1 and the patch came back empty)
|
||||
// — a file changed by a megabyte rendering as "no textual change".
|
||||
// Past the halfway mark the boundary is worth keeping; before it, the
|
||||
// content is, so the last row is cut mid-line instead.
|
||||
const nl = patch.lastIndexOf("\n", cap);
|
||||
patch = patch.slice(0, nl > cap / 2 ? nl + 1 : cap);
|
||||
entry.truncated = true;
|
||||
truncated = true;
|
||||
}
|
||||
budget -= patch.length;
|
||||
entry.hunks = parseUnifiedDiff(patch);
|
||||
}
|
||||
}
|
||||
|
||||
return { range, base, baseRef, sha, context: ctx, files, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent commits on the lane's current branch, for the history list.
|
||||
*
|
||||
* `%x1f`/`%x1e` (unit/record separators) delimit the format instead of a
|
||||
* printable character: a commit SUBJECT can legally contain a tab, a pipe or
|
||||
* any punctuation a hand-picked delimiter would have used.
|
||||
*
|
||||
* @param {string} dir Lane working directory.
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.limit=30] Commits to read, clamped to 200.
|
||||
* @param {string|null} [opts.file] Only commits touching this path.
|
||||
*/
|
||||
async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
|
||||
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
|
||||
const args = ["log", `-${n}`, `--format=${format}%x1e`];
|
||||
if (file) args.push("--", file);
|
||||
const result = await git(dir, args);
|
||||
const commits = [];
|
||||
for (const record of result.stdout.split("\x1e")) {
|
||||
const line = record.replace(/^\n/, "");
|
||||
if (!line.trim()) continue;
|
||||
const [sha, short, subject, author, date, refs] = line.split("\x1f");
|
||||
commits.push({
|
||||
sha,
|
||||
short,
|
||||
subject,
|
||||
author,
|
||||
date,
|
||||
// `%D` is "HEAD -> main, origin/main, tag: v1" — the decorations, split
|
||||
// into the chips a history row shows.
|
||||
refs: (refs || "")
|
||||
.split(", ")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file's full content at a revision, for the "read the whole file" view
|
||||
* that a diff's three lines of context cannot answer.
|
||||
*
|
||||
* `rev` is `work` (the file on disk, including an untracked one), `HEAD`, or a
|
||||
* commit-ish. The path is resolved against git's own index/tree rather than
|
||||
* trusted: for `work` it is realpath-confined to the lane directory, because
|
||||
* that is the one branch that reads the filesystem directly.
|
||||
*/
|
||||
async function readFileAt(dir, file, rev = "work") {
|
||||
if (typeof file !== "string" || !file || file.startsWith("/") || file.includes("\0")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
}
|
||||
if (rev !== "work" && !SAFE_REV.test(rev)) {
|
||||
throw badRequest("EBADREV", "rev is not a valid revision");
|
||||
}
|
||||
|
||||
let text;
|
||||
let binary = false;
|
||||
if (rev === "work") {
|
||||
const abs = path.resolve(dir, file);
|
||||
// `..` in the request must not walk out of the lane. Compare resolved
|
||||
// paths, not the string the caller sent.
|
||||
const root = path.resolve(dir);
|
||||
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
||||
throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
}
|
||||
let buf;
|
||||
try {
|
||||
buf = fs.readFileSync(abs);
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
binary = looksBinary(buf);
|
||||
text = binary ? "" : buf.toString("utf8");
|
||||
} else {
|
||||
let out;
|
||||
try {
|
||||
out = await git(dir, ["show", `${rev}:${file}`]);
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file at that revision");
|
||||
}
|
||||
text = out.stdout;
|
||||
binary = text.includes("\0");
|
||||
if (binary) text = "";
|
||||
}
|
||||
|
||||
let truncated = false;
|
||||
if (text.length > MAX_FILE_VIEW_BYTES) {
|
||||
const nl = text.lastIndexOf("\n", MAX_FILE_VIEW_BYTES);
|
||||
text = text.slice(0, nl > MAX_FILE_VIEW_BYTES / 2 ? nl + 1 : MAX_FILE_VIEW_BYTES);
|
||||
truncated = true;
|
||||
}
|
||||
const lines = text ? text.split("\n") : [];
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
return { path: file, rev, binary, truncated, lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracked paths matching a substring query — the quick-open list.
|
||||
*
|
||||
* `git ls-files` rather than a filesystem walk: it already honours
|
||||
* `.gitignore`, so `node_modules` and build output never enter the list and no
|
||||
* skip-list has to be maintained here.
|
||||
*/
|
||||
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
|
||||
const result = await git(dir, ["ls-files", "-z"]);
|
||||
const needle = String(q || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const cap = Math.min(Math.max(Number(limit) || MAX_PATHS, 1), MAX_PATHS);
|
||||
const all = result.stdout.split("\0").filter(Boolean);
|
||||
const hits = needle ? all.filter((p) => p.toLowerCase().includes(needle)) : all;
|
||||
// A basename hit beats a directory hit: typing "api" wants `api.ts`, not
|
||||
// every file under `src/api/`.
|
||||
if (needle) {
|
||||
hits.sort((a, b) => {
|
||||
const ab = path.basename(a).toLowerCase().includes(needle) ? 0 : 1;
|
||||
const bb = path.basename(b).toLowerCase().includes(needle) ? 0 : 1;
|
||||
return ab - bb || a.length - b.length || a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
return { paths: hits.slice(0, cap), truncated: hits.length > cap, total: hits.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* One directory level of the working tree, for the file explorer. Tracked
|
||||
* entries only (same `.gitignore` reasoning as `listPaths`), folded into
|
||||
* directories at this level — a tree that lists a repo's every file at once is
|
||||
* both slow to render and not what "expand a folder" means.
|
||||
*/
|
||||
async function listTree(dir, { subdir = "" } = {}) {
|
||||
const clean = String(subdir || "")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.trim();
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
|
||||
const args = ["ls-files", "-z"];
|
||||
if (clean) args.push("--", `${clean}/`);
|
||||
const result = await git(dir, args);
|
||||
const prefix = clean ? `${clean}/` : "";
|
||||
const dirs = new Set();
|
||||
const files = [];
|
||||
for (const p of result.stdout.split("\0").filter(Boolean)) {
|
||||
const rest = p.slice(prefix.length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash === -1) files.push({ name: rest, path: p, dir: false });
|
||||
else dirs.add(rest.slice(0, slash));
|
||||
}
|
||||
const entries = [
|
||||
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
|
||||
...files.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
return {
|
||||
path: clean,
|
||||
entries: entries.slice(0, MAX_PATHS),
|
||||
truncated: entries.length > MAX_PATHS,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
listCommits,
|
||||
listPaths,
|
||||
listTree,
|
||||
parseUnifiedDiff,
|
||||
readFileAt,
|
||||
resolveBaseRef,
|
||||
DEFAULT_CONTEXT,
|
||||
MAX_CONTEXT,
|
||||
MAX_FILES,
|
||||
MAX_FILE_BYTES,
|
||||
MAX_TOTAL_BYTES,
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
|
||||
* stage/unstage a file, commit, discard a file, switch branch, and create or
|
||||
* delete a branch. Everything here is deliberately the boring half of git —
|
||||
* there is no merge, no rebase, no reset, no stash and no conflict
|
||||
* resolution, because a dashboard button is the wrong place to start an
|
||||
* operation whose recovery needs a terminal (`reset` stays where it was:
|
||||
* behind the three-check destroy guard in worktree.js).
|
||||
*
|
||||
* Four rules hold for every action:
|
||||
* - argv only, via worktree.js's `git()` — never a shell string.
|
||||
* - the lane's own working directory is the only cwd.
|
||||
* - a caller-supplied path is never trusted as a path: it must be one
|
||||
* `git status` is reporting as changed right now (`resolvePaths`), so no
|
||||
* `..`, no absolute path and no untouched file is reachable.
|
||||
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
|
||||
* invent a merge commit or a conflict underneath a running agent.
|
||||
*
|
||||
* `discard` is the ONE irreversible action here — it throws away work git has
|
||||
* no copy of — so it additionally demands an explicit `confirm: true` in the
|
||||
* body. Do not add a second destructive action without the same gate.
|
||||
*
|
||||
* Network actions carry a timeout: a lane pointed at an unreachable remote
|
||||
* should fail in half a minute, not hold an HTTP request open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const {
|
||||
git,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
} = require("./worktree");
|
||||
|
||||
/** How long a fetch/pull/push may run before it is killed. */
|
||||
const NETWORK_TIMEOUT_MS = 60_000;
|
||||
/** Longest commit message accepted — a subject+body, not a pasted file. */
|
||||
const MAX_MESSAGE_LEN = 4000;
|
||||
|
||||
const ACTIONS = new Set([
|
||||
"fetch",
|
||||
"pull",
|
||||
"push",
|
||||
"commit",
|
||||
"switch",
|
||||
"stage",
|
||||
"unstage",
|
||||
"discard",
|
||||
"branch-create",
|
||||
"branch-delete",
|
||||
]);
|
||||
|
||||
/** Branches this module refuses to delete, whatever the request says. */
|
||||
const PROTECTED = new Set(["main", "master", "development", "develop"]);
|
||||
|
||||
function bad(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Whether the working tree has anything uncommitted (tracked or not). */
|
||||
async function isDirty(dir) {
|
||||
const { dirty, untracked } = await statusCounts(dir);
|
||||
return dirty + untracked > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The paths a per-file action may name — exactly the ones `git status` is
|
||||
* reporting as changed right now, keyed by their porcelain code.
|
||||
*
|
||||
* This is the whole authorization story for `stage`/`unstage`/`discard`: the
|
||||
* request cannot reach a path git is not already talking about, so no `..`,
|
||||
* no absolute path, and no untouched file can be deleted by a typo. A path
|
||||
* that is not in this map is a 400, never a silent no-op.
|
||||
*/
|
||||
async function changedPaths(dir) {
|
||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||
const map = new Map();
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const code = line.slice(0, 2);
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
map.set(file, code);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Validate `body.paths` against what git reports as changed. */
|
||||
async function resolvePaths(dir, body) {
|
||||
const wanted = Array.isArray(body.paths)
|
||||
? body.paths
|
||||
: typeof body.path === "string"
|
||||
? [body.path]
|
||||
: [];
|
||||
if (!wanted.length) throw bad("EBADPATH", "at least one path is required");
|
||||
if (wanted.some((p) => typeof p !== "string" || !p)) {
|
||||
throw bad("EBADPATH", "every path must be a non-empty string");
|
||||
}
|
||||
const changed = await changedPaths(dir);
|
||||
const unknown = wanted.filter((p) => !changed.has(p));
|
||||
if (unknown.length) {
|
||||
throw bad("EBADPATH", `not a changed path: ${unknown[0]}`);
|
||||
}
|
||||
return wanted.map((p) => ({ path: p, code: changed.get(p) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one action against a lane's working directory.
|
||||
*
|
||||
* @param {string} dir The lane's cwd.
|
||||
* @param {string} action One of ACTIONS.
|
||||
* @param {object} [body] Action input: `{message}` for commit,
|
||||
* `{branch, create}` for switch. Ignored otherwise.
|
||||
* @returns {Promise<{action, ok, output, branch?}>} `output` is git's own
|
||||
* stdout+stderr, shown verbatim — a push rejection or a credential failure
|
||||
* says more in git's words than in ours.
|
||||
*/
|
||||
async function runGitAction(dir, action, body = {}) {
|
||||
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
|
||||
|
||||
if (action === "fetch") {
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["fetch", "--all", "--prune"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "pull") {
|
||||
// --ff-only on purpose: a divergent branch is reported, never merged. The
|
||||
// user (or the agent in the pane) decides how to reconcile it.
|
||||
return done(action, await git(dir, ["pull", "--ff-only"], { timeout: NETWORK_TIMEOUT_MS }));
|
||||
}
|
||||
|
||||
if (action === "push") {
|
||||
// HEAD, not a branch name from the request: the lane pushes the branch it
|
||||
// is actually on, and -u makes the first push set the upstream the
|
||||
// ahead/behind counts then read.
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["push", "-u", "origin", "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "stage") {
|
||||
const paths = await resolvePaths(dir, body);
|
||||
// `add` covers every case the panel can show: a modification, a new file
|
||||
// and a deletion all stage with the same verb.
|
||||
return done(action, await git(dir, ["add", "--", ...paths.map((p) => p.path)]));
|
||||
}
|
||||
|
||||
if (action === "unstage") {
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const untracked = paths.filter((p) => p.code === "A " || p.code === "AM");
|
||||
// An intent-to-add/new file has nothing in HEAD to restore from, so
|
||||
// `restore --staged` would fail on the very first commit of a repo.
|
||||
const args =
|
||||
untracked.length === paths.length && !(await hasHead(dir))
|
||||
? ["rm", "--cached", "-r", "--", ...paths.map((p) => p.path)]
|
||||
: ["restore", "--staged", "--", ...paths.map((p) => p.path)];
|
||||
return done(action, await git(dir, args));
|
||||
}
|
||||
|
||||
if (action === "discard") {
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||
const tracked = paths.filter((p) => p.code !== "??").map((p) => p.path);
|
||||
const out = [];
|
||||
// Untracked files are deleted (git has no copy to restore); tracked ones
|
||||
// go back to HEAD in both gutters, so a staged-then-edited file lands in
|
||||
// one state rather than half-reverted.
|
||||
if (untracked.length) out.push(await git(dir, ["clean", "-fd", "--", ...untracked]));
|
||||
if (tracked.length) {
|
||||
out.push(
|
||||
await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...tracked])
|
||||
);
|
||||
}
|
||||
return done(action, mergeResults(out));
|
||||
}
|
||||
|
||||
if (action === "commit") {
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
if (!message) throw bad("EBADMESSAGE", "commit message is required");
|
||||
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
|
||||
if (body.only === "staged") {
|
||||
// The panel's per-file staging only means something if a commit can
|
||||
// take the index as it stands — no `add -A` sweeping in the rest.
|
||||
const staged = [...(await changedPaths(dir)).values()].some(
|
||||
(code) => code[0] !== " " && code[0] !== "?"
|
||||
);
|
||||
if (!staged) throw bad("ENOCHANGES", "nothing staged to commit");
|
||||
} else {
|
||||
if (!(await isDirty(dir))) throw bad("ENOCHANGES", "nothing to commit");
|
||||
await git(dir, ["add", "-A"]);
|
||||
}
|
||||
// -m takes the message as one argv element, so a message containing
|
||||
// newlines, quotes or a leading dash is content, never syntax.
|
||||
return done(action, await git(dir, ["commit", "-m", message]));
|
||||
}
|
||||
|
||||
if (action === "branch-create" || action === "branch-delete") {
|
||||
return branchAction(dir, action, body);
|
||||
}
|
||||
|
||||
// switch
|
||||
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
|
||||
if (!branch) throw bad("EBADBRANCH", "branch is required");
|
||||
if (!(await isValidBranchName(dir, branch))) {
|
||||
throw bad("EBADBRANCH", "branch is not a valid git branch name");
|
||||
}
|
||||
if (await isDirty(dir)) {
|
||||
// git would refuse anyway for conflicting files, but it happily carries
|
||||
// unrelated edits across a switch — which silently moves an agent's
|
||||
// half-finished work onto another branch. Refuse the whole case.
|
||||
throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
}
|
||||
const { branches } = await listBranches(dir);
|
||||
const exists = branches.includes(branch);
|
||||
if (body.create) {
|
||||
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
|
||||
return done(action, await git(dir, ["switch", "-c", branch]), branch);
|
||||
}
|
||||
if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
|
||||
const busyAt = await branchCheckedOutAt(dir, branch);
|
||||
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
|
||||
return done(action, await git(dir, ["switch", branch]), branch);
|
||||
}
|
||||
|
||||
/** Create or delete a branch WITHOUT touching the working tree. */
|
||||
async function branchAction(dir, action, body) {
|
||||
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
|
||||
if (!branch) throw bad("EBADBRANCH", "branch is required");
|
||||
if (!(await isValidBranchName(dir, branch))) {
|
||||
throw bad("EBADBRANCH", "branch is not a valid git branch name");
|
||||
}
|
||||
const { branches, current } = await listBranches(dir);
|
||||
const exists = branches.includes(branch);
|
||||
|
||||
if (action === "branch-create") {
|
||||
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
|
||||
const base = typeof body.base === "string" ? body.base.trim() : "";
|
||||
if (base && !(await isValidBranchName(dir, base))) {
|
||||
throw bad("EBADBRANCH", "base is not a valid git ref name");
|
||||
}
|
||||
// No switch: creating a branch to spawn a sibling worktree lane from must
|
||||
// not move the branch the agent in this pane is standing on.
|
||||
return done(action, await git(dir, ["branch", branch, ...(base ? [base] : [])]), branch);
|
||||
}
|
||||
|
||||
if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
|
||||
if (PROTECTED.has(branch)) throw bad("EPROTECTED", `branch ${branch} is protected`);
|
||||
if (branch === current) throw bad("EBRANCHBUSY", `branch ${branch} is the current branch`);
|
||||
const busyAt = await branchCheckedOutAt(dir, branch);
|
||||
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
|
||||
// -d, never -D: git refuses to drop unmerged commits, and that refusal is
|
||||
// exactly the safety net a dashboard button needs. Force-deleting is a
|
||||
// terminal operation.
|
||||
return done(action, await git(dir, ["branch", "-d", branch]), branch);
|
||||
}
|
||||
|
||||
/** Whether the repo has any commit yet. */
|
||||
async function hasHead(dir) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "--verify", "HEAD"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeResults(results) {
|
||||
return {
|
||||
stdout: results.map((r) => r.stdout).join(""),
|
||||
stderr: results.map((r) => r.stderr).join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function done(action, result, branch) {
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("").trim();
|
||||
return { action, ok: true, output, ...(branch ? { branch } : {}) };
|
||||
}
|
||||
|
||||
module.exports = { runGitAction, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
|
||||
+45
-3
@@ -30,7 +30,7 @@ const STATUS_FILE_CAP = 20;
|
||||
* these variables. This module's whole job is to run git safely against repos other
|
||||
* than the one enclosing the current working directory.
|
||||
*/
|
||||
async function git(cwd, args) {
|
||||
async function git(cwd, args, { timeout = 0 } = {}) {
|
||||
// Build a clean environment: copy process.env but scrub git hook variables
|
||||
// that could point to the outer repo's git directory or index.
|
||||
const env = { ...process.env };
|
||||
@@ -58,12 +58,16 @@ async function git(cwd, args) {
|
||||
cwd,
|
||||
env,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
// 0 = no limit, node's own default. A network command (fetch/pull/push)
|
||||
// passes one so an unreachable remote fails instead of pinning a request
|
||||
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
|
||||
timeout,
|
||||
});
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (err) {
|
||||
const error = new Error(`git ${args[0]} failed`);
|
||||
error.code = err.code;
|
||||
error.git = { args, code: err.code, stderr: err.stderr };
|
||||
error.git = { args, code: err.code, stderr: err.stderr, stdout: err.stdout };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -640,7 +644,45 @@ async function gitFacts(dir) {
|
||||
const { dirty, untracked, head, files } = await statusCounts(dir);
|
||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||
return { branch, head, subject, dirty, untracked, files };
|
||||
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this branch stands against its upstream: the upstream's name and how
|
||||
* many commits each side has that the other does not. `unpushedCount` already
|
||||
* counted one half of this, but only the destroy guard ever saw it — a lane
|
||||
* that is three commits ahead of origin looks identical to a pushed one in the
|
||||
* UI, which is the state most worth seeing before a machine is shut down.
|
||||
*
|
||||
* A branch with no upstream (never pushed) reports `upstream: null` and null
|
||||
* counts — not zeros, which would claim "in sync" about a comparison that was
|
||||
* never possible. Never throws: these are extra fields on a polled endpoint.
|
||||
*/
|
||||
async function trackingFacts(dir) {
|
||||
let hasRemote = false;
|
||||
try {
|
||||
hasRemote = !!(await git(dir, ["remote"])).stdout.trim();
|
||||
} catch {
|
||||
/* unreadable repo — the caller already has the facts that matter */
|
||||
}
|
||||
try {
|
||||
const upstream = (
|
||||
await git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
|
||||
).stdout.trim();
|
||||
const counts = (
|
||||
await git(dir, ["rev-list", "--left-right", "--count", `${upstream}...HEAD`])
|
||||
).stdout.trim();
|
||||
const [behind, ahead] = counts.split(/\s+/).map((n) => Number(n));
|
||||
// Anything but the two numbers git documents reports as unknown, not as
|
||||
// NaN: the UI treats a falsy count as "nothing to push", and NaN is falsy,
|
||||
// so a malformed line would render a diverged branch as in sync.
|
||||
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
|
||||
return { hasRemote, upstream, ahead: null, behind: null };
|
||||
}
|
||||
return { hasRemote, upstream, ahead, behind };
|
||||
} catch {
|
||||
return { hasRemote, upstream: null, ahead: null, behind: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -441,11 +441,226 @@ const paths = {
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{branches: string[], current: string | null}." },
|
||||
200: {
|
||||
description:
|
||||
"{branches: string[], current: string | null, worktrees: {path, branch?, locked?}[]}. The worktrees ride along so the picker can grey out branches git would refuse to check out twice.",
|
||||
},
|
||||
400: { description: "repo is missing, relative, does not exist, or is not a git repo." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/changes": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "A lane's code changes, at one of three depths",
|
||||
description:
|
||||
"Backs the Workspace Changes panel. No extra params = a summary of every changed file; full=1 adds every file's hunks (review mode); path=<file> returns that one file's hunks. range=work (default) is HEAD vs the working tree with untracked files included; range=branch is <base_branch>...HEAD; range=commit needs sha= and shows that one commit. context= widens each hunk (default 3, clamped to 5000) — the viewer's expand button. Lines arrive with both gutters resolved. Capped at 300 files, 200 KB of patch per file and 1 MB per full read; a cap that bit sets `truncated`. Read-only, no same-origin guard.",
|
||||
operationId: "getLaneChanges",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{
|
||||
name: "range",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["work", "branch", "commit"], default: "work" },
|
||||
},
|
||||
{
|
||||
name: "sha",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description: "range=commit only: the commit to show.",
|
||||
},
|
||||
{
|
||||
name: "context",
|
||||
in: "query",
|
||||
schema: { type: "integer", default: 3, maximum: 5000 },
|
||||
description: "Unchanged lines around each hunk.",
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description: "Return only this file, with its hunks.",
|
||||
},
|
||||
{
|
||||
name: "full",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1", "true"] },
|
||||
description: "Include every file's hunks (review mode).",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"{available, range, base, baseRef, sha, context, files, truncated}, or {available:false} for a cwd git cannot read.",
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
"ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/log": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The lane's recent commits",
|
||||
description:
|
||||
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneCommits",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "limit", in: "query", schema: { type: "integer", default: 30, maximum: 200 } },
|
||||
{ name: "file", in: "query", schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/file": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One file's whole content at a revision",
|
||||
description:
|
||||
"rev=work (the file on disk, untracked included), HEAD, or a commit-ish. The `work` revision is the only one that reads the filesystem and it is realpath-confined to the lane directory. Binary content is reported, never returned. Capped at 1 MB. Read-only, no same-origin guard.",
|
||||
operationId: "readLaneFile",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "rev", in: "query", schema: { type: "string", default: "work" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, path, rev, binary, truncated, lines}." },
|
||||
400: { description: "EBADPATH, EBADREV or ENOFILE." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/ls": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Tracked paths matching a substring (quick-open)",
|
||||
description:
|
||||
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.",
|
||||
operationId: "listLanePaths",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "q", in: "query", schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, paths, truncated, total}." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/tree": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One directory level of tracked files",
|
||||
description:
|
||||
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneTree",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "path", in: "query", schema: { type: "string", default: "" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, path, entries:[{name, path, dir}], truncated}." },
|
||||
400: { description: "EBADPATH (the path escapes the lane)." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The lane repo's own local branches",
|
||||
description:
|
||||
"Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneOwnBranches",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "{available, branches, current, worktrees}." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/{action}": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One git write on the lane's working copy",
|
||||
description:
|
||||
'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent\'s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.',
|
||||
operationId: "runLaneGitAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{
|
||||
name: "action",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"fetch",
|
||||
"pull",
|
||||
"push",
|
||||
"stage",
|
||||
"unstage",
|
||||
"discard",
|
||||
"commit",
|
||||
"switch",
|
||||
"branch-create",
|
||||
"branch-delete",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "commit only; required, max 4000 chars." },
|
||||
only: {
|
||||
type: "string",
|
||||
enum: ["staged"],
|
||||
description: "commit only; commit the index as it stands, with no add -A.",
|
||||
},
|
||||
branch: {
|
||||
type: "string",
|
||||
description: "switch, branch-create and branch-delete.",
|
||||
},
|
||||
base: { type: "string", description: "branch-create only; defaults to HEAD." },
|
||||
create: { type: "boolean", description: "switch only; create the branch." },
|
||||
paths: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "stage, unstage, discard; each must be a path git reports changed.",
|
||||
},
|
||||
confirm: {
|
||||
type: "boolean",
|
||||
description: "discard only; the call is refused without it.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "{action, ok:true, output} — git's own stdout+stderr." },
|
||||
400: {
|
||||
description:
|
||||
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
500: { description: "git failed; its stderr is appended to error.message." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/{action}": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
+186
-1
@@ -35,10 +35,13 @@ const {
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
resetWorktree,
|
||||
resolveBase,
|
||||
slugify,
|
||||
} = require("../lib/worktree");
|
||||
const { collectChanges, listCommits, listPaths, listTree, readFileAt } = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
|
||||
const { installAgents } = require("../lib/lane-agents");
|
||||
@@ -279,7 +282,16 @@ router.get("/branches", async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const { branches, current } = await listBranches(sourceRepo);
|
||||
res.json({ branches, current });
|
||||
// The repo's worktrees ride along because every question the Add-lane
|
||||
// picker asks about a branch is really a question about them: which
|
||||
// branches can still be checked out (git allows one worktree per branch,
|
||||
// and `addWorktree` throws EBRANCHBUSY for the rest), and what this repo
|
||||
// already has provisioned. One extra subprocess on a form the user is
|
||||
// still typing into, versus a second round trip for facts that expire
|
||||
// together. A repo whose worktree list cannot be read still answers with
|
||||
// its branches — the picker just loses the busy markers.
|
||||
const worktrees = await listWorktrees(sourceRepo).catch(() => []);
|
||||
res.json({ branches, current, worktrees });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
|
||||
}
|
||||
@@ -733,6 +745,179 @@ router.get("/:id/git", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The lane's code changes, in the three shapes the Workspace's Changes panel
|
||||
* asks for — one route, because they are the same read at three depths:
|
||||
*
|
||||
* ?range=work|branch|commit — overview: every changed file + its counts
|
||||
* &full=1 — review: the same list WITH every hunk
|
||||
* &path=<file> — one file's hunks (opened from the overview)
|
||||
* &context=<n> — lines of context per hunk (expand)
|
||||
*
|
||||
* `work` (default) is HEAD vs the working tree, untracked files included;
|
||||
* `branch` is `<base>...HEAD`, and answers 404-free with ENOBASE when the lane
|
||||
* has no base branch to measure against; `commit` needs `&sha=` and shows that
|
||||
* one commit. Read-only, so no same-origin guard — same reasoning as
|
||||
* GET /:id/git. See server/lib/git-diff.js for the caps.
|
||||
*/
|
||||
router.get("/:id/git/changes", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work";
|
||||
const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null;
|
||||
const full = req.query.full === "1" || req.query.full === "true";
|
||||
try {
|
||||
const changes = await collectChanges(lane.cwd, {
|
||||
range,
|
||||
base: lane.base_branch,
|
||||
sha: typeof req.query.sha === "string" ? req.query.sha : null,
|
||||
file,
|
||||
full,
|
||||
context: req.query.context,
|
||||
});
|
||||
res.json({ available: true, ...changes });
|
||||
} catch (err) {
|
||||
if (["ENOBASE", "ENOFILECHANGE", "EBADRANGE", "EBADREV"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Not a git repo (an adopted lane pointed at a plain directory), or a repo
|
||||
// with no commits yet. Same contract as GET /:id/git: a state, not a fault.
|
||||
res.json({ available: false, range, files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The lane's recent commits, for the history list. `?file=` narrows it to the
|
||||
* commits touching one path — the "history of this file" the single-file view
|
||||
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
|
||||
*/
|
||||
router.get("/:id/git/log", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const commits = await listCommits(lane.cwd, {
|
||||
limit: req.query.limit,
|
||||
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
|
||||
});
|
||||
res.json({ available: true, commits });
|
||||
} catch {
|
||||
res.json({ available: false, commits: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* One file's whole content at a revision (`?rev=work|HEAD|<sha>`), for the
|
||||
* viewer's "show the entire file" mode. Path validation lives in
|
||||
* git-diff.js's `readFileAt` — the `work` revision is the one branch that
|
||||
* touches the filesystem, and it is realpath-confined to the lane.
|
||||
*/
|
||||
router.get("/:id/git/file", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const file = await readFileAt(
|
||||
lane.cwd,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
|
||||
);
|
||||
res.json({ available: true, ...file });
|
||||
} catch (err) {
|
||||
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json({ available: false });
|
||||
}
|
||||
});
|
||||
|
||||
/** Tracked paths matching `?q=`, for quick-open. */
|
||||
router.get("/:id/git/ls", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
res.json({ available: true, ...(await listPaths(lane.cwd, { q: req.query.q })) });
|
||||
} catch {
|
||||
res.json({ available: false, paths: [], truncated: false, total: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
/** One directory level of tracked files, for the file explorer. */
|
||||
router.get("/:id/git/tree", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const subdir = typeof req.query.path === "string" ? req.query.path : "";
|
||||
res.json({ available: true, ...(await listTree(lane.cwd, { subdir })) });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADPATH") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json({ available: false, path: "", entries: [], truncated: false });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The lane's own local branches, for the branch switcher. Separate from
|
||||
* `GET /branches` (which takes a candidate repo PATH from the query, for a
|
||||
* lane that does not exist yet); this one resolves the repo from the lane row,
|
||||
* so no path ever comes from the request.
|
||||
*/
|
||||
router.get("/:id/git/branches", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const { branches, current } = await listBranches(lane.cwd);
|
||||
const worktrees = await listWorktrees(lane.cwd).catch(() => []);
|
||||
res.json({ available: true, branches, current, worktrees });
|
||||
} catch {
|
||||
res.json({ available: false, branches: [], current: null, worktrees: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The git WRITES the dashboard offers: fetch, pull, push, stage, unstage,
|
||||
* discard, commit, switch, branch-create and branch-delete.
|
||||
* Guarded (they mutate), serialized per lane (`withLaneLock`) so a push and a
|
||||
* commit fired from two tabs cannot interleave on one index, and thin — every
|
||||
* rule about what each action may do lives in server/lib/git-ops.js, not here.
|
||||
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
|
||||
*/
|
||||
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const result = await withLaneLock(lane.id, () =>
|
||||
runGitAction(lane.cwd, req.params.action, req.body || {})
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const badRequest = [
|
||||
"EBADACTION",
|
||||
"EBADMESSAGE",
|
||||
"EBADBRANCH",
|
||||
"ENOBRANCH",
|
||||
"EBRANCHEXISTS",
|
||||
"EBRANCHBUSY",
|
||||
"EDIRTY",
|
||||
"ENOCHANGES",
|
||||
"EBADPATH",
|
||||
"ECONFIRM",
|
||||
"EPROTECTED",
|
||||
];
|
||||
const status = badRequest.includes(err.code) ? 400 : 500;
|
||||
// git's own stderr is the useful half of a failed push or pull (a
|
||||
// rejection, a missing credential, a diverged branch), so it joins the
|
||||
// message rather than riding a side field the client would have to know
|
||||
// about — `message` is the one thing every caller already reads.
|
||||
const detail = err.git ? (err.git.stderr || "").trim() : "";
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "EGIT",
|
||||
message: detail ? `${err.message}\n${detail}` : err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
|
||||
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
|
||||
|
||||
@@ -106,6 +106,8 @@ router.get("/history", (req, res) => {
|
||||
* - "home": $HOME (the Run page's default cwd and first autocomplete group —
|
||||
* a neutral spawn location that doesn't inherit this repo's project
|
||||
* context, issue #202)
|
||||
* - "repo": source repos existing lanes were provisioned from — the paths
|
||||
* the Add-lane form's repo field is actually asking for (issue #34)
|
||||
* - "recent": distinct cwds Claude Code has been used in, sourced from the
|
||||
* dashboard's own sessions table. Filtered to dirs that still exist.
|
||||
*
|
||||
@@ -126,6 +128,26 @@ router.get("/cwds", (_req, res) => {
|
||||
push("dashboard", process.cwd(), "Dashboard server");
|
||||
push("home", require("node:os").homedir(), "Home");
|
||||
|
||||
// Source repos lanes have already been provisioned from, newest lane first.
|
||||
// The Add-lane form's repo field used to offer only "recent" session cwds,
|
||||
// which for a worktree lane is the WORKTREE, never the repo it was cut from
|
||||
// — so the one path that form actually wants had to be retyped every time.
|
||||
// These sit above the recents below because a repo is a deliberate choice a
|
||||
// user made once, not a directory a session happened to run in.
|
||||
try {
|
||||
const { db } = require("../db");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT source_repo AS p, MAX(id) AS last_id FROM lanes
|
||||
WHERE source_repo IS NOT NULL AND source_repo <> ''
|
||||
GROUP BY source_repo ORDER BY last_id DESC LIMIT 20`
|
||||
)
|
||||
.all();
|
||||
for (const row of rows) push("repo", row.p, path.basename(row.p));
|
||||
} catch {
|
||||
/* ignore — same best-effort contract as the recents below */
|
||||
}
|
||||
|
||||
// Pull recent cwds from the sessions DB (best-effort; if the DB isn't
|
||||
// ready or has a different schema, just return what we have).
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user