From c2385b472ce476f5d2db76a97cda5b35c7cad7b4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:15:20 +0100 Subject: [PATCH] feat(studio): publish as merge, update draft, conflict resolution, explicit sync Co-Authored-By: Claude Opus 5.5 --- packages/feature-studio/test/publish.test.ts | 146 +++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 packages/feature-studio/test/publish.test.ts diff --git a/packages/feature-studio/test/publish.test.ts b/packages/feature-studio/test/publish.test.ts new file mode 100644 index 0000000..cc5c33e --- /dev/null +++ b/packages/feature-studio/test/publish.test.ts @@ -0,0 +1,146 @@ +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") { + const s = await newStore(); + 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 () => { + const { store, pub } = await courseWithEdit(); + 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; + }); +});