import { env } from "cloudflare:workers"; import { mergeBudget, mergeRootTrees } from "../git/merge"; import type { MergeConflict } from "../git/merge"; import { MergeTooComplexError } from "../git/merge3"; import { makeCommit, signatureNow } from "../git/objects"; import type { RawObject } from "../git/objects"; import { compareCommits } from "../git/history"; import type { GitStore } from "../git/store"; import { publicUrl } from "../events/emit"; import { oursResolutions } from "./resolve"; import type { Relation } from "./plan"; import type { ConflictStrategy } from "~/lib/mirror"; const MAX_LISTED_PATHS = 20; /** How `local` and `remote` relate, from their merge base. */ export async function relate(store: GitStore, local: string, remote: string): Promise { const comparison = await compareCommits(store, local, store, remote); if (comparison.mergeBase === null) return comparison.truncated ? "unknown" : "unrelated"; if (comparison.mergeBase === local) return "remote-ahead"; if (comparison.mergeBase === remote) return "local-ahead"; return "diverged"; } export type MirrorMerge = | { ok: true; sha: string; objects: RawObject[]; resolved: string[] } | { ok: false; reason: string; paths: string[] }; function mirrorSignature() { const host = new URL(publicUrl("/")).hostname; return signatureNow(`${env.FORGE_NAME || "gitfor"} mirror`, `mirror@${host}`); } function describe(conflicts: MergeConflict[]): string[] { return conflicts.slice(0, MAX_LISTED_PATHS).map((conflict) => `${conflict.path} (${conflict.kind})`); } /** * Merges diverged `local` and `remote` tips into one commit both sides can fast-forward to. Non-overlapping * changes merge cleanly; overlapping hunks either stop the merge (`merge`) or keep the preferred side's lines * while every other change from both sides still lands (`prefer-local` / `prefer-remote`). */ export async function mergeTips(input: { store: GitStore; branch: string; local: string; remote: string; strategy: ConflictStrategy; }): Promise { const { store, local, remote } = input; const comparison = await compareCommits(store, local, store, remote); if (!comparison.mergeBase) return { ok: false, reason: "no common history", paths: [] }; const [base, localCommit, remoteCommit] = await Promise.all([ store.commit(comparison.mergeBase), store.commit(local), store.commit(remote), ]); if (!base || !localCommit || !remoteCommit) return { ok: false, reason: "a commit is missing", paths: [] }; const remoteFirst = input.strategy === "prefer-remote"; const ours = remoteFirst ? remoteCommit : localCommit; const theirs = remoteFirst ? localCommit : remoteCommit; const labels = remoteFirst ? { ours: "remote", theirs: "gitfor" } : { ours: "gitfor", theirs: "remote" }; const budget = mergeBudget(); try { const run = (resolutions?: Map) => mergeRootTrees({ source: store, baseTree: base.treeSha, oursTree: ours.treeSha, theirsTree: theirs.treeSha, labels, resolutions, budget, }); let result = await run(); let resolved: string[] = []; if (result.conflicts.length > 0) { if (input.strategy === "merge") { return { ok: false, reason: "conflicting changes", paths: describe(result.conflicts) }; } const resolutions = oursResolutions(result.conflicts); if (!resolutions) return { ok: false, reason: "conflict markers could not be resolved", paths: [] }; resolved = describe(result.conflicts); if (resolutions.size > 0) result = await run(resolutions); } const side = remoteFirst ? "the remote" : "gitfor"; const note = resolved.length > 0 ? `\n\nConflicts resolved in favor of ${side}:\n${resolved.join("\n")}` : ""; const commit = await makeCommit({ tree: result.treeSha, parents: [local, remote], author: mirrorSignature(), committer: mirrorSignature(), message: `Merge remote ${input.branch} (mirror sync)${note}`, }); return { ok: true, sha: commit.sha, objects: [...result.objects, commit], resolved }; } catch (error) { if (error instanceof MergeTooComplexError) return { ok: false, reason: error.message, paths: [] }; throw error; } }