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

- Changes card beside lane detail with five modes: Files tree, overview,
  Review (whole diff, per-file collapse), single file, commit history.
- LaneDiffView: unified / side-by-side with independent scroll on both axes
  and mirrored scrollLeft, expand/collapse context, intra-line emphasis.
- Staged-only commit box; git actions (fetch/pull/push/stage/unstage/
  discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js.
- Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and
  POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind.
- Add lane: new/existing branch, current branch, worktree list, prefill
  from the git bar's "New lane here"; /api/run/cwds gains a repo kind.
- GlobalTooltip upgrades every native title into a themed anchored bubble;
  chart/info popovers use theme tokens.
This commit is contained in:
2026-09-23 09:18:17 +07:00
parent 32a8887419
commit 1b561c22d9
47 changed files with 7354 additions and 895 deletions
+599
View File
@@ -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,
};
+286
View File
@@ -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
View File
@@ -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 };
}
}
/**