import { triggerPush } from "../events/push-trigger"; import { receivePackBody } from "../git/ref-sync-protocol"; import type { RelaySource } from "../git/relay"; import { pushToArtifacts } from "../git/writer"; import { screenMirrorPush } from "../integrations/mirror-screen"; import { CONFLICT_PREFIX, ZERO_SHA } from "./plan"; import { pushToRemote } from "./remote"; import type { Plan } from "./sync"; import type { SyncReport } from "~/lib/mirror"; import type { RepoRow } from "~/lib/types"; const MAX_GATE_PASSES = 3; const MAX_TRIGGERED_REFS = 50; function emptyPack(): ReadableStream { return new ReadableStream({ start: (controller) => controller.close() }); } /** * Applies forge-side updates through the push gates (branch protection as the configuring admin with write * permission, push protection), dropping refused refs and re-screening the rest. A refused merge is not * pushed to the remote either, so the two sides never end up with different merge results. * Applied refs fire normal push events, so CI, webhooks and integrations run. Returns ref → new sha. */ export async function applyLocal(input: { repo: RepoRow; plan: Plan; pusherId: string; report: SyncReport; /** Called before push events fire, so the mirror can recognize its own writes when they come back. */ onWrite?: (written: Map) => Promise; }): Promise> { const { repo, plan, report } = input; let commands = plan.local; let clean = false; for (let pass = 0; pass < MAX_GATE_PASSES && commands.length > 0 && !clean; pass++) { const screened = await screenMirrorPush( repo, commands, receivePackBody(commands, emptyPack()), { force: true, pusherId: input.pusherId }, undefined, false, ); await screened.body.cancel().catch(() => undefined); clean = screened.rejected.size === 0; for (const [ref, reason] of screened.rejected) report.refused.push(`${ref}: ${reason}`); commands = commands.filter((command) => !screened.rejected.has(command.name)); } if (!clean) commands = []; const kept = new Set(commands.map((command) => command.name)); plan.remote = plan.remote.filter((command) => !plan.merged.has(command.name) || kept.has(command.name)); if (commands.length === 0) return new Map(); await pushToArtifacts( repo, commands.map((command) => ({ ref: command.name, oldSha: command.oldSha ?? ZERO_SHA, newSha: command.sha })), commands.flatMap((command) => plan.objects.get(command.name) ?? []), ); const written = new Map(commands.map((command) => [command.name, command.sha])); await input.onWrite?.(written); for (const command of commands.slice(0, MAX_TRIGGERED_REFS)) { await triggerPush({ source: "internal", repoId: repo.id, artifactsName: repo.artifacts_name, ref: command.name, before: command.oldSha ?? ZERO_SHA, after: command.sha, pusherId: input.pusherId, }); } for (const command of commands) { if (command.name.startsWith(CONFLICT_PREFIX)) continue; if (command.sha === ZERO_SHA) report.deletedLocal.push(command.name); else if (plan.merged.has(command.name)) report.merged.push(command.name); else report.pulled.push(command.name); } return written; } /** Pushes forge-side changes (and merge results) to the remote. Returns ref → sha the remote accepted. */ export async function applyRemote(input: { self: RelaySource; remote: RelaySource; plan: Plan; haves: string[]; report: SyncReport; }): Promise> { const { plan, report } = input; if (plan.remote.length === 0) return new Map(); const result = await pushToRemote({ self: input.self, remote: input.remote, commands: plan.remote, haves: input.haves, }); for (const [ref, reason] of result.rejected) report.refused.push(`remote ${ref}: ${reason}`); const accepted = plan.remote.filter((command) => result.accepted.has(command.name)); for (const command of accepted) { if (command.sha === ZERO_SHA) report.deletedRemote.push(command.name); else if (!plan.merged.has(command.name)) report.pushed.push(command.name); } return new Map(accepted.map((command) => [command.name, command.sha])); }