import { agentCommitter, agentKeyFromEmail, type Actor } from "./actor"; import { db } from "./db"; import type { RepoEvent } from "./events"; import { branchesContaining, commitsBetween, defaultBranch, diffRangeText, findRevertOf, getCommit, revParse } from "./git"; import { applyPatchToTree, treeOf, updateBranch, writeCommit } from "./git-write"; import { assertCanCommit, ProtectionError } from "./protection"; import { createPull } from "./pulls"; import { afterRefsUpdated } from "./push"; import { markPushed, type RepoRecord } from "./repos"; import { getUserById } from "./users"; export class RevertError extends Error {} type RevertInput = { branch: string; /** The changes from `before` to `after` are undone. */ before: string; after: string; /** "Revert Claude Code's push to main" */ title: string; /** The commits being reverted, named in the message ("This reverts commit .") like git revert does. */ commits: string[]; }; /** * Undoes the changes from `before` to `after` with a new commit on top of the branch as it is now * (like `git revert`, merging three ways if the files changed since). If the branch is protected, * the revert goes to a branch of its own with a pull request instead. */ async function revertChanges(repo: RepoRecord, actor: Actor, input: RevertInput) { const ref = `refs/heads/${input.branch}`; const head = await revParse(repo.dir, ref); if (!head) throw new RevertError(`${input.branch} doesn't exist anymore.`); const patch = await diffRangeText(repo.dir, input.before, input.after, [], { binary: true }); if (!patch.trim()) throw new RevertError("There are no file changes to undo."); const tree = await applyPatchToTree(repo.dir, head, patch, { reverse: true }).catch((e: Error) => { throw new RevertError(e.message); }); if (tree === (await treeOf(repo.dir, head))) throw new RevertError("Those changes were undone already."); const user = (await getUserById(actor.userId))!; const person = { name: user.name || user.username, email: user.email }; const listed = input.commits.slice(0, 100).map((c) => `This reverts commit ${c}.`); if (input.commits.length > 100) listed.push(`…and ${input.commits.length - 100} more, from ${input.before.slice(0, 7)} to ${input.after.slice(0, 7)}.`); const message = `${input.title}\n\n${listed.join("\n")}\n`; const commit = await writeCommit(repo.dir, { tree, parents: [head], message, author: person, committer: actor.agent ? agentCommitter(actor.agent) : person }); let target = input.branch; let pull: number | null = null; try { await assertCanCommit(repo, actor, input.branch, head); } catch (e) { if (!(e instanceof ProtectionError)) throw e; target = `revert/${input.after.slice(0, 7)}`; } await updateBranch(repo.dir, target, commit, target === input.branch ? head : null, "revert"); await markPushed(repo.id); await afterRefsUpdated(repo, new Map(target === input.branch ? [[ref, head]] : []), new Map([[`refs/heads/${target}`, commit]]), actor); if (target !== input.branch) { pull = (await createPull(repo, actor, { head: target, base: input.branch, title: input.title, body: message, draft: false })).number; } return { commit, branch: target, pull }; } /** Undoes everything a push added to a branch. */ export async function revertPush(repo: RepoRecord, actor: Actor, event: RepoEvent) { const p = event.payload; const ref = String(p.ref ?? ""); const before = String(p.before ?? ""); const after = String(p.after ?? ""); if (!ref.startsWith("refs/heads/") || !before || !after) throw new RevertError("Only pushes that added commits to a branch can be undone."); const branch = ref.slice(11); const commits = (await commitsBetween(repo.dir, before, after, 1000)).map((c) => c.sha); const who = event.agent ? `${event.agent.name}'s` : "the"; const result = await revertChanges(repo, actor, { branch, before, after, title: `Revert ${who} push to ${branch}`, commits }); await (await db()).collection("events").update(event.id, { payload: { ...p, reverted: { ...result, at: new Date().toISOString() } } }); return result; } /** * Undoes one commit on a branch that has it (the default branch if it does). Merge commits are * undone against their first parent, like `git revert -m 1`. */ export async function revertCommit(repo: RepoRecord, actor: Actor, sha: string, branch?: string) { const commit = await getCommit(repo.dir, sha); if (!commit) throw new RevertError(`There's no commit ${sha}.`); if (!commit.parents.length) throw new RevertError("The first commit of a repository can't be reverted."); const containing = await branchesContaining(repo.dir, commit.sha); const main = await defaultBranch(repo.dir); const target = branch ?? (main && containing.includes(main) ? main : containing[0]); if (!target || !containing.includes(target)) throw new RevertError(branch ? `${branch} doesn't contain ${commit.sha.slice(0, 7)}.` : `No branch contains ${commit.sha.slice(0, 7)}.`); const done = await findRevertOf(repo.dir, `refs/heads/${target}`, commit.sha); if (done) throw new RevertError(`${commit.sha.slice(0, 7)} was reverted already, in ${done.slice(0, 7)}.`); const agent = agentKeyFromEmail(commit.committerEmail) ? `${commit.committerName}'s ` : ""; return revertChanges(repo, actor, { branch: target, before: commit.parents[0], after: commit.sha, title: `Revert ${agent}"${commit.subject}"`, commits: [commit.sha] }); }