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