Files
STCourses/packages/feature-studio/src/server/git.ts
T
Lucas WintherandClaude Opus 5.5 f02e30450c fix(studio): review round 2 - a conversion that fails after its commit recovers without a restart
- 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]>
2026-10-30 21:00:45 +01:00

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