feat(studio): git wrapper and StudioStore (create course, drafts, save as commit)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:14:47 +01:00
co-authored by Claude Opus 5.5
parent fc37da9789
commit a6f887f31c
4 changed files with 664 additions and 0 deletions
+65
View File
@@ -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<string, string> {
const e: Record<string, string> = {};
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: "[email protected]" };
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 <args>` in `cwd`. Never throws on a non-zero exit; use `gitOk` for that. */
export async function git(cwd: string, args: string[], who?: Identity): Promise<GitResult> {
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<string> {
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<string, Promise<unknown>>();
/** Serialises git work per key (the course's bare repo path) across every store instance in the process. */
export function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
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;
}
+456
View File
@@ -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 = <T>(courseId: string, fn: () => Promise<T>) => 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<string> {
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<string | null> {
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<string[]> {
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<string[]> {
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<void> {
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<string[]> {
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<SaveResult> {
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<DraftStatus> {
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<string> {
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<Diagnostic[]> {
return api.validateDraftUnlocked(courseId, await api.ensureDraft(courseId, authorId));
},
publish(courseId: string, who: Identity & { id: string }): Promise<PublishResult> {
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<Diagnostic[]> {
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<MergeResult> {
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<ConflictFile[]> {
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<void> {
return lock(courseId, async () => {
const dir = await ensureDraftUnlocked(courseId, authorId);
if (await isMerging(dir)) await gitOk(dir, ["merge", "--abort"]);
});
},
async remote(courseId: string): Promise<string | null> {
return remoteUrl(requireManaged(courseId).bare);
},
setRemote(courseId: string, url: string | null): Promise<void> {
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<SyncResult> {
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<typeof createStudioStore>;
+25
View File
@@ -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: "[email protected]" };
export const bob: Identity & { id: string } = { id: "u-bob", name: "Bob Ext", email: "[email protected]" };
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<string> {
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();
}
+118
View File
@@ -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 <[email protected]>");
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/<author>; 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 <[email protected]>|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;
});
});