import { matchesFilters } from "~/lib/glob"; import type { ConflictStrategy, MirrorDirection } from "~/lib/mirror"; export const ZERO_SHA = "0".repeat(40); export const STAGING_PREFIX = "refs/gitfor/mirror/"; export const CONFLICT_PREFIX = "refs/heads/mirror-conflicts/"; export type Relation = "local-ahead" | "remote-ahead" | "diverged" | "unrelated" | "unknown"; export type RefAction = | { kind: "none" } /** local := remote; `force` when local history is discarded. */ | { kind: "pull"; force: boolean } /** remote := local; `force` when remote history is discarded. */ | { kind: "push"; force: boolean } | { kind: "delete-local" } | { kind: "delete-remote" } | { kind: "merge" } | { kind: "conflict"; reason: string }; export type RefState = { name: string; local: string | null; remote: string | null; /** The sha both sides agreed on after the last successful sync, if any. */ synced: string | null; /** How local and remote relate; only meaningful when both exist and differ. */ relation: Relation; }; export type PlanOptions = { direction: MirrorDirection; strategy: ConflictStrategy; propagateDeletes: boolean; }; const pulls = (direction: MirrorDirection) => direction !== "push"; const pushes = (direction: MirrorDirection) => direction !== "pull"; /** Where the remote's objects for `ref` are parked in the forge repo so they can be compared and merged. */ export function stagingRef(ref: string): string { return `${STAGING_PREFIX}${ref.slice("refs/".length)}`; } export function conflictRef(branchRef: string): string { return `${CONFLICT_PREFIX}${branchRef.slice("refs/heads/".length)}`; } /** Branches (filtered by `branches` globs, all when empty) and optionally tags; conflict branches never sync. */ export function isMirroredRef(ref: string, branches: string[], syncTags: boolean): boolean { if (ref.startsWith(CONFLICT_PREFIX)) return false; if (ref.startsWith("refs/tags/")) return syncTags; if (!ref.startsWith("refs/heads/")) return false; return branches.length === 0 || matchesFilters(branches, ref.slice("refs/heads/".length)); } function missingSide(state: RefState, options: PlanOptions): RefAction { const { local, remote, synced } = state; const isTag = state.name.startsWith("refs/tags/"); if (local === null && remote !== null) { const deletedHere = !isTag && options.propagateDeletes && synced === remote; if (deletedHere && pushes(options.direction)) return { kind: "delete-remote" }; return pulls(options.direction) && !deletedHere ? { kind: "pull", force: false } : { kind: "none" }; } if (remote === null && local !== null) { const deletedThere = !isTag && options.propagateDeletes && synced === local; if (deletedThere && pulls(options.direction)) return { kind: "delete-local" }; return pushes(options.direction) && !deletedThere ? { kind: "push", force: false } : { kind: "none" }; } return { kind: "none" }; } function divergedTag(options: PlanOptions): RefAction { if (options.strategy === "prefer-remote" && pulls(options.direction)) return { kind: "pull", force: true }; if (options.strategy === "prefer-local" && pushes(options.direction)) return { kind: "push", force: true }; return { kind: "conflict", reason: "the tag points at different commits here and on the remote" }; } /** * Decides what one ref needs so both sides agree. Fast-forwards flow in whichever direction is allowed, * diverged branches are merged (bidirectional) or resolved by the preferred side (one-way), and deletions * only propagate when the surviving side is unchanged since the last sync, so a delete never drops new work. */ export function decideRef(state: RefState, options: PlanOptions): RefAction { const { local, remote } = state; if (local === remote) return { kind: "none" }; if (local === null || remote === null) return missingSide(state, options); if (state.name.startsWith("refs/tags/")) return divergedTag(options); switch (state.relation) { case "remote-ahead": return pulls(options.direction) ? { kind: "pull", force: false } : { kind: "none" }; case "local-ahead": return pushes(options.direction) ? { kind: "push", force: false } : { kind: "none" }; case "unknown": return { kind: "conflict", reason: "the histories are too far apart to compare" }; case "diverged": if (options.direction === "both") return { kind: "merge" }; return oneWayOverwrite(options, "both sides have new commits"); case "unrelated": return oneWayOverwrite(options, "the histories share no commits"); } } function oneWayOverwrite(options: PlanOptions, reason: string): RefAction { if (options.strategy === "prefer-remote" && pulls(options.direction)) return { kind: "pull", force: true }; if (options.strategy === "prefer-local" && pushes(options.direction)) return { kind: "push", force: true }; return { kind: "conflict", reason }; } const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; /** Scheduled syncs: daily on the free plan, hourly on Pro (and on instances without billing). */ export function scheduleIntervalMs(hourly: boolean): number { return hourly ? HOUR_MS : DAY_MS; } /** Push- and webhook-triggered syncs per UTC day; triggers past the budget wait for the next scheduled run. */ export function eventBudget(hourly: boolean): number { return hourly ? 500 : 50; } /** Event-triggered syncs coalesce: at most one per this gap, so a burst of pushes costs a single run. */ export const EVENT_GAP_MS = 60_000; export const EVENT_DEBOUNCE_MS = 10_000; /** When the next event-triggered run may start, given the last run start. */ export function nextEventRunAt(now: number, lastRunAt: number | null): number { const earliest = lastRunAt === null ? now : lastRunAt + EVENT_GAP_MS; return Math.max(now + EVENT_DEBOUNCE_MS, earliest); } export function utcDay(now: number): string { return new Date(now).toISOString().slice(0, 10); }