/** * @file The git WRITES the dashboard offers on a lane: fetch, pull, push, * stage/unstage a file, commit (the index, everything, or exactly the ticked * `paths`), discard a file, add an untracked file to the workspace's * .gitignore (`ignore`), switch branch, create or * delete a branch, publish a repo that has no remote yet, and merge a branch * with its conflict resolution (issue #45: merge, resolve ours/theirs/ * hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset * and no stash, because a dashboard button is the wrong place to start an * operation whose recovery needs a terminal (a hard `reset` stays where it * was: behind the three-check destroy guard in worktree.js). A merge is the * exception because its recovery is ONE button here too — `merge-abort` puts * the branch back where it was — and `cherry-pick` and `revert` follow it: * same clean-tree/repo-root guards, same conflict flow, and `merge-commit`/ * `merge-abort` finish or abort whichever of the three git has open. * * `undo-commit` (`reset --soft HEAD~1`) and `amend` rewrite the last commit, * so both refuse once any remote-tracking branch holds it (EPUSHED): an * unpushed commit is the only one whose rewrite costs nobody else anything. * `undo-commit` keeps every change staged, never touches the working tree, * and still asks for `confirm: true` because the commit itself is dropped. * * 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, and its boundary: * in a lane that is a subdirectory of its repo, paths are cwd-relative * and nothing outside the cwd can be named or swept into a commit. * - every path is a LITERAL file name, never a pathspec glob — `new*.js` * must not also discard new1.js (`literal: true` on every call). * - 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. A merge * happens only when asked for by name (`merge`), on a clean tree, from a * lane that IS the repo root (a merge is repo-wide), one at a time. * * `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy * of, so each demands an explicit `confirm: true` in the body. Do not add * another destructive action without the same gate. * * `publish` is the ONE action whose destination comes from the request (a * remote URL), so it is gated differently: only when `git remote` prints * nothing, always named `origin`, the URL allowlisted by `validateRemoteUrl`, * and the remote removed again if the push fails. * * 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 fs = require("node:fs"); const path = require("node:path"); const { git: rawGit, isValidBranchName, listBranches, branchCheckedOutAt, statusCounts, operationInProgress, branchRef, } = require("./worktree"); const { hunkPatch, selectionPatch, laneStatus, CONFLICT_CODES } = require("./git-diff"); const gitCredentials = require("./git-credentials"); const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts }); /** 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", "stage-hunk", "discard-hunk", "branch-create", "branch-delete", "publish", "merge", "resolve", "merge-commit", "merge-abort", "cherry-pick", "revert", "undo-commit", "amend", "ignore", ]); /** Actions that would tangle with a merge, cherry-pick or revert in progress. */ const BLOCKED_WHILE_MERGING = new Set([ "pull", "switch", "merge", "stage-hunk", "discard-hunk", "cherry-pick", "revert", "undo-commit", "amend", ]); /** A commit as the request may name it: an abbreviated or full hex sha * (64 = a SHA-256 repository). */ const SHA_RE = /^[0-9a-f]{4,64}$/i; /** * Whether a rebase (started in a terminal) is stopped in this repo. It leaves * no *_HEAD the dashboard finishes, so it gets no banner and no button — but * nothing here may move HEAD or commit underneath it either. */ async function rebaseInProgress(dir) { for (const name of ["rebase-merge", "rebase-apply"]) { const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim(); if (fs.existsSync(path.resolve(dir, rel))) return true; } return false; } /** A line git writes to open or close a conflict block. */ const MARKER_RE = /^(<{7} |>{7} |={7}$)/m; /** A remote URL is at most this long — a forge URL, not a payload. */ const MAX_URL_LEN = 1024; /** `user@host:path`, git's scp-like ssh form. Nothing may start with `-`. */ const SCP_LIKE = /^[A-Za-z0-9._][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*:[A-Za-z0-9._~/][A-Za-z0-9._~/-]*$/; /** 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; } /** * The destination `publish` may push to, or an EBADURL throw. * * An allowlist, because this is the one write whose destination comes from * the request: `https://` with NO userinfo (a token typed into the URL would * land in .git/config in plain text), `ssh://`, and scp-like `user@host:path`. * Everything else is refused — a local path or `file://` (a push into some * other directory on this machine), `ext::` (git runs it as a command), * `fd::`, `git://` and `http://` (unauthenticated / cleartext), and anything * starting with `-` that could be read as an option. */ function validateRemoteUrl(url) { const refuse = () => bad("EBADURL", "url must be https://, ssh:// or user@host:path"); if (typeof url !== "string" || !url || url.length > MAX_URL_LEN) throw refuse(); // eslint-disable-next-line no-control-regex if (/[\s\x00-\x1f\x7f]/.test(url) || url.startsWith("-")) throw refuse(); if (SCP_LIKE.test(url)) return url; // WHATWG is more forgiving than git: it reads `https:host/x` or `https:\\host` // as https://host/x, where git sees no "://" and dials an ssh host named // "https". So the scheme must be spelled out literally, and the authority // may carry no `%` (git decodes `%2D` into the leading "-" refused below) // and no backslash — the string checked is the string git gets. const scheme = ["https://", "ssh://"].find((p) => url.startsWith(p)); if (!scheme || /[%\\]/.test(url.slice(scheme.length).split("/")[0])) throw refuse(); let parsed; try { parsed = new URL(url); } catch { throw refuse(); } const { protocol, username, password, hostname } = parsed; if (!hostname || hostname.startsWith("-") || password) throw refuse(); if (protocol === "https:" && !username) return url; if (protocol === "ssh:" && !username.startsWith("-")) return url; throw refuse(); } /** 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 path → `{code, from}` (`from` is a * rename's original path). `-z`, so a path git would C-quote (non-ASCII, a * space, a quote) matches the literal path the panel sends. * * 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 map = new Map(); for (const { code, path, from } of await laneStatus(dir)) map.set(path, { code, from }); return map; } /** * The remote a push goes to — never a name from the request: the current * branch's configured upstream remote, else the ONLY remote, else `origin`. * Anything else is ambiguous, so ENOREMOTE rather than a guess. */ async function pushRemote(dir) { const remotes = (await git(dir, ["remote"])).stdout.split("\n").filter(Boolean); const branch = ( await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" })) ).stdout.trim(); if (branch) { const upstream = ( await git(dir, ["config", "--get", `branch.${branch}.remote`]).catch(() => ({ stdout: "" })) ).stdout.trim(); if (remotes.includes(upstream)) return upstream; } if (remotes.length === 1) return remotes[0]; if (remotes.includes("origin")) return "origin"; throw bad( "ENOREMOTE", remotes.length ? "several remotes and no upstream or origin: set this branch's upstream first" : "this repository has no remote to push to" ); } /** Throw ESTAGEDOUTSIDE when the index holds a change outside the lane's cwd. */ async function assertNothingStagedOutside(dir) { const prefix = (await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim(); if (!prefix) return; // the lane IS the repo root: nothing is outside it // No --relative: root-relative names, so outside entries are visible. // --no-renames: a staged move INTO the lane is listed as its new path only // otherwise, and the deletion of the old path outside would slip through. const staged = (await git(dir, ["diff", "--cached", "--name-only", "--no-renames", "-z"])).stdout .split("\0") .filter(Boolean); const outside = staged.find((p) => !p.startsWith(prefix)); if (outside) { throw bad( "ESTAGEDOUTSIDE", `refusing to commit: ${outside} is staged outside this workspace's directory` ); } } /** 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, ...changed.get(p) })); } /** * A fetch/pull/push, authenticated with AgentHub's own git credentials * (git-credentials.js): a stored one for the remote's host, or * `body.credentials` typed for this one call. A login failure becomes * EGITAUTH carrying the host, which the git bar turns into a login form; a * typed credential is saved only after git accepted it and only if asked. */ async function networkGit(dir, args, body = {}) { const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null; const cfg = await gitCredentials.gitConfigFor(dir, typed); try { const out = await git(dir, [...cfg.args, ...args], { timeout: NETWORK_TIMEOUT_MS, env: cfg.env, }); if (typed?.remember) gitCredentials.save(typed); return out; } catch (e) { const auth = gitCredentials.authFailure(e.git?.stderr); if (!auth) throw e; const remote = (await gitCredentials.remoteHosts(dir)).find( (h) => h.protocol === auth.protocol && h.host === auth.host ); const stored = gitCredentials.find(auth.protocol, auth.host); const wrong = Boolean(typed) || Boolean(stored); throw Object.assign( new Error( wrong ? `${auth.host} rejected the username or token` : `${auth.host} needs a username and password or access token` ), { code: "EGITAUTH", auth: { ...auth, username: typed?.username || stored?.username || remote?.username || null, rejected: wrong, }, } ); } } /** * 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 (BLOCKED_WHILE_MERGING.has(action) || action === "merge-commit" || action === "merge-abort") { // A rebase blocks even Complete/Abort: `commit` mid-rebase would make a // commit the rebase never planned. It is finished where it was started. if (await rebaseInProgress(dir)) { throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first"); } const open = BLOCKED_WHILE_MERGING.has(action) && (await operationInProgress(dir)); if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`); } if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body); if (["cherry-pick", "revert", "undo-commit", "amend"].includes(action)) { return historyAction(dir, action, body); } if (action === "fetch") { return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body)); } 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 networkGit(dir, ["pull", "--ff-only"], body)); } 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. const remote = await pushRemote(dir); return done(action, await networkGit(dir, ["push", "-u", remote, "HEAD"], body)); } if (action === "publish") { // Gives a remote to a repo that has NONE, then does what `push` does. It // never re-points an existing remote, so it cannot redirect a repo that // already publishes somewhere, and the remote name is fixed like `push`'s. if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "make the first commit before publishing"); if ((await git(dir, ["remote"])).stdout.trim()) { throw bad("EREMOTEEXISTS", "this repo already has a remote"); } const url = validateRemoteUrl(body.url); await git(dir, ["remote", "add", "origin", "--", url]); let result; try { result = await networkGit(dir, ["push", "-u", "origin", "HEAD"], body); } catch (err) { // A remote left behind by a failed push (a typo, a missing credential) // would trip EREMOTEEXISTS forever and need a terminal to undo. await git(dir, ["remote", "remove", "origin"]).catch(() => {}); throw err; } return { ...done(action, result), remote: "origin" }; } if (action === "stage") { const paths = await resolvePaths(dir, body); // Staging a conflicted file marks it resolved: not while git's markers // are still in it (the same check `resolve` makes). for (const p of paths.filter((p) => CONFLICT_CODES.has(p.code))) { const file = path.join(dir, p.path); if ( fs.existsSync(file) && MARKER_RE.test(await require("./run-as").readFileSafeAsync(file, "utf8")) ) { throw bad("EMARKERS", `${p.path} still has conflict markers`); } } // `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)] : // A staged rename's ORIGINAL path rides along, as in `discard`: // restoring only the new one leaves the old path's deletion staged. [ "restore", "--staged", "--", ...paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [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); // A rename's ORIGINAL path rides along: restoring only the new one would // drop it from the index and the tree, and leave the old one deleted. const tracked = paths .filter((p) => p.code !== "??") .flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [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 === "stage-hunk" || action === "discard-hunk") { // Undoing a hunk is as irreversible as discarding a file: same gate. if (action === "discard-hunk" && body.confirm !== true) { throw bad("ECONFIRM", "discard-hunk requires confirm: true"); } const [target] = await resolvePaths(dir, { path: body.path }); if (target.code === "??") { throw bad("EUNTRACKED", "an untracked file has no hunks; stage or discard the whole file"); } // `block` (optional) picks one change block inside the hunk (#50). It is // an index into git's own fresh text, never text from the request. const patch = await hunkPatch( dir, target.path, typeof body.hunk === "string" ? body.hunk : "", body.context, body.block === undefined ? {} : { block: body.block, reverse: action === "discard-hunk" } ); if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry"); // The patch names paths from the repo root, so it is applied there. const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim(); const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : []; // stage: HEAD -> worktree hunk onto the index; undo: the same hunk, // reversed, off the working tree. A hunk the index or tree no longer // matches fails in git's own words (409), never half-applies. The patch // goes in on stdin — no file to write (S3: never one in a member's home). const args = action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero]; return done(action, await git(top, [...args, "-"], { input: patch })); } 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"); // `git commit` takes the WHOLE index, whatever the cwd, and a pathspec // would switch it to committing the working-tree copy (unstaged edits // included) — so a subdirectory lane refuses rather than sweep another // actor's staged work outside it into this lane's commit. // `paths` (the panel's ticked files): exactly those, whatever the index // holds — `--only` leaves every other change, staged or not, outside. if (hasHunks(body)) return done(action, await commitSelection(dir, ["-m", message], body)); if (Array.isArray(body.paths)) return done(action, await commitPaths(dir, ["-m", message], body)); await assertNothingStagedOutside(dir); 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 { // The LANE's status, not the repo's: `add -A -- .` below only takes the // lane's files, so a clean subdirectory lane in a repo dirty elsewhere // has nothing to commit — not a git failure after the fact. if (!(await laneStatus(dir)).length) throw bad("ENOCHANGES", "nothing to commit"); // `-- .`: the lane's own directory, not the whole repo it sits in. 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 === "ignore") return ignorePaths(dir, body); 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); } /** Paths git still holds as unmerged, repo-wide. */ async function unmergedPaths(dir) { return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout .split("\0") .filter(Boolean); } /** merge / resolve / merge-commit / merge-abort. */ async function mergeAction(dir, action, body) { if (action === "merge") { const branch = typeof body.branch === "string" ? body.branch.trim() : ""; if (!(await isValidBranchName(dir, branch))) { throw bad("EBADBRANCH", "branch is not a valid git branch name"); } await assertCleanRepoRoot(dir, "a merge"); // The ref is looked up, never passed through: git sees the full refname // of a local or remote-tracking branch that exists, nothing else. const ref = await branchRef(dir, branch); if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`); return startOperation(dir, action, ["merge", "--no-edit", ref], branch); } if (action === "resolve") { const [target] = await resolvePaths(dir, { path: body.path }); if (!CONFLICT_CODES.has(target.code)) throw bad("EBADPATH", `not a conflicted path: ${target.path}`); const side = body.side; if (!["ours", "theirs", "done"].includes(side)) throw bad("EBADSIDE", "side must be ours, theirs or done"); const p = target.path; const gone = (side === "ours" && target.code[0] === "D") || (side === "theirs" && target.code[1] === "D"); if (side !== "done" && !gone) { await git(dir, ["checkout", `--${side}`, "--", p]); return done(action, await git(dir, ["add", "--", p])); } if (side === "done" && fs.existsSync(path.join(dir, p))) { if (MARKER_RE.test(await require("./run-as").readFileSafeAsync(path.join(dir, p), "utf8"))) { throw bad("EMARKERS", `${p} still has conflict markers`); } return done(action, await git(dir, ["add", "--", p])); } // The chosen side deleted it (or the hand edit did): resolved as gone. return done(action, await git(dir, ["rm", "-q", "--", p])); } if (action === "merge-abort" && body.confirm !== true) { throw bad("ECONFIRM", "merge-abort requires confirm: true"); } const open = await operationInProgress(dir); if (!open) throw bad("ENOMERGE", "no merge, cherry-pick or revert in progress"); if (action === "merge-abort") return done(action, await git(dir, [open, "--abort"])); // merge-commit const left = await unmergedPaths(dir); if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`); // `commit` finishes all three: it takes MERGE_MSG, a pick keeps its // original author, and git clears the *_HEAD it was holding. return done(action, await git(dir, ["commit", "--no-edit"])); } /** * A merge, cherry-pick or revert moves the whole repo, so only a lane that * IS the repo may start one — a subdirectory lane could not see, let alone * resolve, a conflict outside its own folder — and only on a clean tree, so * an abort can never take an agent's uncommitted work with it. */ async function assertCleanRepoRoot(dir, what) { if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) { throw bad( "ESUBDIR", `${what} changes the whole repository; run it from a workspace at the repo root` ); } if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes"); } /** Run a merge/cherry-pick/revert; a conflict is one that STARTED, not a failure. */ async function startOperation(dir, action, args, branch) { try { return { ...done(action, await git(dir, args), branch), conflicts: [] }; } catch (e) { if (!(await operationInProgress(dir))) throw e; return { ...done(action, e.git || { stdout: "", stderr: "" }, branch), conflicts: await unmergedPaths(dir), }; } } /** `body.sha` as the full sha of a commit that exists, or EBADSHA. */ async function commitSha(dir, sha) { if (typeof sha !== "string" || !SHA_RE.test(sha)) throw bad("EBADSHA", "sha must be a commit id"); const full = await git(dir, ["rev-parse", "-q", "--verify", `${sha}^{commit}`]).then( (r) => r.stdout.trim(), () => "" ); if (!full) throw bad("EBADSHA", `no commit ${sha}`); return full; } /** How many parents a commit has (0 = root, 2+ = a merge commit). */ async function parentCount(dir, sha) { return ( (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1 ); } /** * Refuse to rewrite HEAD unless it is still the commit the user confirmed * (`sha` — an agent may have committed since the dialog was drawn), it is not * on any remote-tracking branch, and the lane is the repo root (the rewrite * moves the whole branch, outside a subdirectory lane too). */ async function assertUnpushedHead(dir, sha) { if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "there is no commit yet"); const seen = await commitSha(dir, sha); if (seen !== (await git(dir, ["rev-parse", "HEAD"])).stdout.trim()) { throw bad("ESTALE", "the last commit changed since you looked: refresh and retry"); } if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) { throw bad("ESUBDIR", "rewriting a commit moves the whole branch; use the repo-root workspace"); } const on = ( await git(dir, [ "for-each-ref", "--contains", "HEAD", "--format=%(refname:short)", "refs/remotes", ]) ).stdout.trim(); if (on) { throw bad("EPUSHED", `the last commit is already on ${on.split("\n")[0]}; revert it instead`); } } /** cherry-pick / revert / undo-commit / amend. */ async function historyAction(dir, action, body) { if (action === "cherry-pick" || action === "revert") { const sha = await commitSha(dir, body.sha); // A merge commit needs `-m ` to mean anything; picking which side // is a terminal decision, not a menu item. if ((await parentCount(dir, sha)) > 1) { throw bad("EMERGECOMMIT", `${action} of a merge commit is not supported here`); } await assertCleanRepoRoot(dir, `a ${action}`); const args = action === "revert" ? ["revert", "--no-edit", sha] : ["cherry-pick", sha]; try { return { ...done(action, await git(dir, args)), conflicts: [] }; } catch (e) { const open = await operationInProgress(dir); const conflicts = open ? await unmergedPaths(dir) : []; // Conflicts: an operation that STARTED, finished from the banner. if (conflicts.length) return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts }; // Nothing to resolve, so nothing may be left behind either: a pick // whose changes are already here (EEMPTY), or a commit git could not // make (no identity, a failing signer) — which can leave the changes // staged with no *_HEAD and so no Abort. The tree was clean before, so // the commit's own paths go back to HEAD; nothing else is touched. const empty = await git(dir, ["diff", "--cached", "--quiet"]).then( () => true, () => false ); if (open) await git(dir, [open, "--abort"]).catch(() => {}); const touched = ( await git(dir, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", sha]) ).stdout .split("\0") .filter(Boolean); if (touched.length) { await git(dir, [ "restore", "--source=HEAD", "--staged", "--worktree", "--", ...touched, ]).catch(() => {}); } if (empty) throw bad("EEMPTY", "those changes are already on this branch"); throw e; } } if (action === "undo-commit") { if (body.confirm !== true) throw bad("ECONFIRM", "undo-commit requires confirm: true"); await assertUnpushedHead(dir, body.sha); const parents = await parentCount(dir, "HEAD"); if (parents === 0) throw bad("ENOPARENT", "the first commit has nothing to go back to"); if (parents > 1) throw bad("EMERGECOMMIT", "undoing a merge commit is not supported here"); // --soft: only the branch moves. The commit's changes stay staged and the // working tree (an agent's live edits included) is never touched. return done(action, await git(dir, ["reset", "--soft", "HEAD~1"])); } // amend: the index as it stands folds into the last commit, like `commit` // with `only: "staged"`; no message keeps the old one. await assertUnpushedHead(dir, body.sha); const message = typeof body.message === "string" ? body.message.trim() : ""; if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long"); const msgArgs = message ? ["-m", message] : ["--no-edit"]; if (hasHunks(body)) { return done(action, await commitSelection(dir, ["--amend", ...msgArgs], body)); } if (Array.isArray(body.paths)) { // No ticked file: `--only` with no path rewords and leaves the index out. if (!body.paths.length) { if (!message) throw bad("ENOCHANGES", "no file ticked and no new message to amend with"); return done(action, await git(dir, ["commit", "--amend", "--only", ...msgArgs])); } return done(action, await commitPaths(dir, ["--amend", ...msgArgs], body)); } await assertNothingStagedOutside(dir); if (!message) { const staged = [...(await changedPaths(dir)).values()].some( ({ code }) => code[0] !== " " && code[0] !== "?" ); if (!staged) throw bad("ENOCHANGES", "nothing staged and no new message to amend with"); } return done(action, await git(dir, ["commit", "--amend", ...msgArgs])); } /** * `git commit --only -- `: the working-tree content of exactly the * paths the request ticked (JetBrains' commit), each one `git status` reports * changed. An untracked one is added first — `--only` takes only paths git * knows — and taken back out of the index if the commit then fails. */ async function commitPaths(dir, args, body) { const paths = await resolvePaths(dir, body); const conflicted = paths.find((p) => CONFLICT_CODES.has(p.code)); if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`); const untracked = paths.filter((p) => p.code === "??").map((p) => p.path); // A rename's original path rides along, or its deletion stays behind. const all = paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])); if (untracked.length) await git(dir, ["add", "--", ...untracked]); try { return await git(dir, ["commit", ...args, "--only", "--", ...all]); } catch (e) { if (untracked.length) await git(dir, ["rm", "--cached", "-q", "--", ...untracked]).catch(() => {}); throw e; } } /** Whether the request names change blocks (`hunks`), not just whole files. */ function hasHunks(body) { return !!body.hunks && typeof body.hunks === "object" && Object.keys(body.hunks).length > 0; } /** Most files one hunk-level commit may name partially. */ const MAX_PARTIAL_FILES = 300; /** * JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`, * only the ticked change blocks — `{"": [{hunk: "@@ … @@", blocks: [0]}]}`, * the hunks named as `stage-hunk` names them, at `context`. Built in a * TEMPORARY index (HEAD + the whole files + one patch per partial file from * `selectionPatch`, git's own fresh text, never the request's) and committed * from it, so the real index and the working tree are never in the way; after * the commit the real index entries of exactly those paths are reset to the * new HEAD, the rest of each partial file left as an unstaged change. Same * path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED) * and not a rename, and a hunk the tree no longer has is EHUNKGONE. */ async function commitSelection(dir, args, body) { const picks = Object.entries(body.hunks); if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files"); for (const [, list] of picks) { const ok = Array.isArray(list) && list.length > 0 && list.every( (x) => x && typeof x.hunk === "string" && Array.isArray(x.blocks) && x.blocks.length > 0 && x.blocks.every((b) => Number.isInteger(b) && b >= 0) ); if (!ok) throw bad("EBADPATH", "hunks: each file needs [{hunk, blocks: [n, …]}]"); } const whole = Array.isArray(body.paths) && body.paths.length ? await resolvePaths(dir, body) : []; const part = await resolvePaths(dir, { paths: picks.map(([p]) => p) }); const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code)); if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`); for (const p of part) { if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`); if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`); if (whole.some((w) => w.path === p.path)) { throw bad("EBADPATH", `${p.path} is named both whole and in part`); } } const wholePaths = whole.flatMap((p) => p.from && p.code.includes("R") ? [p.path, p.from] : [p.path] ); const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim(); // A temporary index git itself creates (S3: as the member, in their own // handoff dir — the dashboard never writes there). const scratch = require("./run-as").scratchFile(top, "agenthub-index-"); const env = { GIT_INDEX_FILE: scratch.file }; try { await git(dir, (await hasHead(dir)) ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], { env, }); if (wholePaths.length) await git(dir, ["add", "-A", "--", ...wholePaths], { env }); const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : []; for (const [p, list] of picks) { const patch = await selectionPatch(dir, p, body.context, list); if (!patch) throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`); // The patch names paths from the repo root, so it is applied there. await git(top, ["apply", "--cached", ...zero, "-"], { env, input: patch }); } const out = await git(dir, ["commit", ...args], { env }); await git(dir, ["reset", "-q", "--", ...wholePaths, ...part.map((p) => p.path)]).catch( () => {} ); return out; } finally { scratch.remove(); } } /** A path as one literal .gitignore line, anchored at the file's folder. */ function ignoreLine(p) { return `/${p.replace(/[\\*?[]/g, "\\$&").replace(/ $/, "\\ ")}`; } /** * Add to .gitignore: one anchored line per path in the WORKSPACE's own * .gitignore (its cwd — a subdirectory workspace never writes above itself), * only for a path `git status` lists as untracked, never a line it already * has, and never through a symlink or onto anything but a regular file. */ async function ignorePaths(dir, body) { const paths = await resolvePaths(dir, body); const tracked = paths.find((p) => p.code !== "??"); if (tracked) throw bad("ETRACKED", `${tracked.path} is tracked: only an untracked file can be ignored`); // A line break in a name would write a second, arbitrary pattern. if (paths.some((p) => /[\r\n]/.test(p.path))) throw bad("EBADPATH", "a path with a line break cannot be ignored"); const file = path.join(dir, ".gitignore"); const st = await fs.promises.lstat(file).catch(() => null); if (st && !st.isFile()) throw bad("EBADPATH", ".gitignore is not a regular file"); const old = st ? await require("./run-as").readFileSafeAsync(file, "utf8") : ""; const have = new Set(old.split(/\r?\n/)); const lines = [...new Set(paths.map((p) => ignoreLine(p.path)))].filter((l) => !have.has(l)); if (lines.length) { const sep = old && !old.endsWith("\n") ? "\n" : ""; // As the folder's member under S3 (the dashboard cannot write there). require("./run-as").writeFileAs(file, `${sep}${lines.join("\n")}\n`, { append: true }); } return done("ignore", { stdout: lines.map((l) => `${l}\n`).join(""), stderr: "" }); } /** 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, validateRemoteUrl, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };