cb19d7e5d2
Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):
- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
/:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
(git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
the session menu, pinned first); text size setting (lib/fontSize.ts,
Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().
Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
961 lines
42 KiB
JavaScript
961 lines
42 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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);
|
|
let ok = false;
|
|
try {
|
|
const out = await git(dir, [...cfg.args, ...args], {
|
|
timeout: NETWORK_TIMEOUT_MS,
|
|
env: cfg.env,
|
|
});
|
|
ok = true;
|
|
// A member's typed login is already in their own store (finish keeps it).
|
|
if (typed?.remember && !cfg.member) 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 = cfg.member
|
|
? cfg.stored(auth.protocol, auth.host)
|
|
: 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,
|
|
},
|
|
}
|
|
);
|
|
} finally {
|
|
cfg.finish?.(ok);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
// git names the merge after the ref it was GIVEN, so the full refname
|
|
// above made every subject "Merge branch 'refs/heads/x'". The message
|
|
// git's own `git merge x` writes, as one argv element.
|
|
const current = (await git(dir, ["branch", "--show-current"])).stdout.trim();
|
|
const into = current && !["main", "master"].includes(current) ? ` into ${current}` : "";
|
|
const kind = ref.startsWith("refs/remotes/") ? "remote-tracking branch" : "branch";
|
|
const message = `Merge ${kind} '${branch}'${into}`;
|
|
return startOperation(dir, action, ["merge", "--no-edit", "-m", message, 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 <parent>` 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 -- <paths>`: 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 — `{"<path>": [{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 };
|