Files
AgentHub/server/lib/git-pick.js
T

287 lines
11 KiB
JavaScript

/**
* @file The multi-commit cherry-pick / revert WRITES, and the buttons of the
* banner a paused one shows (like GitKraken, GitLens and JetBrains):
*
* - `cherry-pick` / `revert` with `shas: [...]` — 1 to 50 non-merge
* commits, applied in git's own sequencer (`git cherry-pick A B C`) in
* the order `pickPlan` fixes (a pick oldest → newest, a revert newest →
* oldest). The single-`sha` body stays git-ops.js's, unchanged.
* - `pick-continue` — `git <op> --continue` once no file is unmerged.
* - `pick-skip` — `git <op> --skip`, `confirm: true` (it drops the
* resolutions made for the commit it stopped at).
* - `pick-abort` — `git <op> --abort`, `confirm: true`: back to where the
* operation started. Unlike `merge-abort` it also ends a sequence whose
* *_HEAD is gone (a commit made in between) but whose todo is not.
* - `undo-pick` — `reset --soft <from>` after a pick/revert this panel ran,
* `confirm: true`: only while HEAD is still the `to` it produced
* (ESTALE), `from` is its ancestor, nothing in between is a merge or on
* any remote-tracking branch (EPUSHED), from the repo root.
*
* The guards are git-ops.js's: argv only, a sha validated and resolved before
* git sees it as a revision, a clean tree and the repo-root lane to start one
* (an abort can then never take uncommitted work with it), nothing while a
* rebase or another operation is open (EMERGING), and the route's lane lock.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const worktree = require("./worktree");
const history = require("./git-history");
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
/** The actions this module owns (git-ops.js dispatches them here). */
const PICK_ACTIONS = new Set(["pick-continue", "pick-skip", "pick-abort", "undo-pick"]);
/** A commit id: abbreviated or full. */
const SHA_RE = /^[0-9a-f]{4,64}$/i;
/** Most commits one `undo-pick` takes back. */
const MAX_UNDO = 50;
/** `--continue` takes the message git prepared; never an editor. */
const NO_EDITOR = { env: { GIT_EDITOR: "true" } };
function bad(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
function out(result) {
return [result?.stdout, result?.stderr].filter(Boolean).join("").trim();
}
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;
}
async function unmergedPaths(dir) {
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
.split("\0")
.filter(Boolean);
}
async function head(dir) {
return (await git(dir, ["rev-parse", "HEAD"])).stdout.trim();
}
async function assertRepoRoot(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`
);
}
}
/** The cherry-pick or revert open here (by *_HEAD, else by the sequencer). */
async function openPick(dir) {
const op = await worktree.operationInProgress(dir);
if (op === "cherry-pick" || op === "revert") return op;
// A merge or a rebase (the rewrite tools' own) is never a pick here.
if (op) return null;
const todo = await history.sequencerTodo(dir);
if (!todo.length) return null;
return todo[0].verb === "revert" ? "revert" : "cherry-pick";
}
/** The commits `from..to` made, oldest first. */
async function madeBetween(dir, from, to) {
if (!from || from === to) return [];
const { stdout } = await git(dir, [
"log",
"--reverse",
`--max-count=${MAX_UNDO * 2}`,
"--format=%H%x1f%h%x1f%s%x1e",
`${from}..${to}`,
]);
return stdout
.split("\x1e")
.map((r) => r.replace(/^\n/, ""))
.filter((r) => r.trim())
.map((r) => {
const [sha, short, subject] = r.split("\x1f");
return { sha, short, subject };
});
}
/** What every answer carries: where it started, where HEAD is, what it made. */
async function result(dir, action, gitResult, from, extra = {}) {
const to = await head(dir).catch(() => null);
const state = await history.sequenceState(dir);
return {
action,
ok: true,
output: out(gitResult),
from: state.active && state.startedFrom ? state.startedFrom : from,
to,
commits: await madeBetween(
dir,
state.active && state.startedFrom ? state.startedFrom : from,
to
),
conflicts: state.active ? state.conflicts : [],
paused: !!state.active,
...extra,
};
}
/** `cherry-pick` / `revert` with `shas`: one sequence, in pickPlan's order. */
async function startSequence(dir, action, body) {
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
const open = (await worktree.operationInProgress(dir)) || (await openPick(dir));
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
const commits = await history.pickPlan(dir, action, body.shas);
await assertRepoRoot(dir, `a ${action}`);
const { dirty, untracked } = await worktree.statusCounts(dir);
if (dirty + untracked > 0) throw bad("EDIRTY", "working tree has uncommitted changes");
const from = await head(dir);
const shas = commits.map((c) => c.sha);
const args = action === "revert" ? ["revert", "--no-edit", ...shas] : ["cherry-pick", ...shas];
try {
const r = await git(dir, args, NO_EDITOR);
return result(dir, action, r, from, { applied: commits });
} catch (e) {
const state = await history.sequenceState(dir);
if (state.active) {
// Stopped on a conflict, or on a commit whose changes are already here
// (no conflict, nothing to commit): paused, finished from the banner.
const empty = !state.conflicts.length;
return result(dir, action, e.git, from, { applied: commits, ...(empty ? { empty } : {}) });
}
// Nothing paused: git refused outright (no identity, a failing signer).
// The tree was clean before, so the commits' own paths go back to HEAD —
// nothing else is touched, and no reset runs (git-ops.js's single-sha
// path does the same).
const touched = new Set();
for (const sha of shas) {
const r = await git(dir, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", sha]);
for (const p of r.stdout.split("\0")) if (p) touched.add(p);
}
if (touched.size) {
await git(dir, [
"restore",
"--source=HEAD",
"--staged",
"--worktree",
"--",
...touched,
]).catch(() => {});
}
throw e;
}
}
/** pick-continue / pick-skip / pick-abort. */
async function sequenceAction(dir, action, body) {
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
if ((action === "pick-abort" || action === "pick-skip") && body.confirm !== true) {
throw bad("ECONFIRM", `${action} requires confirm: true`);
}
const op = await openPick(dir);
if (!op) throw bad("ENOMERGE", "no cherry-pick or revert in progress");
const from = await history.sequencerHead(dir);
if (action === "pick-abort") {
const r = await git(dir, [op, "--abort"]);
return { action, ok: true, output: out(r), to: await head(dir), conflicts: [], paused: false };
}
if (action === "pick-continue") {
const left = await unmergedPaths(dir);
if (left.length) {
throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
}
}
try {
const r = await git(dir, [op, action === "pick-skip" ? "--skip" : "--continue"], NO_EDITOR);
return result(dir, action, r, from);
} catch (e) {
const state = await history.sequenceState(dir);
if (state.active && state.conflicts.length) return result(dir, action, e.git, from);
if (state.active && action === "pick-continue") {
// Resolved to exactly what the branch already had: nothing to commit.
const staged = await git(dir, ["diff", "--cached", "--quiet"]).then(
() => false,
() => true
);
if (!staged) throw bad("EEMPTY", "this commit's changes are already here: skip it instead");
}
if (state.active) return result(dir, action, e.git, from, { empty: true });
throw e;
}
}
/** undo-pick: `reset --soft <from>`, only over what the pick itself made. */
async function undoPick(dir, body) {
if (body.confirm !== true) throw bad("ECONFIRM", "undo-pick requires confirm: true");
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
const open = (await worktree.operationInProgress(dir)) || (await openPick(dir));
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
for (const k of ["from", "to"]) {
if (typeof body[k] !== "string" || !SHA_RE.test(body[k])) {
throw bad("EBADSHA", `${k} must be a commit id`);
}
}
const resolve = (s) =>
git(dir, ["rev-parse", "-q", "--verify", `${s}^{commit}`]).then(
(r) => r.stdout.trim(),
() => ""
);
const from = await resolve(body.from);
const to = await resolve(body.to);
if (!from || !to) throw bad("EBADSHA", "no such commit");
if (to !== (await head(dir))) {
throw bad("ESTALE", "the branch moved since the pick: refresh and retry");
}
await assertRepoRoot(dir, "undoing a pick");
const ancestor = await git(dir, ["merge-base", "--is-ancestor", from, to]).then(
() => true,
() => false
);
if (!ancestor || from === to) throw bad("EBADSHA", "from is not before to on this branch");
const made = await madeBetween(dir, from, to);
if (!made.length || made.length > MAX_UNDO) {
throw bad("EBADSHA", `undo takes back 1 to ${MAX_UNDO} commits`);
}
if ((await git(dir, ["rev-list", "--merges", `${from}..${to}`])).stdout.trim()) {
throw bad("EMERGECOMMIT", "a merge commit is in that range");
}
// Every later commit contains the first one, so a remote holding any of
// them holds the first.
const on = (
await git(dir, [
"for-each-ref",
"--contains",
made[0].sha,
"--format=%(refname:short)",
"refs/remotes",
])
).stdout.trim();
if (on) {
throw bad("EPUSHED", `those commits are already on ${on.split("\n")[0]}; revert them instead`);
}
// --soft: only the branch moves; the changes stay staged, the working tree
// (an agent's live edits included) is never touched.
const r = await git(dir, ["reset", "--soft", from]);
return { action: "undo-pick", ok: true, output: out(r), to: from, undone: made };
}
/** Entry point from git-ops.js's `runGitAction`. */
async function runPickAction(dir, action, body = {}) {
if (action === "undo-pick") return undoPick(dir, body);
if (PICK_ACTIONS.has(action)) return sequenceAction(dir, action, body);
return startSequence(dir, action, body);
}
module.exports = { runPickAction, PICK_ACTIONS };