Vectrum
Sign In
93 lines · 4.5 KB · TypeScript
import { agentCommitter, type Actor } from "./actor";
import { emitEvent } from "./events";
import { countBetween, defaultBranch, fetchBranch, isAncestor, mergeTrees, revParse } from "./git";
import { updateBranch, writeCommit } from "./git-write";
import { assertRefUpdates } from "./protection";
import { afterRefsUpdated } from "./push";
import { findRepoById, forkRepo, markPushed, repoAccess, RepoError, type RepoRecord } from "./repos";
import { getUserById, type User } from "./users";

/*
 * Forks: copies of a repository that keep a link to it. Changes go back to it through pull requests
 * from the fork's branches (lib/pulls.ts), and the fork catches up with it by syncing its default
 * branch. Used by the web UI and MCP, so nothing from next/* here.
 */

/** Forks `source` for `owner` and tells the source's feed (and webhooks) about it. */
export async function createFork(owner: User, source: RepoRecord, input: { name: string; description: string; defaultBranchOnly: boolean }, actor: Actor) {
  const fork = await forkRepo(owner, source, input);
  await emitEvent(source, {
    kind: "fork",
    actor,
    subject: `${fork.owner}/${fork.name}`,
    payload: { forkee: { full_name: `${fork.owner}/${fork.name}`, owner: fork.owner, name: fork.name, visibility: fork.visibility } },
  });
  return fork;
}

export type ForkStatus = {
  /** The repository it was forked from. */
  upstream: RepoRecord;
  /** The fork's default branch, and the upstream's. */
  branch: string;
  upstreamBranch: string;
  /** Their commits: both are in the fork. */
  sha: string;
  upstreamSha: string;
  /** Commits the fork's branch has that the upstream's doesn't, and the other way around. */
  ahead: number;
  behind: number;
};

/**
 * How a fork's default branch compares with the default branch of the repository it was forked
 * from, or null when there's nothing to compare (an empty side, or an upstream `viewerId` can't read).
 * Fetches the upstream's new commits into the fork to count them.
 */
export async function forkStatus(fork: RepoRecord, viewerId: string | null): Promise<ForkStatus | null> {
  const upstream = fork.forkOf && (await findRepoById(fork.forkOf.id));
  if (!upstream || !(await repoAccess(upstream, viewerId))) return null;
  const [branch, upstreamBranch] = await Promise.all([defaultBranch(fork.dir), defaultBranch(upstream.dir)]);
  if (!branch || !upstreamBranch) return null;
  const sha = await revParse(fork.dir, `refs/heads/${branch}`);
  const upstreamSha = await fetchBranch(fork.dir, upstream.dir, upstreamBranch).catch(() => null);
  if (!sha || !upstreamSha) return null;
  const [ahead, behind] = await Promise.all([countBetween(fork.dir, upstreamSha, sha), countBetween(fork.dir, sha, upstreamSha)]);
  return { upstream, branch, upstreamBranch, sha, upstreamSha, ahead, behind };
}

/**
 * Brings the fork's default branch up to date with the upstream's: a fast-forward, or a merge
 * commit when the fork has commits of its own. Conflicts have to be resolved by hand.
 */
export async function syncFork(fork: RepoRecord, actor: Actor) {
  const status = await forkStatus(fork, actor.userId);
  if (!status) throw new RepoError("There's nothing to sync this repository with.");
  if (status.behind === 0) return { updated: false, sha: null };
  const ref = `refs/heads/${status.branch}`;
  const { sha: head, upstreamSha } = status;
  const fastForward = await isAncestor(fork.dir, head, upstreamSha);
  let commit = upstreamSha;
  if (!fastForward) {
    const merged = await mergeTrees(fork.dir, head, upstreamSha);
    if (!merged.clean) {
      throw new RepoError(
        `${status.branch} and ${status.upstream.owner}/${status.upstream.name}:${status.upstreamBranch} change the same lines (${merged.conflicts.slice(0, 5).join(", ")}). Merge them on your machine.`,
      );
    }
    const user = (await getUserById(actor.userId))!;
    const person = { name: user.name || user.username, email: user.email };
    commit = await writeCommit(fork.dir, {
      tree: merged.tree,
      parents: [head, upstreamSha],
      message: `Merge branch '${status.upstreamBranch}' of ${status.upstream.owner}/${status.upstream.name}\n`,
      author: person,
      committer: actor.agent ? agentCommitter(actor.agent) : person,
    });
  }
  await assertRefUpdates(fork, actor, [{ ref, old: head, new: commit, fastForward: true }]);
  await updateBranch(fork.dir, status.branch, commit, head, "sync fork");
  await markPushed(fork.id);
  await afterRefsUpdated(fork, new Map([[ref, head]]), new Map([[ref, commit]]), actor);
  return { updated: true, sha: commit };
}