Vectrum
Sign In
96 lines · 5.4 KB · TypeScript
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] });
}