- The convert job reloads the course on success and on failure, so learners get the intact import (rolled back) or the published studio course, never a course whose folder moved away. - POST /convert on a course with an incomplete conversion (.converting-<id>, staging or retired folders) queues a job that recovers and finishes it instead of answering 409; the studio page shows "Conversion incomplete" with Retry for the owner. - The 409 for pending import jobs names their kinds and ids; the dead withLock in git.ts is gone; the dot-folder discovery rule is documented in docs/operations.md and spec section 5. - Route tests (SQLite and Postgres) inject a failed cross-filesystem copy, a checksum mismatch and a pre-commit failure. Co-Authored-By: Claude Opus 5.5 <[email protected]>
58 lines
2.3 KiB
TypeScript
58 lines
2.3 KiB
TypeScript
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",
|
|
"-c", "protocol.ext.allow=never", // author-supplied remote URLs must never run commands
|
|
];
|
|
|
|
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";
|
|
// Ignore the machine's global/system git config (url.*.insteadOf, credential helpers, ...).
|
|
e.GIT_CONFIG_GLOBAL = "/dev/null";
|
|
e.GIT_CONFIG_NOSYSTEM = "1";
|
|
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);
|
|
}
|