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 <sha>.") 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] });
}