287 lines
11 KiB
JavaScript
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 };
|