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:
co-authored by
Claude Opus 5.5
parent
fc37da9789
commit
a6f887f31c
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user