import { describe, expect, test } from "bun:test"; import { conflictRef, decideRef, isMirroredRef, nextEventRunAt, stagingRef, EVENT_GAP_MS } from "./plan"; import type { PlanOptions, RefState } from "./plan"; import { keepOurs, oursResolutions } from "./resolve"; import { verifyMirrorHook } from "./hook-auth"; import { hmacSha256Hex } from "../crypto"; import { mergeRootTrees } from "../git/merge"; import { makeBlob, makeTree } from "../git/objects"; import type { RawObject } from "../git/objects"; const A = "a".repeat(40); const B = "b".repeat(40); const both: PlanOptions = { direction: "both", strategy: "merge", propagateDeletes: false }; function ref(partial: Partial): RefState { return { name: "refs/heads/main", local: A, remote: B, synced: null, relation: "diverged", ...partial }; } describe("decideRef", () => { test("fast-forwards flow in the allowed direction only", () => { expect(decideRef(ref({ relation: "remote-ahead" }), both)).toEqual({ kind: "pull", force: false }); expect(decideRef(ref({ relation: "local-ahead" }), both)).toEqual({ kind: "push", force: false }); expect(decideRef(ref({ relation: "remote-ahead" }), { ...both, direction: "push" })).toEqual({ kind: "none" }); expect(decideRef(ref({ relation: "local-ahead" }), { ...both, direction: "pull" })).toEqual({ kind: "none" }); }); test("diverged branches merge when bidirectional", () => { expect(decideRef(ref({}), both)).toEqual({ kind: "merge" }); }); test("one-way divergence overwrites only with the matching preference", () => { const pull = { ...both, direction: "pull" as const }; expect(decideRef(ref({}), pull).kind).toBe("conflict"); expect(decideRef(ref({}), { ...pull, strategy: "prefer-remote" })).toEqual({ kind: "pull", force: true }); expect(decideRef(ref({}), { ...pull, strategy: "prefer-local" }).kind).toBe("conflict"); const push = { ...both, direction: "push" as const, strategy: "prefer-local" as const }; expect(decideRef(ref({}), push)).toEqual({ kind: "push", force: true }); }); test("unrelated histories never merge silently", () => { expect(decideRef(ref({ relation: "unrelated" }), both).kind).toBe("conflict"); expect(decideRef(ref({ relation: "unrelated" }), { ...both, strategy: "prefer-remote" })).toEqual({ kind: "pull", force: true, }); expect(decideRef(ref({ relation: "unknown" }), { ...both, strategy: "prefer-remote" }).kind).toBe("conflict"); }); test("new refs are created on the other side", () => { expect(decideRef(ref({ local: null }), both)).toEqual({ kind: "pull", force: false }); expect(decideRef(ref({ remote: null }), both)).toEqual({ kind: "push", force: false }); }); test("deletions propagate only when the survivor is unchanged since the last sync", () => { const deletes = { ...both, propagateDeletes: true }; expect(decideRef(ref({ local: null, synced: B }), deletes)).toEqual({ kind: "delete-remote" }); expect(decideRef(ref({ remote: null, local: A, synced: A }), deletes)).toEqual({ kind: "delete-local" }); expect(decideRef(ref({ local: null, synced: A }), deletes)).toEqual({ kind: "pull", force: false }); expect(decideRef(ref({ local: null, synced: B }), both)).toEqual({ kind: "pull", force: false }); }); test("tags are never merged or deleted", () => { const tag = ref({ name: "refs/tags/v1" }); expect(decideRef(tag, both).kind).toBe("conflict"); expect(decideRef({ ...tag, remote: null, synced: A }, { ...both, propagateDeletes: true })).toEqual({ kind: "push", force: false, }); }); }); describe("ref scope", () => { test("filters branches, tags and conflict branches", () => { expect(isMirroredRef("refs/heads/main", [], true)).toBe(true); expect(isMirroredRef("refs/heads/wip/x", ["**", "!wip/**"], true)).toBe(false); expect(isMirroredRef("refs/tags/v1", [], false)).toBe(false); expect(isMirroredRef(conflictRef("refs/heads/main"), [], true)).toBe(false); expect(isMirroredRef("refs/pull/1/head", [], true)).toBe(false); expect(stagingRef("refs/heads/main")).toBe("refs/gitfor/mirror/heads/main"); }); test("event runs debounce and keep a gap between runs", () => { expect(nextEventRunAt(1_000_000, null)).toBe(1_010_000); expect(nextEventRunAt(1_000_000, 990_000)).toBe(990_000 + EVENT_GAP_MS); }); }); describe("conflict resolution", () => { test("keepOurs keeps clean lines and our side of each block", () => { const text = "a\n<<<<<<< gitfor\nours\n=======\ntheirs\n>>>>>>> remote\nb\n"; expect(keepOurs(text, 1)).toBe("a\nours\nb\n"); expect(keepOurs(text, 2)).toBeNull(); expect(keepOurs("a\n=======\nb\n", 0)).toBeNull(); }); test("a preferred-side merge still lands the other side's non-conflicting edits", async () => { const objects = new Map(); const keep = async (object: Promise) => { const value = await object; objects.set(value.sha, value); return value.sha; }; const tree = async (text: string) => keep(makeTree([{ mode: "100644", name: "f.txt", sha: await keep(makeBlob(text)) }])); const base = await tree("one\ntwo\nthree\nfour\nfive\n"); const ours = await tree("ONE\ntwo\nthree\nfour\nfive-ours\n"); const theirs = await tree("one\ntwo\nthree\nfour\nfive-theirs\n"); const decoder = new TextDecoder(); const source = { async tree(sha: string) { const object = objects.get(sha); if (!object) return null; const items: { name: string; mode: string; hash: string }[] = []; let offset = 0; const bytes = object.content; while (offset < bytes.length) { const space = bytes.indexOf(0x20, offset); const nul = bytes.indexOf(0, space); const hash = [...bytes.subarray(nul + 1, nul + 21)].map((b) => b.toString(16).padStart(2, "0")).join(""); items.push({ mode: decoder.decode(bytes.subarray(offset, space)), name: decoder.decode(bytes.subarray(space + 1, nul)), hash, }); offset = nul + 21; } return items; }, async blob(sha: string) { return objects.get(sha)?.content ?? null; }, }; const labels = { ours: "gitfor", theirs: "remote" }; const first = await mergeRootTrees({ source, baseTree: base, oursTree: ours, theirsTree: theirs, labels }); expect(first.conflicts).toHaveLength(1); const resolutions = oursResolutions(first.conflicts); expect(resolutions?.get("f.txt")).toBe("ONE\ntwo\nthree\nfour\nfive-ours\n"); }); }); describe("webhook verification", () => { const secret = "gfm_test_secret"; const body = '{"ref":"refs/heads/main"}'; const request = (headers: Record, query = "") => new Request(`https://gitfor.example.com/integrations/hooks/mirror/r1${query}`, { method: "POST", headers }); test("accepts GitHub, Gitea and GitLab styles", async () => { const hex = await hmacSha256Hex(secret, body); expect(await verifyMirrorHook(secret, request({ "x-hub-signature-256": `sha256=${hex}` }), body)).toBe(true); expect(await verifyMirrorHook(secret, request({ "x-gitea-signature": hex }), body)).toBe(true); expect(await verifyMirrorHook(secret, request({ "x-gitlab-token": secret }), body)).toBe(true); expect(await verifyMirrorHook(secret, request({}, `?secret=${secret}`), body)).toBe(true); }); test("rejects wrong or missing signatures", async () => { const hex = await hmacSha256Hex("other", body); expect(await verifyMirrorHook(secret, request({ "x-hub-signature-256": `sha256=${hex}` }), body)).toBe(false); expect(await verifyMirrorHook(secret, request({ "x-gitlab-token": "" }), body)).toBe(false); expect(await verifyMirrorHook(secret, request({}), body)).toBe(false); }); });