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;
|
||||
}
|
||||
Reference in New Issue
Block a user