import { describe, expect, test } from "bun:test"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { ada, bob, extGit, newStore, tempDir } from "./helpers"; async function courseWithEdit(text = "# Draft text\n", opts: { allowLocalRemotes?: boolean } = {}) { const s = await newStore(opts); await s.store.createCourse({ id: "c1", title: "C" }, ada); const cur = (await s.store.readFile("c1", ada.id, "lessons/welcome.md"))!; await s.store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: text, baseHash: cur.hash }], "Draft edit"); return { ...s, pub: s.store.paths("c1").published }; } describe("publish", () => { test("merges the draft into main; the draft is level afterwards", async () => { const { store, pub } = await courseWithEdit(); const r = await store.publish("c1", ada); expect(r).toMatchObject({ ok: true, published: true }); expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Draft text\n"); expect(await extGit(pub, "log", "-1", "--format=%an|%s")).toBe("Ada Lovelace|Publish draft/u-ada"); const st = await store.status("c1", ada.id); expect([st.ahead, st.behind]).toEqual([0, 0]); expect(await store.publish("c1", ada)).toMatchObject({ ok: true, published: false }); }); test("a conflicting external commit on main is reported and main keeps the external content", async () => { const { store, pub } = await courseWithEdit("# Studio version\n"); writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n"); await extGit(pub, "commit", "-qam", "External edit"); const head = await extGit(pub, "rev-parse", "HEAD"); const r = await store.publish("c1", ada); expect(r).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# External version\n"); expect(await extGit(pub, "rev-parse", "HEAD")).toBe(head); expect(await extGit(pub, "status", "--porcelain")).toBe(""); expect((await store.status("c1", ada.id)).behind).toBe(1); }); test("uncommitted edits in the published worktree block publishing", async () => { const { store, pub } = await courseWithEdit(); writeFileSync(join(pub, "lessons/welcome.md"), "half-typed in an editor"); const r = await store.publish("c1", ada); expect(r).toEqual({ ok: false, reason: "dirty", files: ["lessons/welcome.md"] }); expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("half-typed in an editor"); }); test("an invalid manifest blocks publishing with diagnostics", async () => { const { store } = await courseWithEdit(); const m = (await store.readFile("c1", ada.id, "stcourse.yaml"))!; await store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: m.content.replace("lessons/welcome.md", "lessons/missing.md"), baseHash: m.hash }], "break"); const r = await store.publish("c1", ada); expect(r.ok).toBe(false); if (r.ok || r.reason !== "invalid") throw new Error("expected invalid"); expect(r.diagnostics[0]!.message).toContain("lessons/missing.md"); }); }); describe("update draft and conflict resolution", () => { test("conflicts are shown with both sides, resolved by the author, then publish succeeds", async () => { const { store, pub } = await courseWithEdit("# Studio version\n"); writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n"); await extGit(pub, "commit", "-qam", "External edit"); const u = await store.updateDraft("c1", ada); expect(u).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); const st = await store.status("c1", ada.id); expect(st.merging).toBe(true); expect(st.conflicts).toEqual(["lessons/welcome.md"]); await expect(store.saveFiles("c1", ada, [{ path: "x.md", content: "x" }], "x")).rejects.toThrow(/merge/); const [c] = await store.conflicts("c1", ada.id); expect(c).toMatchObject({ path: "lessons/welcome.md", ours: "# Studio version\n", theirs: "# External version\n" }); expect(c!.working).toContain("<<<<<<<"); const res = await store.resolve("c1", ada, [{ path: "lessons/welcome.md", content: "# Both versions\n" }]); expect(res.remaining).toEqual([]); expect(res.commit).toBeTruthy(); expect(await store.publish("c1", ada)).toMatchObject({ ok: true, published: true }); expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Both versions\n"); }); test("abort returns the draft to its pre-merge state", async () => { const { store, pub } = await courseWithEdit("# Studio version\n"); writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n"); await extGit(pub, "commit", "-qam", "External edit"); await store.updateDraft("c1", ada); await store.abortMerge("c1", ada.id); const st = await store.status("c1", ada.id); expect(st.merging).toBe(false); expect((await store.readFile("c1", ada.id, "lessons/welcome.md"))!.content).toBe("# Studio version\n"); }); test("a non-conflicting external commit merges cleanly into the draft", async () => { const { store, pub } = await courseWithEdit(); writeFileSync(join(pub, "extra.md"), "external\n"); await extGit(pub, "add", "extra.md"); await extGit(pub, "commit", "-qm", "External add"); expect((await store.updateDraft("c1", ada)).ok).toBe(true); expect((await store.readFile("c1", ada.id, "extra.md"))!.content).toBe("external\n"); }); }); describe("sync", () => { test("no remote: an error, nothing happens", async () => { const { store } = await courseWithEdit(); expect(await store.sync("c1", ada)).toMatchObject({ ok: false, reason: "no-remote" }); }); test("push to an empty remote, pull a remote commit, report a conflicting divergence", async () => { // local bare remotes need the test-only allowLocalRemotes switch; the HTTP feature never sets it const { store, pub } = await courseWithEdit("# Draft text\n", { allowLocalRemotes: true }); await store.publish("c1", ada); const remote = join(tempDir(), "remote.git"); await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote); await store.setRemote("c1", remote); expect(await store.remote("c1")).toBe(remote); expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pushed: true, pulled: false }); expect(await extGit(remote, "rev-parse", "main")).toBe(await extGit(pub, "rev-parse", "HEAD")); // someone else pushes to the remote const other = join(tempDir(), "other"); await extGit(tempDir(), "clone", "-q", remote, other); writeFileSync(join(other, "lessons/welcome.md"), "# Remote version\n"); await extGit(other, "commit", "-qam", "Remote edit"); await extGit(other, "push", "-q", "origin", "main"); expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pulled: true, pushed: false }); expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Remote version\n"); // diverge with a conflict writeFileSync(join(other, "lessons/welcome.md"), "# Remote again\n"); await extGit(other, "commit", "-qam", "Remote edit 2"); await extGit(other, "push", "-q", "origin", "main"); await store.updateDraft("c1", ada); const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Local again\n", baseHash: cur.hash }], "local"); expect((await store.publish("c1", ada)).ok).toBe(true); const head = await extGit(pub, "rev-parse", "HEAD"); expect(await store.sync("c1", ada)).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); expect(await extGit(pub, "rev-parse", "HEAD")).toBe(head); expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Local again\n"); // the author resolves via the draft, then Sync goes through expect(await store.updateDraft("c1", ada, "remote")).toMatchObject({ ok: false, reason: "conflict" }); await store.resolve("c1", ada, [{ path: "lessons/welcome.md", content: "# Merged\n" }]); expect((await store.publish("c1", ada)).ok).toBe(true); expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pushed: true }); expect(await extGit(remote, "show", "main:lessons/welcome.md")).toBe("# Merged"); void bob; }); }); test("ext:: remotes cannot run commands, even with local remotes allowed for tests", async () => { const { store } = await courseWithEdit("# x\n", { allowLocalRemotes: true }); const marker = join(tempDir(), "pwned"); await expect(store.setRemote("c1", `ext::touch%s${marker}`)).rejects.toThrow(/remote/); expect(await Bun.file(marker).exists()).toBe(false); });