Files
AgentHub/server/lib/git-ops.js
T
nntrivi2001 1b561c22d9 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.
2026-09-23 09:18:17 +07:00

287 lines
11 KiB
JavaScript

/**
* @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 };