From a6f887f31c9da02ff3d5755f9034a8f4df9adcf2 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:14:47 +0100 Subject: [PATCH] feat(studio): git wrapper and StudioStore (create course, drafts, save as commit) Co-Authored-By: Claude Opus 5.5 --- packages/feature-studio/src/server/git.ts | 65 +++ packages/feature-studio/src/server/store.ts | 456 ++++++++++++++++++++ packages/feature-studio/test/helpers.ts | 25 ++ packages/feature-studio/test/store.test.ts | 118 +++++ 4 files changed, 664 insertions(+) create mode 100644 packages/feature-studio/src/server/git.ts create mode 100644 packages/feature-studio/src/server/store.ts create mode 100644 packages/feature-studio/test/helpers.ts create mode 100644 packages/feature-studio/test/store.test.ts diff --git a/packages/feature-studio/src/server/git.ts b/packages/feature-studio/src/server/git.ts new file mode 100644 index 0000000..9ce4dc4 --- /dev/null +++ b/packages/feature-studio/src/server/git.ts @@ -0,0 +1,65 @@ +import { $ } from "bun"; + +export interface Identity { name: string; email: string } +export interface GitResult { code: number; stdout: string; stderr: string } + +/** Settings that make studio git runs deterministic, whatever the machine's global config says. */ +const BASE = [ + "-c", "core.hooksPath=/dev/null", + "-c", "commit.gpgsign=false", + "-c", "core.quotepath=false", + "-c", "merge.conflictstyle=merge", + "-c", "pull.rebase=false", + "-c", "init.defaultBranch=main", +]; + +function env(who?: Identity): Record { + const e: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined && !k.startsWith("GIT_")) e[k] = v; // never inherit GIT_DIR, GIT_INDEX_FILE, ... + } + e.GIT_TERMINAL_PROMPT = "0"; + e.GIT_SSH_COMMAND = "ssh -o BatchMode=yes"; + // A fallback identity so git never refuses a merge/commit; real saves always pass `who`. + const id = who ?? { name: "STCourses", email: "studio@stcourses.invalid" }; + e.GIT_AUTHOR_NAME = id.name; + e.GIT_AUTHOR_EMAIL = id.email; + e.GIT_COMMITTER_NAME = id.name; + e.GIT_COMMITTER_EMAIL = id.email; + return e; +} + +/** Runs `git ` in `cwd`. Never throws on a non-zero exit; use `gitOk` for that. */ +export async function git(cwd: string, args: string[], who?: Identity): Promise { + const r = await $`git ${BASE} ${args}`.cwd(cwd).env(env(who)).quiet().nothrow(); + return { code: r.exitCode, stdout: r.stdout.toString(), stderr: r.stderr.toString() }; +} + +export class GitError extends Error { + constructor(public args: string[], public result: GitResult) { + super(`git ${args.join(" ")} failed (${result.code}): ${result.stderr.trim() || result.stdout.trim()}`); + } +} + +export async function gitOk(cwd: string, args: string[], who?: Identity): Promise { + const r = await git(cwd, args, who); + if (r.code !== 0) throw new GitError(args, r); + return r.stdout; +} + +/** NUL-separated output (`-z`) to a list, so paths with spaces or newlines survive. */ +export function splitZ(out: string): string[] { + return out.split("\0").filter(Boolean); +} + +const locks = new Map>(); + +/** Serialises git work per key (the course's bare repo path) across every store instance in the process. */ +export function withLock(key: string, fn: () => Promise): Promise { + const prev = locks.get(key) ?? Promise.resolve(); + const next = prev.then(fn, fn); + const tail = next.catch(() => {}); + locks.set(key, tail); + tail.then(() => { if (locks.get(key) === tail) locks.delete(key); }); + return next; +} diff --git a/packages/feature-studio/src/server/store.ts b/packages/feature-studio/src/server/store.ts new file mode 100644 index 0000000..cb63def --- /dev/null +++ b/packages/feature-studio/src/server/store.ts @@ -0,0 +1,456 @@ +import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs"; +import { mkdir, rm, unlink, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, normalize, sep } from "node:path"; +import { loadCourse, type Diagnostic } from "@stc/course-format"; +import { git, gitOk, splitZ, withLock, type Identity } from "./git"; + +export type { Identity } from "./git"; + +export const MANIFEST = "stcourse.yaml"; +const ID_RE = /^[a-z0-9][a-z0-9-]*$/; + +export function contentHash(content: string | Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(content).digest("hex"); +} + +/** Branch- and path-safe form of a user id. Distinct ids may collide only if they differ in unsafe characters. */ +export function safeAuthor(authorId: string): string { + const s = authorId.replace(/[^A-Za-z0-9_-]/g, "_"); + return s === "" ? "_" : s; +} + +/** Normalised relative path, or throws. Rejects absolute paths, `..`, `.git` and NUL. */ +export function checkRelPath(p: string): string { + if (typeof p !== "string" || p === "" || p.includes("\0") || isAbsolute(p) || p.includes("\\")) throw new Error(`invalid path: ${JSON.stringify(p)}`); + const n = normalize(p); + const parts = n.split(sep); + if (n === "." || parts[0] === ".." || parts.some((x) => x === ".." || x.toLowerCase() === ".git") || n.endsWith(sep)) { + throw new Error(`invalid path: ${JSON.stringify(p)}`); + } + return n; +} + +export interface FileWrite { + path: string; + content?: string | Uint8Array; + /** Hash of the content the editor started from: `null` = the file must not exist; omitted = no check. */ + baseHash?: string | null; + delete?: boolean; +} + +export interface FileConflict { path: string; current: string | null; currentHash: string | null } +export type SaveResult = { ok: true; commit: string | null } | { ok: false; conflicts: FileConflict[] }; + +export interface DraftStatus { + courseId: string; + branch: string; + publishedBranch: string; + draftHead: string; + publishedHead: string; + /** Draft commits not yet published. */ + ahead: number; + /** Published commits (external edits, pulls, other authors' publishes) not yet in the draft. */ + behind: number; + merging: boolean; + conflicts: string[]; + remote: string | null; + /** Uncommitted changes in the published worktree (someone editing it outside the studio). */ + publishedDirty: string[]; +} + +export type PublishResult = + | { ok: true; commit: string; published: boolean } + | { ok: false; reason: "conflict"; conflicts: string[] } + | { ok: false; reason: "dirty"; files: string[] } + | { ok: false; reason: "invalid"; diagnostics: Diagnostic[] } + | { ok: false; reason: "merging" }; + +export type MergeResult = { ok: true; commit: string } | { ok: false; reason: "conflict"; conflicts: string[] } | { ok: false; reason: "merging" | "no-remote" | "dirty"; message: string }; + +export interface ConflictFile { path: string; base: string | null; ours: string | null; theirs: string | null; working: string | null } + +export type SyncResult = + | { ok: true; pulled: boolean; pushed: boolean; head: string } + | { ok: false; reason: "no-remote" | "dirty" | "network"; message: string } + | { ok: false; reason: "conflict"; conflicts: string[] }; + +export class StudioError extends Error { + constructor(message: string, public status = 400) { super(message); } +} + +export interface StudioStoreOptions { dataDir: string; publishedBranch?: string } + +export function createStudioStore(opts: StudioStoreOptions) { + const dataDir = opts.dataDir; + const main = opts.publishedBranch ?? "main"; + const coursesDir = join(dataDir, "courses"); + const worktreesDir = join(dataDir, "worktrees"); + + const paths = (courseId: string) => { + if (!ID_RE.test(courseId)) throw new StudioError(`invalid course id: ${courseId}`, 404); + const root = join(worktreesDir, courseId); + return { + bare: join(coursesDir, courseId), + root, + published: join(root, "published"), + draftsDir: join(root, "drafts"), + draft: (authorId: string) => join(root, "drafts", safeAuthor(authorId)), + }; + }; + const branchOf = (authorId: string) => `draft/${safeAuthor(authorId)}`; + + const isManaged = (courseId: string): boolean => { + if (!ID_RE.test(courseId)) return false; + const p = paths(courseId); + return existsSync(join(p.bare, "HEAD")) && existsSync(join(p.published, ".git")); + }; + const requireManaged = (courseId: string) => { + if (!isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404); + return paths(courseId); + }; + const lock = (courseId: string, fn: () => Promise) => withLock(paths(courseId).bare, fn); + + const rev = async (cwd: string, ref: string) => (await gitOk(cwd, ["rev-parse", ref])).trim(); + const count = async (cwd: string, range: string) => Number((await gitOk(cwd, ["rev-list", "--count", range])).trim()); + const unmerged = async (cwd: string) => [...new Set(splitZ(await gitOk(cwd, ["diff", "--name-only", "-z", "--diff-filter=U"])))]; + const isMerging = async (cwd: string) => (await git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"])).code === 0; + const dirtyFiles = async (cwd: string) => + splitZ(await gitOk(cwd, ["status", "--porcelain", "-z", "--untracked-files=all"])).map((l) => l.slice(3)).filter(Boolean); + const remoteUrl = async (bare: string) => { + const r = await git(bare, ["remote", "get-url", "origin"]); + return r.code === 0 ? r.stdout.trim() : null; + }; + + /** Absolute path for `rel` inside `root`; also refuses when an existing parent is a symlink out of the root. */ + const inside = (root: string, rel: string): string => { + const n = checkRelPath(rel); + const abs = join(root, n); + const realRoot = realpathSync(root); + let probe = dirname(abs); + while (!existsSync(probe)) probe = dirname(probe); + const realProbe = realpathSync(probe); + if (realProbe !== realRoot && !realProbe.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`); + if (existsSync(abs)) { + const real = realpathSync(abs); + if (real !== realRoot && !real.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`); + } + return abs; + }; + + async function ensureDraftUnlocked(courseId: string, authorId: string): Promise { + const p = requireManaged(courseId); + const dir = p.draft(authorId); + if (existsSync(join(dir, ".git"))) return dir; + mkdirSync(p.draftsDir, { recursive: true }); + await git(p.bare, ["worktree", "prune"]); + const branch = branchOf(authorId); + const exists = (await git(p.bare, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`])).code === 0; + await gitOk(p.bare, exists ? ["worktree", "add", dir, branch] : ["worktree", "add", "-b", branch, dir, main]); + return dir; + } + + async function readFileIn(dir: string, rel: string): Promise<{ content: string; hash: string } | null> { + let abs: string; + try { abs = inside(dir, rel); } catch { return null; } + if (!existsSync(abs) || !statSync(abs).isFile()) return null; + const bytes = new Uint8Array(await Bun.file(abs).arrayBuffer()); + return { content: new TextDecoder().decode(bytes), hash: contentHash(bytes) }; + } + + async function commitAll(dir: string, who: Identity, message: string, pathspecs?: string[]): Promise { + await gitOk(dir, ["add", "-A", "--", ...(pathspecs ?? ["."])]); + if ((await git(dir, ["diff", "--cached", "--quiet"])).code === 0) return null; + await gitOk(dir, ["commit", "-q", "-m", message], who); + return rev(dir, "HEAD"); + } + + async function writeFiles(dir: string, files: FileWrite[]): Promise { + const touched: string[] = []; + for (const f of files) { + const abs = inside(dir, f.path); + if (f.delete) { + if (existsSync(abs)) await unlink(abs); + } else { + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, f.content ?? ""); + } + touched.push(checkRelPath(f.path)); + } + return touched; + } + + const api = { + dataDir, + publishedBranch: main, + paths, + branchOf, + isManaged, + + async listManaged(): Promise { + if (!existsSync(coursesDir)) return []; + return readdirSync(coursesDir).filter((id) => ID_RE.test(id) && isManaged(id)).sort(); + }, + + async createCourse(input: { id: string; title: string; pitch?: string }, who: Identity): Promise { + const { id, title } = input; + if (!ID_RE.test(id)) throw new StudioError(`invalid course id "${id}": use lowercase letters, digits and dashes`); + if (!title.trim()) throw new StudioError("title is required"); + const p = paths(id); + if (existsSync(p.bare) || existsSync(p.root)) throw new StudioError(`course "${id}" already exists`, 409); + await lock(id, async () => { + mkdirSync(coursesDir, { recursive: true }); + const tmp = join(dataDir, "tmp", `new-${id}-${Date.now()}`); + mkdirSync(join(tmp, "lessons"), { recursive: true }); + try { + await gitOk(dataDir, ["init", "-q", "--bare", "-b", main, p.bare]); + const manifest = { + schema: 1, id, title: title.trim(), ...(input.pitch ? { pitch: input.pitch } : {}), + authors: [who.name], visibility: "restricted", source: { kind: "authored" }, + modules: [{ id: "m1", title: "Getting started", lessons: [{ id: "welcome", title: "Welcome", type: "text", file: "lessons/welcome.md" }] }], + }; + await Bun.write(join(tmp, MANIFEST), Bun.YAML.stringify(manifest, null, 2) + "\n"); + await Bun.write(join(tmp, "lessons/welcome.md"), `# Welcome\n\nThis is the first lesson of ${title.trim()}.\n`); + const gd = ["--git-dir", p.bare, "--work-tree", tmp]; + await gitOk(tmp, [...gd, "add", "-A"]); + await gitOk(tmp, [...gd, "commit", "-q", "-m", `Create course ${id}`], who); + mkdirSync(p.root, { recursive: true }); + await gitOk(p.bare, ["worktree", "add", p.published, main]); + } catch (e) { + rmSync(p.bare, { recursive: true, force: true }); + rmSync(p.root, { recursive: true, force: true }); + throw e; + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + }, + + ensureDraft: (courseId: string, authorId: string) => lock(courseId, () => ensureDraftUnlocked(courseId, authorId)), + + async listFiles(courseId: string, authorId: string): Promise { + const dir = await api.ensureDraft(courseId, authorId); + const tracked = splitZ(await gitOk(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"])); + return [...new Set(tracked)].filter((f) => existsSync(join(dir, f))).sort(); + }, + + async readFile(courseId: string, authorId: string, rel: string) { + const dir = await api.ensureDraft(courseId, authorId); + return readFileIn(dir, rel); + }, + + async readPublished(courseId: string, rel: string) { + return readFileIn(requireManaged(courseId).published, rel); + }, + + draftDir: (courseId: string, authorId: string) => api.ensureDraft(courseId, authorId), + + async saveFiles(courseId: string, who: Identity & { id: string }, files: FileWrite[], message: string): Promise { + for (const f of files) checkRelPath(f.path); + if (files.length === 0) throw new StudioError("nothing to save"); + return lock(courseId, async () => { + const dir = await ensureDraftUnlocked(courseId, who.id); + if (await isMerging(dir)) throw new StudioError("the draft has a merge in progress: resolve or abort it first", 409); + const conflicts: FileConflict[] = []; + for (const f of files) { + if (f.baseHash === undefined) continue; + const cur = await readFileIn(dir, f.path); + if ((cur?.hash ?? null) !== f.baseHash) conflicts.push({ path: checkRelPath(f.path), current: cur?.content ?? null, currentHash: cur?.hash ?? null }); + } + if (conflicts.length) return { ok: false, conflicts }; + const touched = await writeFiles(dir, files); + const commit = await commitAll(dir, who, message.trim() || "Edit in studio", touched); + return { ok: true, commit }; + }); + }, + + async status(courseId: string, authorId: string): Promise { + const p = requireManaged(courseId); + const dir = await api.ensureDraft(courseId, authorId); + const branch = branchOf(authorId); + const merging = await isMerging(dir); + return { + courseId, branch, publishedBranch: main, + draftHead: await rev(dir, "HEAD"), + publishedHead: await rev(p.bare, main), + ahead: await count(p.bare, `${main}..${branch}`), + behind: await count(p.bare, `${branch}..${main}`), + merging, + conflicts: merging ? await unmerged(dir) : [], + remote: await remoteUrl(p.bare), + publishedDirty: await dirtyFiles(p.published), + }; + }, + + async publishedHead(courseId: string): Promise { + return rev(requireManaged(courseId).published, "HEAD"); + }, + + /** Validates the draft as the learner loader would; error diagnostics block publishing. */ + async validateDraft(courseId: string, authorId: string): Promise { + return api.validateDraftUnlocked(courseId, await api.ensureDraft(courseId, authorId)); + }, + + publish(courseId: string, who: Identity & { id: string }): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + const dir = await ensureDraftUnlocked(courseId, who.id); + const branch = branchOf(who.id); + if (await isMerging(dir)) return { ok: false, reason: "merging" }; + const dirty = await dirtyFiles(p.published); + if (dirty.length) return { ok: false, reason: "dirty", files: dirty }; + const diags = (await api.validateDraftUnlocked(courseId, dir)).filter((d) => d.level === "error"); + if (diags.length) return { ok: false, reason: "invalid", diagnostics: diags }; + if ((await count(p.bare, `${main}..${branch}`)) === 0) return { ok: true, commit: await rev(p.published, "HEAD"), published: false }; + const r = await git(p.published, ["merge", "--no-ff", "-m", `Publish ${branch}`, branch], who); + if (r.code !== 0) { + const conflicts = await unmerged(p.published); + await git(p.published, ["merge", "--abort"]); + if (!conflicts.length) throw new StudioError(`publish failed: ${r.stderr.trim() || r.stdout.trim()}`, 500); + return { ok: false, reason: "conflict", conflicts }; + } + // Bring the draft level with what was just published, so "behind" stays meaningful. + await git(dir, ["merge", "-q", "--ff-only", main]); + return { ok: true, commit: await rev(p.published, "HEAD"), published: true }; + }); + }, + + async validateDraftUnlocked(courseId: string, dir: string): Promise { + const c = await loadCourse(join(dir, MANIFEST)); + const diags = [...c.diagnostics]; + if (c.manifest && c.manifest.id !== courseId) diags.push({ level: "error", courseId, path: MANIFEST, message: `manifest id "${c.manifest.id}" must stay "${courseId}"` }); + if (c.manifest?.root) diags.push({ level: "error", courseId, path: MANIFEST, message: "studio courses cannot set root" }); + return diags; + }, + + /** Merges the published branch (or the remote's) into the author's draft. Conflicts stay in the draft for the author to resolve. */ + updateDraft(courseId: string, who: Identity & { id: string }, from: "published" | "remote" = "published"): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + const dir = await ensureDraftUnlocked(courseId, who.id); + if (await isMerging(dir)) return { ok: false, reason: "merging", message: "a merge is already in progress" }; + let target = main; + if (from === "remote") { + if (!(await remoteUrl(p.bare))) return { ok: false, reason: "no-remote", message: "no remote is configured" }; + const f = await git(p.bare, ["fetch", "-q", "origin"]); + if (f.code !== 0) return { ok: false, reason: "no-remote", message: f.stderr.trim() }; + target = `origin/${main}`; + if ((await git(p.bare, ["rev-parse", "-q", "--verify", `refs/remotes/${target}`])).code !== 0) return { ok: true, commit: await rev(dir, "HEAD") }; + } + const r = await git(dir, ["merge", "--no-ff", "-m", `Update draft from ${target}`, target], who); + if (r.code !== 0) { + const conflicts = await unmerged(dir); + if (!conflicts.length) { + await git(dir, ["merge", "--abort"]); + throw new StudioError(`update failed: ${r.stderr.trim() || r.stdout.trim()}`, 500); + } + return { ok: false, reason: "conflict", conflicts }; + } + return { ok: true, commit: await rev(dir, "HEAD") }; + }); + }, + + async conflicts(courseId: string, authorId: string): Promise { + const dir = await api.ensureDraft(courseId, authorId); + if (!(await isMerging(dir))) return []; + const stage = async (n: number, path: string) => { + const r = await git(dir, ["show", `:${n}:${path}`]); + return r.code === 0 ? r.stdout : null; + }; + const out: ConflictFile[] = []; + for (const path of await unmerged(dir)) { + out.push({ path, base: await stage(1, path), ours: await stage(2, path), theirs: await stage(3, path), working: (await readFileIn(dir, path))?.content ?? null }); + } + return out; + }, + + /** Writes the author's resolution (`content: null` deletes) and commits the merge once nothing is unmerged. */ + resolve(courseId: string, who: Identity & { id: string }, files: { path: string; content: string | null }[]): Promise<{ ok: true; remaining: string[]; commit: string | null }> { + return lock(courseId, async () => { + const dir = await ensureDraftUnlocked(courseId, who.id); + if (!(await isMerging(dir))) throw new StudioError("no merge in progress", 409); + const touched = await writeFiles(dir, files.map((f) => (f.content === null ? { path: f.path, delete: true } : { path: f.path, content: f.content }))); + await gitOk(dir, ["add", "-A", "--", ...touched]); + const remaining = await unmerged(dir); + if (remaining.length) return { ok: true, remaining, commit: null }; + await gitOk(dir, ["commit", "-q", "--no-edit"], who); + return { ok: true, remaining: [], commit: await rev(dir, "HEAD") }; + }); + }, + + abortMerge(courseId: string, authorId: string): Promise { + return lock(courseId, async () => { + const dir = await ensureDraftUnlocked(courseId, authorId); + if (await isMerging(dir)) await gitOk(dir, ["merge", "--abort"]); + }); + }, + + async remote(courseId: string): Promise { + return remoteUrl(requireManaged(courseId).bare); + }, + + setRemote(courseId: string, url: string | null): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + const cur = await remoteUrl(p.bare); + if (!url) { + if (cur) await gitOk(p.bare, ["remote", "remove", "origin"]); + return; + } + if (/^\s*-/.test(url) || /\s/.test(url.trim())) throw new StudioError("invalid remote url"); + await gitOk(p.bare, cur ? ["remote", "set-url", "origin", url.trim()] : ["remote", "add", "origin", url.trim()]); + // Bare repos created with `init --bare` have no fetch refspec for remote-tracking branches by default. + await gitOk(p.bare, ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"]); + }); + }, + + /** + * Explicit Sync with the remote: pull (fast-forward, or a merge without conflicts) and push the published branch. + * A conflicting divergence is aborted and reported; the author merges the remote into the draft to resolve it. + */ + sync(courseId: string, who: Identity): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + if (!(await remoteUrl(p.bare))) return { ok: false, reason: "no-remote", message: "no remote is configured for this course" }; + const dirty = await dirtyFiles(p.published); + if (dirty.length) return { ok: false, reason: "dirty", message: `uncommitted changes in the published worktree: ${dirty.join(", ")}` }; + const f = await git(p.bare, ["fetch", "-q", "origin"]); + if (f.code !== 0) return { ok: false, reason: "network", message: f.stderr.trim() || "fetch failed" }; + const remoteRef = `refs/remotes/origin/${main}`; + const hasRemote = (await git(p.bare, ["rev-parse", "-q", "--verify", remoteRef])).code === 0; + let pulled = false; + let pushed = false; + if (hasRemote) { + const local = await rev(p.bare, main); + const remote = await rev(p.bare, remoteRef); + if (local !== remote) { + const localBehind = (await git(p.bare, ["merge-base", "--is-ancestor", local, remote])).code === 0; + const remoteBehind = (await git(p.bare, ["merge-base", "--is-ancestor", remote, local])).code === 0; + if (localBehind) { + await gitOk(p.published, ["merge", "-q", "--ff-only", remoteRef]); + pulled = true; + } else if (!remoteBehind) { + const r = await git(p.published, ["merge", "--no-ff", "-m", `Sync with origin/${main}`, remoteRef], who); + if (r.code !== 0) { + const conflicts = await unmerged(p.published); + await git(p.published, ["merge", "--abort"]); + return conflicts.length ? { ok: false, reason: "conflict", conflicts } : { ok: false, reason: "network", message: r.stderr.trim() }; + } + pulled = true; + } + } + } + const ahead = hasRemote ? await count(p.bare, `${remoteRef}..${main}`) : 1; + if (ahead > 0) { + const r = await git(p.bare, ["push", "-q", "origin", `${main}:${main}`]); + if (r.code !== 0) return { ok: false, reason: "network", message: r.stderr.trim() || "push failed" }; + await git(p.bare, ["fetch", "-q", "origin"]); + pushed = true; + } + return { ok: true, pulled, pushed, head: await rev(p.published, "HEAD") }; + }); + }, + }; + return api; +} + +export type StudioStore = ReturnType; diff --git a/packages/feature-studio/test/helpers.ts b/packages/feature-studio/test/helpers.ts new file mode 100644 index 0000000..c4516e8 --- /dev/null +++ b/packages/feature-studio/test/helpers.ts @@ -0,0 +1,25 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { createStudioStore, type Identity } from "../src/server/store"; + +export const ada: Identity & { id: string } = { id: "u-ada", name: "Ada Lovelace", email: "ada@example.org" }; +export const bob: Identity & { id: string } = { id: "u-bob", name: "Bob Ext", email: "bob@example.org" }; + +export function tempDir(prefix = "stc-studio-"): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + +export async function newStore() { + const dataDir = tempDir(); + const store = createStudioStore({ dataDir }); + return { dataDir, store }; +} + +/** Runs git as an outside tool (VS Code, a terminal) would: plain identity, in the given dir. */ +export async function extGit(cwd: string, ...args: string[]): Promise { + const r = await $`git -c user.name=${bob.name} -c user.email=${bob.email} -c commit.gpgsign=false -c core.hooksPath=/dev/null ${args}`.cwd(cwd).quiet().nothrow(); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr.toString()}`); + return r.stdout.toString().trim(); +} diff --git a/packages/feature-studio/test/store.test.ts b/packages/feature-studio/test/store.test.ts new file mode 100644 index 0000000..6377f42 --- /dev/null +++ b/packages/feature-studio/test/store.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadCourse } from "@stc/course-format"; +import { contentHash } from "../src/server/store"; +import { ada, bob, extGit, newStore } from "./helpers"; + +describe("createCourse", () => { + test("makes a bare repo, a published worktree on main and a valid manifest", async () => { + const { dataDir, store } = await newStore(); + await store.createCourse({ id: "intro-git", title: "Intro: Git & you" }, ada); + expect(existsSync(join(dataDir, "courses/intro-git/HEAD"))).toBe(true); + expect(existsSync(join(dataDir, "courses/intro-git/stcourse.yaml"))).toBe(false); + const pub = store.paths("intro-git").published; + expect(pub).toBe(join(dataDir, "worktrees/intro-git/published")); + expect(await extGit(pub, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main"); + const loaded = await loadCourse(join(pub, "stcourse.yaml")); + expect(loaded.status).toBe("ok"); + expect(loaded.manifest?.title).toBe("Intro: Git & you"); + expect(await extGit(pub, "log", "-1", "--format=%an <%ae>")).toBe("Ada Lovelace "); + expect(store.isManaged("intro-git")).toBe(true); + expect(await store.listManaged()).toEqual(["intro-git"]); + }); + + test("rejects bad and duplicate ids", async () => { + const { store } = await newStore(); + await expect(store.createCourse({ id: "Bad Id", title: "x" }, ada)).rejects.toThrow(/id/); + await store.createCourse({ id: "c1", title: "x" }, ada); + await expect(store.createCourse({ id: "c1", title: "y" }, ada)).rejects.toThrow(/exists/); + }); +}); + +describe("drafts and saving", () => { + test("a draft worktree per author on draft/; save commits as the author; published untouched", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const dir = await store.ensureDraft("c1", ada.id); + expect(dir).toBe(store.paths("c1").draft(ada.id)); + expect(await extGit(dir, "rev-parse", "--abbrev-ref", "HEAD")).toBe("draft/u-ada"); + const cur = await store.readFile("c1", ada.id, "lessons/welcome.md"); + expect(cur).not.toBeNull(); + const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Hello draft\n", baseHash: cur!.hash }], "Edit welcome"); + expect(r.ok).toBe(true); + expect(await extGit(dir, "log", "-1", "--format=%an <%ae>|%s")).toBe("Ada Lovelace |Edit welcome"); + expect(readFileSync(join(store.paths("c1").published, "lessons/welcome.md"), "utf8")).not.toContain("Hello draft"); + const st = await store.status("c1", ada.id); + expect(st.ahead).toBe(1); + expect(st.behind).toBe(0); + expect(st.merging).toBe(false); + }); + + test("saving unchanged content makes no commit", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; + const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: cur.content, baseHash: cur.hash }], "noop"); + expect(r).toEqual({ ok: true, commit: null }); + }); + + test("an external edit between load and save is a conflict, not an overwrite", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; + const file = join(store.paths("c1").draft(ada.id), "lessons/welcome.md"); + writeFileSync(file, "edited in VS Code\n"); + const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "studio text\n", baseHash: cur.hash }], "x"); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.conflicts).toEqual([{ path: "lessons/welcome.md", current: "edited in VS Code\n", currentHash: contentHash("edited in VS Code\n") }]); + expect(readFileSync(file, "utf8")).toBe("edited in VS Code\n"); + }); + + test("baseHash null means the file must not exist yet", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "x", baseHash: null }], "x"); + expect(r.ok).toBe(false); + }); + + test("paths with spaces and non-ASCII characters", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const p = "S08 - Containerizing/Æble notes ü.md"; + const r = await store.saveFiles("c1", ada, [{ path: p, content: "æøå\n", baseHash: null }], "Add notes"); + expect(r.ok).toBe(true); + expect((await store.readFile("c1", ada.id, p))?.content).toBe("æøå\n"); + expect(await store.listFiles("c1", ada.id)).toContain(p); + }); + + test("binary content and deletion", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const bytes = new Uint8Array([0, 1, 2, 255]); + expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", content: bytes }], "bin")).ok).toBe(true); + expect(new Uint8Array(readFileSync(join(store.paths("c1").draft(ada.id), "media/a.bin")))).toEqual(bytes); + expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", delete: true }], "rm")).ok).toBe(true); + expect(await store.listFiles("c1", ada.id)).not.toContain("media/a.bin"); + }); + + test("path traversal and .git are rejected", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + for (const bad of ["../x.md", "/etc/passwd", ".git/config", "a/../../x", "", "a\0b"]) { + await expect(store.saveFiles("c1", ada, [{ path: bad, content: "x" }], "x")).rejects.toThrow(/path/i); + } + expect(await store.readFile("c1", ada.id, "../../../etc/passwd")).toBeNull(); + }); + + test("authors get separate drafts; odd author ids are made safe", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const a = await store.ensureDraft("c1", ada.id); + const b = await store.ensureDraft("c1", "bob/../x y"); + expect(a).not.toBe(b); + expect(await extGit(b, "rev-parse", "--abbrev-ref", "HEAD")).toMatch(/^draft\/[A-Za-z0-9_-]+$/); + void bob; + }); +});