/** * @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 --continue` once no file is unmerged. * - `pick-skip` — `git --skip`, `confirm: true` (it drops the * resolutions made for the commit it stopped at). * - `pick-abort` — `git --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 ` 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ĩ */ 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 `, 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 };