import { first } from "./db"; import { repoDb } from "./scoped-db"; import { loadRepoById } from "./repos"; import { withStore } from "./git/store"; import { isAncestor } from "./git/history"; import { pushToArtifacts, PushRejectedError } from "./git/writer"; import { addIssueEvent, loadPullByIssueId, PULL_SELECT } from "./pulls"; import type { PullRow } from "./pulls"; import { planRebase, RebaseConflictError } from "./pull-rebase"; import type { MergeMethod } from "~/lib/types"; /** * Stacked pull requests. A pull request is stacked when its base branch is the head branch of another * open pull request in the same repository, so stacks need no extra bookkeeping and work with any * client (git, gt, jj, git-town) that sets the base. When a parent merges, its children are retargeted * onto the parent's base and, if the merge rewrote history (squash or rebase), restacked so they keep * showing only their own commits. */ const MAX_STACK_DEPTH = 25; const MAX_STACK_SIZE = 50; const MAX_CHILDREN = 20; const SYSTEM_COMMITTER = { name: "gitfor", email: "stacks@noreply.gitfor" }; export type StackEntry = { issueId: string; number: number; title: string; state: "open" | "closed"; merged: boolean; draft: boolean; headRef: string; baseRef: string; /** Distance from the bottom of the stack; siblings share a depth. */ depth: number; current: boolean; }; export type StackLoaders = { parent(pull: PullRow): Promise; children(pull: PullRow): Promise; }; /** The open pull request whose head branch this one targets, if any. The default branch is never a stack. */ export async function stackParent(pull: PullRow): Promise { const defaultBranch = await defaultBranchOf(pull.repo_id); if (defaultBranch === null || pull.base_ref === defaultBranch) return null; return repoDb(pull.repo_id).first( `${PULL_SELECT} WHERE p.head_repo_id = ?1 AND p.head_ref = ?2 AND i.repo_id = ?1 AND i.state = 'open' AND p.issue_id != ?3 LIMIT 1`, pull.repo_id, pull.base_ref, pull.issue_id, ); } async function defaultBranchOf(repoId: string): Promise { const row = await first<{ default_branch: string }>("SELECT default_branch FROM repos WHERE id = ?", repoId); return row?.default_branch ?? null; } /** Open pull requests that target this one's head branch. Only same-repository heads can be bases. */ export async function stackChildren(pull: PullRow): Promise { if (pull.head_repo_id !== pull.repo_id) return []; const defaultBranch = await defaultBranchOf(pull.repo_id); if (defaultBranch === null || pull.head_ref === defaultBranch) return []; return repoDb(pull.repo_id).all( `${PULL_SELECT} WHERE i.repo_id = ?1 AND p.base_ref = ?2 AND i.state = 'open' AND p.issue_id != ?3 ORDER BY i.number LIMIT ?4`, pull.repo_id, pull.head_ref, pull.issue_id, MAX_CHILDREN, ); } const dbLoaders: StackLoaders = { async parent(pull) { const open = await stackParent(pull); if (open) return open; return pull.stacked_on_issue_id ? loadPullByIssueId(pull.repo_id, pull.stacked_on_issue_id) : null; }, children: stackChildren, }; function entry(pull: PullRow, depth: number, current: boolean): StackEntry { return { issueId: pull.issue_id, number: pull.number, title: pull.title, state: pull.state, merged: pull.merged_at !== null, draft: pull.draft === 1, headRef: pull.head_ref, baseRef: pull.base_ref, depth, current, }; } /** * The stack around `pull`, bottom first: its ancestors (including merged parents it was retargeted * from), itself, then its descendants depth-first. Returns an empty list when the pull is not stacked. */ export async function loadStack(pull: PullRow, loaders: StackLoaders = dbLoaders): Promise { const seen = new Set([pull.issue_id]); const below: PullRow[] = []; let cursor = pull; while (below.length < MAX_STACK_DEPTH) { const parent = await loaders.parent(cursor); if (!parent || seen.has(parent.issue_id)) break; seen.add(parent.issue_id); below.unshift(parent); cursor = parent; } const entries = below.map((parent, index) => entry(parent, index, false)); entries.push(entry(pull, below.length, true)); const visit = async (node: PullRow, depth: number): Promise => { for (const child of await loaders.children(node)) { if (entries.length >= MAX_STACK_SIZE || seen.has(child.issue_id) || depth > MAX_STACK_DEPTH) return; seen.add(child.issue_id); entries.push(entry(child, depth, false)); await visit(child, depth + 1); } }; await visit(pull, below.length + 1); return entries.length > 1 ? entries : []; } /** The open ancestors of `pull`, bottom first, then `pull` itself: the order a stack must merge in. */ export async function mergePath(pull: PullRow): Promise { const path = [pull]; const seen = new Set([pull.issue_id]); let cursor = pull; while (path.length <= MAX_STACK_DEPTH) { const parent = await stackParent(cursor); if (!parent || seen.has(parent.issue_id)) break; seen.add(parent.issue_id); path.unshift(parent); cursor = parent; } return path; } async function clearUpstream(pull: PullRow): Promise { await repoDb(pull.repo_id).run( "UPDATE pull_requests SET restack_upstream_sha = NULL WHERE issue_id = ?", pull.issue_id, ); } export type RestackOutcome = "restacked" | "up_to_date" | "conflict" | "unsupported" | "moved"; type Committer = { name: string; email: string }; /** * Rebases `child`'s own commits onto `onto` and moves its head branch with a compare-and-swap push, * which then runs through the normal push pipeline (new head recorded, checks rerun). The child's own * commits are those after the first of `upstreams` its head contains; if it contains none of them the * history is ambiguous and the restack is left to the author. */ export async function restackPull( child: PullRow, onto: string, upstreams: Array, committer: Committer = SYSTEM_COMMITTER, ): Promise<{ outcome: RestackOutcome; sha?: string }> { if (child.head_repo_id !== child.repo_id || child.state !== "open") return { outcome: "unsupported" }; const repo = await loadRepoById(child.repo_id); if (!repo) return { outcome: "unsupported" }; try { return await withStore(repo.artifacts_name, async (store) => { if (await isAncestor(store, onto, child.head_sha)) { await clearUpstream(child); return { outcome: "up_to_date" as const }; } let from: string | null = null; for (const candidate of upstreams) { if (candidate && (await isAncestor(store, candidate, child.head_sha))) { from = candidate; break; } } if (!from) return { outcome: "unsupported" as const }; const labels = { ours: child.base_ref, theirs: child.head_ref }; const plan = await planRebase(store, onto, child.head_sha, committer, labels, from); if (!plan) return { outcome: "unsupported" as const }; if (plan.kind !== "rebased") { await clearUpstream(child); return { outcome: "up_to_date" as const }; } await pushToArtifacts( repo, [{ ref: `refs/heads/${child.head_ref}`, oldSha: child.head_sha, newSha: plan.sha }], plan.objects, ); await clearUpstream(child); return { outcome: "restacked" as const, sha: plan.sha }; }); } catch (error) { if (error instanceof RebaseConflictError) return { outcome: "conflict" }; if (error instanceof PushRejectedError) return { outcome: "moved" }; throw error; } } const RESTACK_REASONS: Record, string> = { conflict: "Its commits conflict with the new base; restack it locally and force-push", unsupported: "Its history could not be restacked on the server; restack it locally and force-push", moved: "Its head branch moved while restacking; restack it again", }; /** * Restacks `child` onto `onto`, then cascades up the stack: each descendant is replayed onto its * parent's new head, dropping the parent's pre-restack commits. Stops at the first failure on a path. */ export async function restackTree( child: PullRow, onto: string, upstreams: Array, context: { actorId: string | null; committer?: Committer; parentNumber: number; ontoRef: string }, depth = 0, ): Promise { const { outcome, sha } = await restackPull(child, onto, upstreams, context.committer).catch((error: unknown) => { console.error("Restack failed", child.issue_id, error); return { outcome: "unsupported" as const, sha: undefined }; }); if (outcome === "restacked") { await addIssueEvent(child, context.actorId, "restacked", { onto: context.ontoRef, parent: context.parentNumber, }); } else if (outcome !== "up_to_date") { await addIssueEvent(child, context.actorId, "restack_failed", { parent: context.parentNumber, reason: RESTACK_REASONS[outcome], }); } if (outcome !== "restacked" || !sha || depth >= MAX_STACK_DEPTH) return outcome; for (const grandchild of await stackChildren(child)) { await restackTree( grandchild, sha, [grandchild.restack_upstream_sha, child.head_sha, grandchild.base_sha], { ...context, parentNumber: child.number, ontoRef: child.head_ref }, depth + 1, ); } return outcome; } /** * After `parent` merges, points each child at the parent's base and, when the merge rewrote the parent's * commits, replays the child's own commits (and its descendants') onto the merge result. */ export async function restackAfterMerge( parent: PullRow, method: MergeMethod | "push", mergeSha: string, actorId: string | null, ): Promise { const children = await stackChildren(parent); const rewritten = method === "squash" || method === "rebase"; for (const child of children) { await repoDb(child.repo_id).run( `UPDATE pull_requests SET base_ref = ?, base_sha = ?, stacked_on_issue_id = ?, mergeable = 'unknown', restack_upstream_sha = NULL WHERE issue_id = ?`, parent.base_ref, mergeSha, parent.issue_id, child.issue_id, ); await addIssueEvent(child, actorId, "base_changed", { from: child.base_ref, to: parent.base_ref, reason: `#${parent.number} merged`, }); if (!rewritten || child.head_repo_id !== child.repo_id) continue; const retargeted = { ...child, base_ref: parent.base_ref, base_sha: mergeSha }; await restackTree(retargeted, mergeSha, [child.restack_upstream_sha, parent.head_sha, child.base_sha], { actorId, parentNumber: parent.number, ontoRef: parent.base_ref, }); } return children; } /** * Merges the stack from the bottom up to and including `top`: auto-merge is enabled on every open pull * request on the path, so each one lands once its checks pass, its children are retargeted and * restacked, and the next one follows. Requires write access, checked by the caller. */ export async function enableStackMerge( top: PullRow, viewerId: string, method: MergeMethod, ): Promise<{ queued: number[]; baseRef: string }> { const path = await mergePath(top); const blocked = path.find((pull) => pull.state !== "open" || pull.draft === 1); if (blocked) throw new StackError(`#${blocked.number} is ${blocked.draft === 1 ? "a draft" : "not open"}`); const now = Date.now(); for (const pull of path) { await repoDb(pull.repo_id).run( `INSERT INTO pull_auto_merges (issue_id, repo_id, method, message, enabled_by, created_at, head_sha) VALUES (?, ?, ?, '', ?, ?, ?) ON CONFLICT (issue_id) DO UPDATE SET method = excluded.method, enabled_by = excluded.enabled_by, head_sha = excluded.head_sha`, pull.issue_id, pull.repo_id, method, viewerId, now, pull.head_sha, ); await addIssueEvent(pull, viewerId, "auto_merge_enabled", { method, stack: top.number }); } return { queued: path.map((pull) => pull.number), baseRef: path[0]?.base_ref ?? top.base_ref }; } export class StackError extends Error {}