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;
}